mirror of
https://github.com/SigNoz/signoz.git
synced 2026-05-18 16:00:32 +01:00
Compare commits
2 Commits
main
...
ns/waterfa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7a8231ff8 | ||
|
|
4fdee20976 |
@@ -5937,12 +5937,6 @@ components:
|
||||
type: string
|
||||
rootServiceName:
|
||||
type: string
|
||||
serviceNameToTotalDurationMap:
|
||||
additionalProperties:
|
||||
minimum: 0
|
||||
type: integer
|
||||
nullable: true
|
||||
type: object
|
||||
spans:
|
||||
items:
|
||||
$ref: '#/components/schemas/TracedetailtypesWaterfallSpan'
|
||||
|
||||
@@ -10,13 +10,6 @@ export default defineConfig({
|
||||
signoz: {
|
||||
input: {
|
||||
target: '../docs/api/openapi.yml',
|
||||
// Perses' `common.JSONRef` (used by `DashboardGridItem.content`) has a
|
||||
// field tagged `json:"$ref"`, so our spec contains a property literally
|
||||
// named `$ref`.
|
||||
// Orval v8's validator (`@scalar/openapi-parser`) treats every `$ref` key
|
||||
// as a JSON Reference and aborts with `INVALID_REFERENCE` when the value isn't a URI string.
|
||||
// Safe to disable: yes, the spec is generated by `cmd/openapi.go` and gated by backend CI, not hand-edited.
|
||||
unsafeDisableValidation: true,
|
||||
},
|
||||
output: {
|
||||
target: './src/api/generated/services',
|
||||
|
||||
@@ -144,18 +144,18 @@ const routes: AppRoutes[] = [
|
||||
// /trace-old serves V3 (URL-only access). Flip the two `component`
|
||||
// values back to release V3.
|
||||
{
|
||||
path: ROUTES.TRACE_DETAIL_OLD,
|
||||
path: ROUTES.TRACE_DETAIL,
|
||||
exact: true,
|
||||
component: TraceDetail,
|
||||
isPrivate: true,
|
||||
key: 'TRACE_DETAIL_OLD',
|
||||
key: 'TRACE_DETAIL',
|
||||
},
|
||||
{
|
||||
path: ROUTES.TRACE_DETAIL,
|
||||
path: ROUTES.TRACE_DETAIL_OLD,
|
||||
exact: true,
|
||||
component: TraceDetailV3,
|
||||
isPrivate: true,
|
||||
key: 'TRACE_DETAIL',
|
||||
key: 'TRACE_DETAIL_OLD',
|
||||
},
|
||||
{
|
||||
path: ROUTES.SETTINGS,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
* OpenAPI spec version: 0.0.1
|
||||
*/
|
||||
import { useQuery } from 'react-query';
|
||||
import type {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
* OpenAPI spec version: 0.0.1
|
||||
*/
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import type {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
* OpenAPI spec version: 0.0.1
|
||||
*/
|
||||
import { useMutation } from 'react-query';
|
||||
import type {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
* OpenAPI spec version: 0.0.1
|
||||
*/
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import type {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
* OpenAPI spec version: 0.0.1
|
||||
*/
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import type {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
* OpenAPI spec version: 0.0.1
|
||||
*/
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import type {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
* OpenAPI spec version: 0.0.1
|
||||
*/
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import type {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
* OpenAPI spec version: 0.0.1
|
||||
*/
|
||||
import { useQuery } from 'react-query';
|
||||
import type {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
* OpenAPI spec version: 0.0.1
|
||||
*/
|
||||
import { useQuery } from 'react-query';
|
||||
import type {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
* OpenAPI spec version: 0.0.1
|
||||
*/
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import type {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
* OpenAPI spec version: 0.0.1
|
||||
*/
|
||||
import { useQuery } from 'react-query';
|
||||
import type {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
* OpenAPI spec version: 0.0.1
|
||||
*/
|
||||
import { useQuery } from 'react-query';
|
||||
import type {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
* OpenAPI spec version: 0.0.1
|
||||
*/
|
||||
import { useMutation } from 'react-query';
|
||||
import type {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
* OpenAPI spec version: 0.0.1
|
||||
*/
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import type {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
* OpenAPI spec version: 0.0.1
|
||||
*/
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import type {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
* OpenAPI spec version: 0.0.1
|
||||
*/
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import type {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
* OpenAPI spec version: 0.0.1
|
||||
*/
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import type {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
* OpenAPI spec version: 0.0.1
|
||||
*/
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import type {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
* OpenAPI spec version: 0.0.1
|
||||
*/
|
||||
import { useMutation } from 'react-query';
|
||||
import type {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
* OpenAPI spec version: 0.0.1
|
||||
*/
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import type {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
* OpenAPI spec version: 0.0.1
|
||||
*/
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import type {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
* OpenAPI spec version: 0.0.1
|
||||
*/
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import type {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
* OpenAPI spec version: 0.0.1
|
||||
*/
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import type {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
* OpenAPI spec version: 0.0.1
|
||||
*/
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import type {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
* OpenAPI spec version: 0.0.1
|
||||
*/
|
||||
export interface AlertmanagertypesChannelDTO {
|
||||
/**
|
||||
@@ -6894,15 +6895,6 @@ export interface TracedetailtypesEventDTO {
|
||||
timeUnixNano?: number;
|
||||
}
|
||||
|
||||
export type TracedetailtypesGettableWaterfallTraceDTOServiceNameToTotalDurationMapAnyOf =
|
||||
{ [key: string]: number };
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type TracedetailtypesGettableWaterfallTraceDTOServiceNameToTotalDurationMap =
|
||||
TracedetailtypesGettableWaterfallTraceDTOServiceNameToTotalDurationMapAnyOf | null;
|
||||
|
||||
export enum TracedetailtypesSpanAggregationTypeDTO {
|
||||
span_count = 'span_count',
|
||||
execution_time_percentage = 'execution_time_percentage',
|
||||
@@ -7093,10 +7085,6 @@ export interface TracedetailtypesGettableWaterfallTraceDTO {
|
||||
* @type string
|
||||
*/
|
||||
rootServiceName?: string;
|
||||
/**
|
||||
* @type object,null
|
||||
*/
|
||||
serviceNameToTotalDurationMap?: TracedetailtypesGettableWaterfallTraceDTOServiceNameToTotalDurationMap;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
* OpenAPI spec version: 0.0.1
|
||||
*/
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import type {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
* OpenAPI spec version: 0.0.1
|
||||
*/
|
||||
import { useMutation } from 'react-query';
|
||||
import type {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
* OpenAPI spec version: 0.0.1
|
||||
*/
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import type {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
* OpenAPI spec version: 0.0.1
|
||||
*/
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import type {
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Checkbox } from '@signozhq/ui/checkbox';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Input as AntdInput } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import { ArrowRight } from '@signozhq/icons';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
import { OnboardingQuestionHeader } from '../OnboardingQuestionHeader';
|
||||
|
||||
@@ -33,31 +32,11 @@ const interestedInOptions: Record<string, string> = {
|
||||
openSourceTooling: 'Prefer open-source tooling',
|
||||
};
|
||||
|
||||
function seededShuffle<T>(array: T[], seed: string): T[] {
|
||||
const result = [...array];
|
||||
|
||||
let num = 0;
|
||||
for (let i = 0; i < seed.length; i++) {
|
||||
num = Math.imul(num + seed.charCodeAt(i), 2654435761);
|
||||
num = Math.abs(num);
|
||||
}
|
||||
|
||||
for (let i = result.length - 1; i > 0; i--) {
|
||||
num = Math.abs(Math.imul(num, 1664525) + 1013904223);
|
||||
const j = num % (i + 1);
|
||||
[result[i], result[j]] = [result[j], result[i]];
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function AboutSigNozQuestions({
|
||||
signozDetails,
|
||||
setSignozDetails,
|
||||
onNext,
|
||||
}: AboutSigNozQuestionsProps): JSX.Element {
|
||||
const { versionData } = useAppContext();
|
||||
|
||||
const [interestInSignoz, setInterestInSignoz] = useState<string[]>(
|
||||
signozDetails?.interestInSignoz || [],
|
||||
);
|
||||
@@ -69,12 +48,6 @@ export function AboutSigNozQuestions({
|
||||
);
|
||||
const [isNextDisabled, setIsNextDisabled] = useState<boolean>(true);
|
||||
|
||||
const shuffledOptionKeys = useMemo(
|
||||
() =>
|
||||
seededShuffle(Object.keys(interestedInOptions), versionData?.version ?? ''),
|
||||
[versionData?.version],
|
||||
);
|
||||
|
||||
useEffect((): void => {
|
||||
if (
|
||||
discoverSignoz !== '' &&
|
||||
@@ -142,7 +115,7 @@ export function AboutSigNozQuestions({
|
||||
<div className="form-group">
|
||||
<div className="question">What got you interested in SigNoz?</div>
|
||||
<div className="checkbox-grid">
|
||||
{shuffledOptionKeys.map((option: string) => (
|
||||
{Object.keys(interestedInOptions).map((option: string) => (
|
||||
<div key={option} className="checkbox-item">
|
||||
<Checkbox
|
||||
id={`checkbox-${option}`}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { Redirect, useHistory, useLocation } from 'react-router-dom';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
import { Trash2 } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
@@ -26,9 +26,7 @@ import type { AuthzResources } from '../utils';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { capitalize } from 'lodash-es';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import { LicenseStatus } from 'types/api/licensesV3/getActive';
|
||||
import { RoleType } from 'types/roles';
|
||||
import { handleApiError, toAPIError } from 'utils/errorUtils';
|
||||
|
||||
@@ -54,9 +52,8 @@ function RoleDetailsPage(): JSX.Element {
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const { activeLicense, isFetchingActiveLicense } = useAppContext();
|
||||
|
||||
const authzResources: AuthzResources = permissionsType.data;
|
||||
const authzResources = permissionsType.data as unknown as AuthzResources;
|
||||
|
||||
// Extract roleId from URL pathname since useParams doesn't work in nested routing
|
||||
const roleIdMatch = pathname.match(ROLE_ID_REGEX);
|
||||
@@ -161,22 +158,6 @@ function RoleDetailsPage(): JSX.Element {
|
||||
},
|
||||
});
|
||||
|
||||
if (isFetchingActiveLicense) {
|
||||
return (
|
||||
<div className="role-details-page">
|
||||
<Skeleton
|
||||
active
|
||||
paragraph={{ rows: 8 }}
|
||||
className="role-details-skeleton"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (activeLicense?.status !== LicenseStatus.VALID) {
|
||||
return <Redirect to={ROUTES.ROLES_SETTINGS} />;
|
||||
}
|
||||
|
||||
if (!hasReadPermission && readPerms !== null) {
|
||||
return <PermissionDeniedFullPage permissionName="role:read" />;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
} from 'mocks-server/__mockdata__/roles';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { Route, Switch } from 'react-router-dom';
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
@@ -16,7 +15,6 @@ import {
|
||||
} from 'tests/test-utils';
|
||||
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
|
||||
import {
|
||||
invalidLicense,
|
||||
mockUseAuthZDenyAll,
|
||||
mockUseAuthZGrantAll,
|
||||
} from 'tests/authz-test-utils';
|
||||
@@ -232,28 +230,6 @@ describe('RoleDetailsPage', () => {
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('redirects to the roles list when license is not valid', async () => {
|
||||
render(
|
||||
<Switch>
|
||||
<Route path="/settings/roles/:roleId">
|
||||
<RoleDetailsPage />
|
||||
</Route>
|
||||
<Route path="/settings/roles" exact>
|
||||
<div data-testid="roles-list-redirect-target" />
|
||||
</Route>
|
||||
</Switch>,
|
||||
undefined,
|
||||
{
|
||||
initialRoute: `/settings/roles/${CUSTOM_ROLE_ID}`,
|
||||
appContextOverrides: { activeLicense: invalidLicense },
|
||||
},
|
||||
);
|
||||
|
||||
await expect(
|
||||
screen.findByTestId('roles-list-redirect-target'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('permission side panel', () => {
|
||||
beforeEach(() => {
|
||||
// Both hooks mocked so data renders synchronously — no React Query scheduler or MSW round-trip.
|
||||
|
||||
@@ -11,9 +11,7 @@ import { RoleListPermission } from 'hooks/useAuthZ/permissions/role.permissions'
|
||||
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import LineClampedText from 'periscope/components/LineClampedText/LineClampedText';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { LicenseStatus } from 'types/api/licensesV3/getActive';
|
||||
import { RoleType } from 'types/roles';
|
||||
import { toAPIError } from 'utils/errorUtils';
|
||||
|
||||
@@ -32,9 +30,6 @@ interface RolesListingTableProps {
|
||||
function RolesListingTable({
|
||||
searchQuery,
|
||||
}: RolesListingTableProps): JSX.Element {
|
||||
const { activeLicense } = useAppContext();
|
||||
const isValidLicense = activeLicense?.status === LicenseStatus.VALID;
|
||||
|
||||
const { permissions: listPerms, isLoading: isAuthZLoading } = useAuthZ([
|
||||
RoleListPermission,
|
||||
]);
|
||||
@@ -208,27 +203,19 @@ function RolesListingTable({
|
||||
const renderRow = (role: AuthtypesRoleDTO): JSX.Element => (
|
||||
<div
|
||||
key={role.id}
|
||||
className={`roles-table-row${isValidLicense ? ' roles-table-row--clickable' : ''}`}
|
||||
role={isValidLicense ? 'button' : undefined}
|
||||
tabIndex={isValidLicense ? 0 : undefined}
|
||||
onClick={
|
||||
isValidLicense
|
||||
? (): void => {
|
||||
if (role.id) {
|
||||
navigateToRole(role.id, role.name);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onKeyDown={
|
||||
isValidLicense
|
||||
? (e): void => {
|
||||
if ((e.key === 'Enter' || e.key === ' ') && role.id) {
|
||||
navigateToRole(role.id, role.name);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
className="roles-table-row roles-table-row--clickable"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(): void => {
|
||||
if (role.id) {
|
||||
navigateToRole(role.id, role.name);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e): void => {
|
||||
if ((e.key === 'Enter' || e.key === ' ') && role.id) {
|
||||
navigateToRole(role.id, role.name);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="roles-table-cell roles-table-cell--name">
|
||||
{role.name ?? '—'}
|
||||
|
||||
@@ -4,8 +4,6 @@ import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
|
||||
import { RoleCreatePermission } from 'hooks/useAuthZ/permissions/role.permissions';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { LicenseStatus } from 'types/api/licensesV3/getActive';
|
||||
|
||||
import CreateRoleModal from './RolesComponents/CreateRoleModal';
|
||||
import RolesListingTable from './RolesComponents/RolesListingTable';
|
||||
@@ -15,8 +13,6 @@ import './RolesSettings.styles.scss';
|
||||
function RolesSettings(): JSX.Element {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
|
||||
const { activeLicense } = useAppContext();
|
||||
const isValidLicense = activeLicense?.status === LicenseStatus.VALID;
|
||||
|
||||
return (
|
||||
<div className="roles-settings" data-testid="roles-settings">
|
||||
@@ -42,19 +38,17 @@ function RolesSettings(): JSX.Element {
|
||||
value={searchQuery}
|
||||
onChange={(e): void => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
{isValidLicense && (
|
||||
<AuthZTooltip checks={[RoleCreatePermission]}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
className="role-settings-toolbar-button"
|
||||
onClick={(): void => setIsCreateModalOpen(true)}
|
||||
>
|
||||
<Plus size={14} />
|
||||
Custom role
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
)}
|
||||
<AuthZTooltip checks={[RoleCreatePermission]}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
className="role-settings-toolbar-button"
|
||||
onClick={(): void => setIsCreateModalOpen(true)}
|
||||
>
|
||||
<Plus size={14} />
|
||||
Custom role
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
</div>
|
||||
<RolesListingTable searchQuery={searchQuery} />
|
||||
</div>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { fireEvent, render, screen } from 'tests/test-utils';
|
||||
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
|
||||
import { invalidLicense, mockUseAuthZGrantAll } from 'tests/authz-test-utils';
|
||||
import { mockUseAuthZGrantAll } from 'tests/authz-test-utils';
|
||||
|
||||
import RolesSettings from '../RolesSettings';
|
||||
|
||||
@@ -176,26 +176,6 @@ describe('RolesSettings', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('hides the create button and disables row clicks when license is not valid', async () => {
|
||||
render(<RolesSettings />, undefined, {
|
||||
appContextOverrides: { activeLicense: invalidLicense },
|
||||
});
|
||||
|
||||
await expect(screen.findByText('signoz-admin')).resolves.toBeInTheDocument();
|
||||
|
||||
// Create button must be absent
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /custom role/i }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// Rows must not carry the clickable class or button role
|
||||
const rows = document.querySelectorAll('.roles-table-row');
|
||||
rows.forEach((row) => {
|
||||
expect(row).not.toHaveClass('roles-table-row--clickable');
|
||||
expect(row.getAttribute('role')).not.toBe('button');
|
||||
});
|
||||
});
|
||||
|
||||
it('handles invalid dates gracefully by showing fallback', async () => {
|
||||
const invalidRole = {
|
||||
id: 'edge-0009',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
CoretypesResourceRefDTO,
|
||||
CoretypesObjectGroupDTO,
|
||||
CoretypesTypeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
@@ -7,7 +8,11 @@ import type {
|
||||
PermissionConfig,
|
||||
ResourceDefinition,
|
||||
} from '../PermissionSidePanel/PermissionSidePanel.types';
|
||||
import type { AuthzResources } from '../utils';
|
||||
|
||||
type AuthzResources = {
|
||||
resources: CoretypesResourceRefDTO[];
|
||||
relations: Record<string, string[]>;
|
||||
};
|
||||
import { PermissionScope } from '../PermissionSidePanel/PermissionSidePanel.types';
|
||||
import {
|
||||
buildConfig,
|
||||
@@ -36,14 +41,12 @@ jest.mock('../RoleDetails/constants', () => {
|
||||
|
||||
const dashboardResource: AuthzResources['resources'][number] = {
|
||||
kind: 'dashboard',
|
||||
type: 'metaresource',
|
||||
allowedVerbs: ['create', 'read', 'update', 'delete', 'list'],
|
||||
type: 'metaresource' as CoretypesTypeDTO,
|
||||
};
|
||||
|
||||
const alertResource: AuthzResources['resources'][number] = {
|
||||
kind: 'alert',
|
||||
type: 'metaresource',
|
||||
allowedVerbs: ['create', 'read', 'update', 'delete', 'list'],
|
||||
type: 'metaresource' as CoretypesTypeDTO,
|
||||
};
|
||||
|
||||
const baseAuthzResources: AuthzResources = {
|
||||
@@ -54,16 +57,6 @@ const baseAuthzResources: AuthzResources = {
|
||||
},
|
||||
};
|
||||
|
||||
// API payload resource refs — only kind+type, no allowedVerbs (matches CoretypesResourceRefDTO shape)
|
||||
const dashboardResourceRef = {
|
||||
kind: 'dashboard',
|
||||
type: 'metaresource' as CoretypesTypeDTO,
|
||||
};
|
||||
const alertResourceRef = {
|
||||
kind: 'alert',
|
||||
type: 'metaresource' as CoretypesTypeDTO,
|
||||
};
|
||||
|
||||
const resourceDefs: ResourceDefinition[] = [
|
||||
{
|
||||
id: 'metaresource:dashboard',
|
||||
@@ -114,7 +107,7 @@ describe('buildPatchPayload', () => {
|
||||
});
|
||||
|
||||
expect(result.additions).toStrictEqual([
|
||||
{ resource: dashboardResourceRef, selectors: [ID_B] },
|
||||
{ resource: dashboardResource, selectors: [ID_B] },
|
||||
]);
|
||||
expect(result.deletions).toBeNull();
|
||||
});
|
||||
@@ -149,7 +142,7 @@ describe('buildPatchPayload', () => {
|
||||
});
|
||||
|
||||
expect(result.deletions).toStrictEqual([
|
||||
{ resource: dashboardResourceRef, selectors: [ID_B] },
|
||||
{ resource: dashboardResource, selectors: [ID_B] },
|
||||
]);
|
||||
expect(result.additions).toBeNull();
|
||||
});
|
||||
@@ -214,10 +207,10 @@ describe('buildPatchPayload', () => {
|
||||
});
|
||||
|
||||
expect(result.deletions).toStrictEqual([
|
||||
{ resource: dashboardResourceRef, selectors: ['*'] },
|
||||
{ resource: dashboardResource, selectors: ['*'] },
|
||||
]);
|
||||
expect(result.additions).toStrictEqual([
|
||||
{ resource: dashboardResourceRef, selectors: [ID_A, ID_B] },
|
||||
{ resource: dashboardResource, selectors: [ID_A, ID_B] },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -248,7 +241,7 @@ describe('buildPatchPayload', () => {
|
||||
});
|
||||
|
||||
expect(result.deletions).toStrictEqual([
|
||||
{ resource: dashboardResourceRef, selectors: ['*'] },
|
||||
{ resource: dashboardResource, selectors: ['*'] },
|
||||
]);
|
||||
expect(result.additions).toBeNull();
|
||||
});
|
||||
@@ -271,7 +264,7 @@ describe('buildPatchPayload', () => {
|
||||
});
|
||||
|
||||
expect(result.deletions).toStrictEqual([
|
||||
{ resource: dashboardResourceRef, selectors: ['*'] },
|
||||
{ resource: dashboardResource, selectors: ['*'] },
|
||||
]);
|
||||
expect(result.additions).toBeNull();
|
||||
});
|
||||
@@ -294,7 +287,7 @@ describe('buildPatchPayload', () => {
|
||||
});
|
||||
|
||||
expect(result.additions).toStrictEqual([
|
||||
{ resource: dashboardResourceRef, selectors: ['*'] },
|
||||
{ resource: dashboardResource, selectors: ['*'] },
|
||||
]);
|
||||
expect(result.deletions).toBeNull();
|
||||
});
|
||||
@@ -320,7 +313,7 @@ describe('buildPatchPayload', () => {
|
||||
});
|
||||
|
||||
expect(result.deletions).toStrictEqual([
|
||||
{ resource: dashboardResourceRef, selectors: [ID_A, ID_B] },
|
||||
{ resource: dashboardResource, selectors: [ID_A, ID_B] },
|
||||
]);
|
||||
expect(result.additions).toBeNull();
|
||||
});
|
||||
@@ -346,7 +339,7 @@ describe('buildPatchPayload', () => {
|
||||
});
|
||||
|
||||
expect(result.additions).toStrictEqual([
|
||||
{ resource: dashboardResourceRef, selectors: [ID_A] },
|
||||
{ resource: dashboardResource, selectors: [ID_A] },
|
||||
]);
|
||||
expect(result.deletions).toBeNull();
|
||||
});
|
||||
@@ -392,7 +385,7 @@ describe('buildPatchPayload', () => {
|
||||
});
|
||||
|
||||
expect(result.additions).toStrictEqual([
|
||||
{ resource: alertResourceRef, selectors: [ID_B] },
|
||||
{ resource: alertResource, selectors: [ID_B] },
|
||||
]);
|
||||
expect(result.deletions).toBeNull();
|
||||
});
|
||||
@@ -401,7 +394,7 @@ describe('buildPatchPayload', () => {
|
||||
describe('objectsToPermissionConfig', () => {
|
||||
it('maps a wildcard selector to ALL scope', () => {
|
||||
const objects: CoretypesObjectGroupDTO[] = [
|
||||
{ resource: dashboardResourceRef, selectors: ['*'] },
|
||||
{ resource: dashboardResource, selectors: ['*'] },
|
||||
];
|
||||
|
||||
const result = objectsToPermissionConfig(objects, resourceDefs);
|
||||
@@ -414,7 +407,7 @@ describe('objectsToPermissionConfig', () => {
|
||||
|
||||
it('maps specific selectors to ONLY_SELECTED scope with the IDs', () => {
|
||||
const objects: CoretypesObjectGroupDTO[] = [
|
||||
{ resource: dashboardResourceRef, selectors: [ID_A, ID_B] },
|
||||
{ resource: dashboardResource, selectors: [ID_A, ID_B] },
|
||||
];
|
||||
|
||||
const result = objectsToPermissionConfig(objects, resourceDefs);
|
||||
@@ -573,41 +566,4 @@ describe('deriveResourcesForRelation', () => {
|
||||
deriveResourcesForRelation(baseAuthzResources, 'nonexistent'),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
describe('allowedVerbs filtering', () => {
|
||||
it('excludes resources whose allowedVerbs does not include the relation', () => {
|
||||
const authz: AuthzResources = {
|
||||
resources: [
|
||||
{
|
||||
kind: 'dashboard',
|
||||
type: 'metaresource',
|
||||
allowedVerbs: ['create', 'read', 'update', 'delete', 'list'],
|
||||
},
|
||||
{
|
||||
kind: 'alert',
|
||||
type: 'metaresource',
|
||||
allowedVerbs: ['create', 'read', 'update', 'delete', 'list', 'attach'],
|
||||
},
|
||||
],
|
||||
relations: { attach: ['metaresource'] },
|
||||
};
|
||||
|
||||
const result = deriveResourcesForRelation(authz, 'attach');
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe('metaresource:alert');
|
||||
});
|
||||
|
||||
it('requires both type-relation match and allowedVerbs — neither condition alone is sufficient', () => {
|
||||
const authz: AuthzResources = {
|
||||
resources: [
|
||||
{ kind: 'dashboard', type: 'metaresource', allowedVerbs: ['read'] },
|
||||
{ kind: 'role', type: 'role', allowedVerbs: ['create'] },
|
||||
],
|
||||
relations: { create: ['metaresource'] },
|
||||
};
|
||||
|
||||
expect(deriveResourcesForRelation(authz, 'create')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import React from 'react';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import type {
|
||||
CoretypesObjectGroupDTO,
|
||||
CoretypesResourceRefDTO,
|
||||
CoretypesTypeDTO,
|
||||
CoretypesObjectGroupDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { capitalize } from 'lodash-es';
|
||||
@@ -22,11 +21,7 @@ import {
|
||||
} from './RoleDetails/constants';
|
||||
|
||||
export type AuthzResources = {
|
||||
resources: ReadonlyArray<{
|
||||
kind: string;
|
||||
type: string;
|
||||
allowedVerbs: readonly string[];
|
||||
}>;
|
||||
resources: ReadonlyArray<CoretypesResourceRefDTO>;
|
||||
relations: Readonly<Record<string, ReadonlyArray<string>>>;
|
||||
};
|
||||
|
||||
@@ -74,9 +69,7 @@ export function deriveResourcesForRelation(
|
||||
}
|
||||
const supportedTypes = authzResources.relations[relation] ?? [];
|
||||
return authzResources.resources
|
||||
.filter(
|
||||
(r) => supportedTypes.includes(r.type) && r.allowedVerbs.includes(relation),
|
||||
)
|
||||
.filter((r) => supportedTypes.includes(r.type))
|
||||
.map((r) => ({
|
||||
id: `${r.type}:${r.kind}`,
|
||||
kind: r.kind,
|
||||
@@ -148,7 +141,7 @@ export function buildPatchPayload({
|
||||
}
|
||||
const resourceDef: CoretypesResourceRefDTO = {
|
||||
kind: found.kind,
|
||||
type: found.type as CoretypesTypeDTO,
|
||||
type: found.type,
|
||||
};
|
||||
|
||||
const initialScope = initial?.scope ?? PermissionScope.NONE;
|
||||
|
||||
@@ -189,7 +189,7 @@ describe('Tooltip utils', () => {
|
||||
];
|
||||
}
|
||||
|
||||
it('builds tooltip content sorted by value descending with isActive flag set correctly', () => {
|
||||
it('builds tooltip content in series-index order with isActive flag set correctly', () => {
|
||||
const data: AlignedData = [[0], [10], [20], [30]];
|
||||
const series = createSeriesConfig();
|
||||
const dataIndexes = [null, 0, 0, 0];
|
||||
@@ -206,21 +206,21 @@ describe('Tooltip utils', () => {
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
// Sorted by value descending: B (20) before A (10)
|
||||
// Series are returned in series-index order (A=index 1 before B=index 2)
|
||||
expect(result[0]).toMatchObject<Partial<TooltipContentItem>>({
|
||||
label: 'B',
|
||||
value: 20,
|
||||
tooltipValue: 'formatted-20',
|
||||
color: 'color-2',
|
||||
isActive: true,
|
||||
});
|
||||
expect(result[1]).toMatchObject<Partial<TooltipContentItem>>({
|
||||
label: 'A',
|
||||
value: 10,
|
||||
tooltipValue: 'formatted-10',
|
||||
color: '#ff0000',
|
||||
isActive: false,
|
||||
});
|
||||
expect(result[1]).toMatchObject<Partial<TooltipContentItem>>({
|
||||
label: 'B',
|
||||
value: 20,
|
||||
tooltipValue: 'formatted-20',
|
||||
color: 'color-2',
|
||||
isActive: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('skips series with null data index or non-finite values', () => {
|
||||
@@ -274,7 +274,7 @@ describe('Tooltip utils', () => {
|
||||
expect(result[1].value).toBe(30);
|
||||
});
|
||||
|
||||
it('returns items sorted by value descending', () => {
|
||||
it('returns items in series-index order', () => {
|
||||
// Series values in non-sorted order: 3, 1, 4, 2
|
||||
const data: AlignedData = [[0], [3], [1], [4], [2]];
|
||||
const series: Series[] = [
|
||||
@@ -297,7 +297,7 @@ describe('Tooltip utils', () => {
|
||||
decimalPrecision,
|
||||
});
|
||||
|
||||
expect(result.map((item) => item.value)).toStrictEqual([4, 3, 2, 1]);
|
||||
expect(result.map((item) => item.value)).toStrictEqual([3, 1, 4, 2]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -142,7 +142,5 @@ export function buildTooltipContent({
|
||||
}
|
||||
}
|
||||
|
||||
items.sort((a, b) => b.value - a.value);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
@@ -473,7 +473,6 @@ export const SpanDuration = memo(function SpanDuration({
|
||||
const columnDefHelper = createColumnHelper<SpanV3>();
|
||||
|
||||
const ROW_HEIGHT = 28;
|
||||
const WATERFALL_BOTTOM_PADDING = 24;
|
||||
const DEFAULT_SIDEBAR_WIDTH = 450;
|
||||
const MIN_SIDEBAR_WIDTH = 240;
|
||||
const MAX_SIDEBAR_WIDTH = 900;
|
||||
@@ -741,69 +740,53 @@ function Success(props: ISuccessProps): JSX.Element {
|
||||
);
|
||||
}, [spans, sidebarWidth]);
|
||||
|
||||
// Scroll a span to viewport center if it isn't already visible. Shared by
|
||||
// the two effects below — one keyed on interestedSpanId (chevron, boundary
|
||||
// pagination, deep-link to unloaded), the other on selectedSpan (in-window
|
||||
// URL navigation that doesn't mutate interestedSpanId).
|
||||
const scrollSpanIntoView = useCallback(
|
||||
(span: SpanV3, spansList: SpanV3[]): void => {
|
||||
if (!virtualizerRef.current) {
|
||||
return;
|
||||
}
|
||||
const idx = spansList.findIndex((s) => s.span_id === span.span_id);
|
||||
if (idx === -1) {
|
||||
return;
|
||||
}
|
||||
const scrollEl = scrollContainerRef.current;
|
||||
const scrollTop = scrollEl?.scrollTop ?? 0;
|
||||
const viewportHeight = scrollEl?.clientHeight ?? 0;
|
||||
const viewportStartIdx = Math.floor(scrollTop / ROW_HEIGHT);
|
||||
const viewportEndIdx =
|
||||
Math.ceil((scrollTop + viewportHeight) / ROW_HEIGHT) - 1;
|
||||
const isOnScreen =
|
||||
viewportHeight > 0 && idx >= viewportStartIdx && idx <= viewportEndIdx;
|
||||
if (isOnScreen) {
|
||||
return;
|
||||
}
|
||||
setTimeout(() => {
|
||||
virtualizerRef.current?.scrollToIndex(idx, {
|
||||
align: 'center',
|
||||
behavior: 'auto',
|
||||
});
|
||||
const sidebarScrollEl = scrollContainerRef.current?.querySelector(
|
||||
'.resizable-box__content',
|
||||
);
|
||||
if (sidebarScrollEl) {
|
||||
const targetScrollLeft = Math.max(0, span.level * CONNECTOR_WIDTH - 40);
|
||||
(sidebarScrollEl as HTMLElement).scrollLeft = targetScrollLeft;
|
||||
}
|
||||
}, 100);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Scroll to the interested span only when it isn't already on screen.
|
||||
// Covers every entry point uniformly: deep-link, flamegraph click,
|
||||
// filter prev/next, browser back/forward all scroll only if needed;
|
||||
// waterfall row clicks and chevron expand/collapse don't yank the viewport
|
||||
// because the affected row is by definition already visible.
|
||||
useEffect(() => {
|
||||
if (interestedSpanId.spanId !== '') {
|
||||
if (interestedSpanId.spanId !== '' && virtualizerRef.current) {
|
||||
const idx = spans.findIndex(
|
||||
(span) => span.span_id === interestedSpanId.spanId,
|
||||
);
|
||||
if (idx !== -1) {
|
||||
scrollSpanIntoView(spans[idx], spans);
|
||||
const visible = virtualizerRef.current.getVirtualItems();
|
||||
const isOnScreen =
|
||||
visible.length > 0 &&
|
||||
idx >= visible[0].index &&
|
||||
idx <= visible[visible.length - 1].index;
|
||||
|
||||
if (!isOnScreen) {
|
||||
setTimeout(() => {
|
||||
virtualizerRef.current?.scrollToIndex(idx, {
|
||||
align: 'center',
|
||||
behavior: 'auto',
|
||||
});
|
||||
|
||||
// Auto-scroll sidebar horizontally to show the span name
|
||||
const span = spans[idx];
|
||||
const sidebarScrollEl = scrollContainerRef.current?.querySelector(
|
||||
'.resizable-box__content',
|
||||
);
|
||||
if (sidebarScrollEl) {
|
||||
const targetScrollLeft = Math.max(0, span.level * CONNECTOR_WIDTH - 40);
|
||||
sidebarScrollEl.scrollLeft = targetScrollLeft;
|
||||
}
|
||||
}, 400);
|
||||
}
|
||||
|
||||
setSelectedSpan(spans[idx]);
|
||||
}
|
||||
} else {
|
||||
setSelectedSpan((prev) => prev ?? spans[0]);
|
||||
setSelectedSpan((prev) => {
|
||||
if (!prev) {
|
||||
return spans[0];
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
}, [interestedSpanId, setSelectedSpan, spans, scrollSpanIntoView]);
|
||||
|
||||
// Covers URL-driven navigation to an already-loaded span (flamegraph /
|
||||
// filter / browser back) that the interestedSpanId-keyed effect doesn't see.
|
||||
useEffect(() => {
|
||||
if (selectedSpan) {
|
||||
scrollSpanIntoView(selectedSpan, spans);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedSpan, scrollSpanIntoView]);
|
||||
}, [interestedSpanId, setSelectedSpan, spans]);
|
||||
|
||||
const virtualItems = virtualizer.getVirtualItems();
|
||||
const leftRows = leftTable.getRowModel().rows;
|
||||
@@ -863,7 +846,7 @@ function Success(props: ISuccessProps): JSX.Element {
|
||||
<div
|
||||
className={styles.splitBody}
|
||||
style={{
|
||||
minHeight: virtualizer.getTotalSize() + WATERFALL_BOTTOM_PADDING,
|
||||
minHeight: virtualizer.getTotalSize(),
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -74,21 +74,17 @@ function TraceDetailsV3(): JSX.Element {
|
||||
onClose: handleSpanDetailsClose,
|
||||
});
|
||||
|
||||
const allSpansRef = useRef<SpanV3[]>([]);
|
||||
|
||||
// Refetch only when the URL target isn't already loaded. Keeps row clicks
|
||||
// and other in-window URL navigation from triggering a backend window slide.
|
||||
useEffect(() => {
|
||||
const spanId = urlQuery.get('spanId') || '';
|
||||
// Only update interestedSpanId when a new span is selected,
|
||||
// not when it's cleared (panel close) — avoids unnecessary API refetch
|
||||
if (!spanId) {
|
||||
return;
|
||||
}
|
||||
const idx = allSpansRef.current.findIndex((s) => s.span_id === spanId);
|
||||
if (idx !== -1) {
|
||||
setSelectedSpan(allSpansRef.current[idx]);
|
||||
return;
|
||||
}
|
||||
setInterestedSpanId({ spanId, isUncollapsed: true });
|
||||
setInterestedSpanId({
|
||||
spanId,
|
||||
isUncollapsed: true,
|
||||
});
|
||||
}, [urlQuery]);
|
||||
|
||||
// Hardcoded for now — fetch aggregations for all 3 candidate color-by fields
|
||||
@@ -149,10 +145,6 @@ function TraceDetailsV3(): JSX.Element {
|
||||
};
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
allSpansRef.current = allSpans;
|
||||
}, [allSpans]);
|
||||
|
||||
// Frontend mode: expand all parents by default when full data arrives
|
||||
useEffect(() => {
|
||||
if (isFullDataLoaded && allSpans.length > 0) {
|
||||
|
||||
@@ -11,13 +11,6 @@ import type {
|
||||
} from 'hooks/useAuthZ/types';
|
||||
import { rest } from 'msw';
|
||||
import type { RestHandler } from 'msw';
|
||||
import {
|
||||
LicenseEvent,
|
||||
LicensePlatform,
|
||||
type LicenseResModel,
|
||||
LicenseState,
|
||||
LicenseStatus,
|
||||
} from 'types/api/licensesV3/getActive';
|
||||
|
||||
export const AUTHZ_CHECK_URL = `${ENVIRONMENT.baseURL || ''}/api/v1/authz/check`;
|
||||
|
||||
@@ -104,40 +97,6 @@ export function setupAuthzAllow(
|
||||
});
|
||||
}
|
||||
|
||||
export function buildLicense(
|
||||
overrides?: Partial<LicenseResModel>,
|
||||
): LicenseResModel {
|
||||
return {
|
||||
key: 'test-key',
|
||||
status: LicenseStatus.VALID,
|
||||
state: LicenseState.ACTIVATED,
|
||||
platform: LicensePlatform.CLOUD,
|
||||
event_queue: {
|
||||
created_at: '0',
|
||||
event: LicenseEvent.NO_EVENT,
|
||||
scheduled_at: '0',
|
||||
status: '',
|
||||
updated_at: '0',
|
||||
},
|
||||
plan: {
|
||||
created_at: '0',
|
||||
description: '',
|
||||
is_active: true,
|
||||
name: '',
|
||||
updated_at: '0',
|
||||
},
|
||||
plan_id: '0',
|
||||
free_until: '0',
|
||||
updated_at: '0',
|
||||
valid_from: 0,
|
||||
valid_until: 0,
|
||||
created_at: '0',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
export const invalidLicense = buildLicense({ status: LicenseStatus.INVALID });
|
||||
|
||||
export function mockUseAuthZGrantAll(
|
||||
permissions: BrandedPermission[],
|
||||
_options?: UseAuthZOptions,
|
||||
|
||||
@@ -48,7 +48,7 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
|
||||
HOME: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
ALERTS_NEW: ['ADMIN', 'EDITOR'],
|
||||
ORG_SETTINGS: ['ADMIN'],
|
||||
MY_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
|
||||
MY_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
SERVICE_MAP: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
ALL_CHANNELS: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
INGESTION_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
@@ -72,7 +72,7 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
|
||||
NOT_FOUND: ['ADMIN', 'VIEWER', 'EDITOR', 'ANONYMOUS'],
|
||||
PASSWORD_RESET: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
SERVICE_METRICS: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
|
||||
SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
SIGN_UP: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
TRACES_EXPLORER: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
TRACE: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
@@ -98,10 +98,10 @@ export const routePermission: Record<keyof typeof ROUTES, ROLES[]> = {
|
||||
GET_STARTED_AZURE_MONITORING: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
WORKSPACE_LOCKED: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
WORKSPACE_SUSPENDED: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
ROLES_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
|
||||
ROLE_DETAILS: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
|
||||
ROLES_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
ROLE_DETAILS: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
MEMBERS_SETTINGS: ['ADMIN'],
|
||||
SERVICE_ACCOUNTS_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
|
||||
SERVICE_ACCOUNTS_SETTINGS: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
BILLING: ['ADMIN'],
|
||||
SUPPORT: ['ADMIN', 'EDITOR', 'VIEWER', 'ANONYMOUS'],
|
||||
SOMETHING_WENT_WRONG: ['ADMIN', 'EDITOR', 'VIEWER'],
|
||||
|
||||
16
pkg/cache/memorycache/provider.go
vendored
16
pkg/cache/memorycache/provider.go
vendored
@@ -64,8 +64,7 @@ func New(ctx context.Context, settings factory.ProviderSettings, config cache.Co
|
||||
o.ObserveInt64(telemetry.setsRejected, int64(metrics.SetsRejected()), metric.WithAttributes(attributes...))
|
||||
o.ObserveInt64(telemetry.getsDropped, int64(metrics.GetsDropped()), metric.WithAttributes(attributes...))
|
||||
o.ObserveInt64(telemetry.getsKept, int64(metrics.GetsKept()), metric.WithAttributes(attributes...))
|
||||
o.ObserveInt64(telemetry.costUsed, int64(metrics.CostAdded())-int64(metrics.CostEvicted()), metric.WithAttributes(attributes...))
|
||||
o.ObserveInt64(telemetry.totalCost, cc.MaxCost(), metric.WithAttributes(attributes...))
|
||||
o.ObserveInt64(telemetry.totalCost, int64(cc.MaxCost()), metric.WithAttributes(attributes...))
|
||||
return nil
|
||||
},
|
||||
telemetry.cacheRatio,
|
||||
@@ -80,7 +79,6 @@ func New(ctx context.Context, settings factory.ProviderSettings, config cache.Co
|
||||
telemetry.setsRejected,
|
||||
telemetry.getsDropped,
|
||||
telemetry.getsKept,
|
||||
telemetry.costUsed,
|
||||
telemetry.totalCost,
|
||||
)
|
||||
if err != nil {
|
||||
@@ -114,13 +112,11 @@ func (provider *provider) Set(ctx context.Context, orgID valuer.UUID, cacheKey s
|
||||
}
|
||||
|
||||
if cloneable, ok := data.(cachetypes.Cloneable); ok {
|
||||
cost := max(cloneable.Cost(), 1)
|
||||
// Clamp to a minimum of 1: ristretto treats cost 0 specially and we
|
||||
// never want zero-size entries to bypass admission accounting.
|
||||
span.SetAttributes(attribute.Bool("memory.cloneable", true))
|
||||
span.SetAttributes(attribute.Int64("memory.cost", cost))
|
||||
span.SetAttributes(attribute.Int64("memory.cost", 1))
|
||||
toCache := cloneable.Clone()
|
||||
if ok := provider.cc.SetWithTTL(strings.Join([]string{orgID.StringValue(), cacheKey}, "::"), toCache, cost, ttl); !ok {
|
||||
// In case of contention we are choosing to evict the cloneable entries first hence cost is set to 1
|
||||
if ok := provider.cc.SetWithTTL(strings.Join([]string{orgID.StringValue(), cacheKey}, "::"), toCache, 1, ttl); !ok {
|
||||
return errors.New(errors.TypeInternal, errors.CodeInternal, "error writing to cache")
|
||||
}
|
||||
|
||||
@@ -129,15 +125,15 @@ func (provider *provider) Set(ctx context.Context, orgID valuer.UUID, cacheKey s
|
||||
}
|
||||
|
||||
toCache, err := provider.marshalBinary(ctx, data)
|
||||
cost := int64(len(toCache))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cost := max(int64(len(toCache)), 1)
|
||||
|
||||
span.SetAttributes(attribute.Bool("memory.cloneable", false))
|
||||
span.SetAttributes(attribute.Int64("memory.cost", cost))
|
||||
|
||||
if ok := provider.cc.SetWithTTL(strings.Join([]string{orgID.StringValue(), cacheKey}, "::"), toCache, cost, ttl); !ok {
|
||||
if ok := provider.cc.SetWithTTL(strings.Join([]string{orgID.StringValue(), cacheKey}, "::"), toCache, 1, ttl); !ok {
|
||||
return errors.New(errors.TypeInternal, errors.CodeInternal, "error writing to cache")
|
||||
}
|
||||
|
||||
|
||||
43
pkg/cache/memorycache/provider_test.go
vendored
43
pkg/cache/memorycache/provider_test.go
vendored
@@ -31,10 +31,6 @@ func (cloneable *CloneableA) Clone() cachetypes.Cacheable {
|
||||
}
|
||||
}
|
||||
|
||||
func (cloneable *CloneableA) Cost() int64 {
|
||||
return int64(len(cloneable.Key)) + 16
|
||||
}
|
||||
|
||||
func (cloneable *CloneableA) MarshalBinary() ([]byte, error) {
|
||||
return json.Marshal(cloneable)
|
||||
}
|
||||
@@ -169,45 +165,6 @@ func TestSetGetWithDifferentTypes(t *testing.T) {
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// LargeCloneable reports a large byte cost so we can test ristretto eviction
|
||||
// without allocating the full payload in memory.
|
||||
type LargeCloneable struct {
|
||||
Key string
|
||||
CostHint int64
|
||||
}
|
||||
|
||||
func (c *LargeCloneable) Clone() cachetypes.Cacheable {
|
||||
return &LargeCloneable{Key: c.Key, CostHint: c.CostHint}
|
||||
}
|
||||
|
||||
func (c *LargeCloneable) Cost() int64 { return c.CostHint }
|
||||
|
||||
func (c *LargeCloneable) MarshalBinary() ([]byte, error) { return json.Marshal(c) }
|
||||
|
||||
func (c *LargeCloneable) UnmarshalBinary(data []byte) error { return json.Unmarshal(data, c) }
|
||||
|
||||
func TestCloneableExceedingMaxCostIsRejected(t *testing.T) {
|
||||
const maxCost int64 = 1 << 20 // 1 MiB
|
||||
const oversize int64 = 2 << 20 // 2 MiB, larger than the entire cache
|
||||
|
||||
c, err := New(context.Background(), factorytest.NewSettings(), cache.Config{Provider: "memory", Memory: cache.Memory{
|
||||
NumCounters: 10 * 1000,
|
||||
MaxCost: maxCost,
|
||||
}})
|
||||
require.NoError(t, err)
|
||||
|
||||
orgID := valuer.GenerateUUID()
|
||||
const key = "oversize-key"
|
||||
assert.NoError(t, c.Set(context.Background(), orgID, key,
|
||||
&LargeCloneable{Key: key, CostHint: oversize}, time.Minute))
|
||||
|
||||
// Ristretto rejects any entry with cost > MaxCost (policy.go:100). Probe
|
||||
// ristretto directly to confirm no admission, instead of relying on metrics.
|
||||
cc := c.(*provider).cc
|
||||
_, ok := cc.Get(strings.Join([]string{orgID.StringValue(), key}, "::"))
|
||||
assert.False(t, ok, "entry with Cost() > MaxCost must be rejected")
|
||||
}
|
||||
|
||||
func TestCloneableConcurrentSetGet(t *testing.T) {
|
||||
cache, err := New(context.Background(), factorytest.NewSettings(), cache.Config{Provider: "memory", Memory: cache.Memory{
|
||||
NumCounters: 10 * 1000,
|
||||
|
||||
53
pkg/cache/memorycache/telemetry.go
vendored
53
pkg/cache/memorycache/telemetry.go
vendored
@@ -7,18 +7,17 @@ import (
|
||||
|
||||
type telemetry struct {
|
||||
cacheRatio metric.Float64ObservableGauge
|
||||
cacheHits metric.Int64ObservableCounter
|
||||
cacheMisses metric.Int64ObservableCounter
|
||||
costAdded metric.Int64ObservableCounter
|
||||
costEvicted metric.Int64ObservableCounter
|
||||
keysAdded metric.Int64ObservableCounter
|
||||
keysEvicted metric.Int64ObservableCounter
|
||||
keysUpdated metric.Int64ObservableCounter
|
||||
setsDropped metric.Int64ObservableCounter
|
||||
setsRejected metric.Int64ObservableCounter
|
||||
getsDropped metric.Int64ObservableCounter
|
||||
getsKept metric.Int64ObservableCounter
|
||||
costUsed metric.Int64ObservableGauge
|
||||
cacheHits metric.Int64ObservableGauge
|
||||
cacheMisses metric.Int64ObservableGauge
|
||||
costAdded metric.Int64ObservableGauge
|
||||
costEvicted metric.Int64ObservableGauge
|
||||
keysAdded metric.Int64ObservableGauge
|
||||
keysEvicted metric.Int64ObservableGauge
|
||||
keysUpdated metric.Int64ObservableGauge
|
||||
setsDropped metric.Int64ObservableGauge
|
||||
setsRejected metric.Int64ObservableGauge
|
||||
getsDropped metric.Int64ObservableGauge
|
||||
getsKept metric.Int64ObservableGauge
|
||||
totalCost metric.Int64ObservableGauge
|
||||
}
|
||||
|
||||
@@ -29,67 +28,62 @@ func newMetrics(meter metric.Meter) (*telemetry, error) {
|
||||
errs = errors.Join(errs, err)
|
||||
}
|
||||
|
||||
cacheHits, err := meter.Int64ObservableCounter("signoz.cache.hits", metric.WithDescription("Hits is the number of Get calls where a value was found for the corresponding key."))
|
||||
cacheHits, err := meter.Int64ObservableGauge("signoz.cache.hits", metric.WithDescription("Hits is the number of Get calls where a value was found for the corresponding key."))
|
||||
if err != nil {
|
||||
errs = errors.Join(errs, err)
|
||||
}
|
||||
|
||||
cacheMisses, err := meter.Int64ObservableCounter("signoz.cache.misses", metric.WithDescription("Misses is the number of Get calls where a value was not found for the corresponding key"))
|
||||
cacheMisses, err := meter.Int64ObservableGauge("signoz.cache.misses", metric.WithDescription("Misses is the number of Get calls where a value was not found for the corresponding key"))
|
||||
if err != nil {
|
||||
errs = errors.Join(errs, err)
|
||||
}
|
||||
|
||||
costAdded, err := meter.Int64ObservableCounter("signoz.cache.cost.added", metric.WithDescription("CostAdded is the sum of costs that have been added (successful Set calls)"))
|
||||
costAdded, err := meter.Int64ObservableGauge("signoz.cache.cost.added", metric.WithDescription("CostAdded is the sum of costs that have been added (successful Set calls)"))
|
||||
if err != nil {
|
||||
errs = errors.Join(errs, err)
|
||||
}
|
||||
|
||||
costEvicted, err := meter.Int64ObservableCounter("signoz.cache.cost.evicted", metric.WithDescription("CostEvicted is the sum of all costs that have been evicted"))
|
||||
costEvicted, err := meter.Int64ObservableGauge("signoz.cache.cost.evicted", metric.WithDescription("CostEvicted is the sum of all costs that have been evicted"))
|
||||
if err != nil {
|
||||
errs = errors.Join(errs, err)
|
||||
}
|
||||
|
||||
keysAdded, err := meter.Int64ObservableCounter("signoz.cache.keys.added", metric.WithDescription("KeysAdded is the total number of Set calls where a new key-value item was added"))
|
||||
keysAdded, err := meter.Int64ObservableGauge("signoz.cache.keys.added", metric.WithDescription("KeysAdded is the total number of Set calls where a new key-value item was added"))
|
||||
if err != nil {
|
||||
errs = errors.Join(errs, err)
|
||||
}
|
||||
|
||||
keysEvicted, err := meter.Int64ObservableCounter("signoz.cache.keys.evicted", metric.WithDescription("KeysEvicted is the total number of keys evicted"))
|
||||
keysEvicted, err := meter.Int64ObservableGauge("signoz.cache.keys.evicted", metric.WithDescription("KeysEvicted is the total number of keys evicted"))
|
||||
if err != nil {
|
||||
errs = errors.Join(errs, err)
|
||||
}
|
||||
|
||||
keysUpdated, err := meter.Int64ObservableCounter("signoz.cache.keys.updated", metric.WithDescription("KeysUpdated is the total number of Set calls where the value was updated"))
|
||||
keysUpdated, err := meter.Int64ObservableGauge("signoz.cache.keys.updated", metric.WithDescription("KeysUpdated is the total number of Set calls where the value was updated"))
|
||||
if err != nil {
|
||||
errs = errors.Join(errs, err)
|
||||
}
|
||||
|
||||
setsDropped, err := meter.Int64ObservableCounter("signoz.cache.sets.dropped", metric.WithDescription("SetsDropped is the number of Set calls that don't make it into internal buffers (due to contention or some other reason)"))
|
||||
setsDropped, err := meter.Int64ObservableGauge("signoz.cache.sets.dropped", metric.WithDescription("SetsDropped is the number of Set calls that don't make it into internal buffers (due to contention or some other reason)"))
|
||||
if err != nil {
|
||||
errs = errors.Join(errs, err)
|
||||
}
|
||||
|
||||
setsRejected, err := meter.Int64ObservableCounter("signoz.cache.sets.rejected", metric.WithDescription("SetsRejected is the number of Set calls rejected by the policy (TinyLFU)"))
|
||||
setsRejected, err := meter.Int64ObservableGauge("signoz.cache.sets.rejected", metric.WithDescription("SetsRejected is the number of Set calls rejected by the policy (TinyLFU)"))
|
||||
if err != nil {
|
||||
errs = errors.Join(errs, err)
|
||||
}
|
||||
|
||||
getsDropped, err := meter.Int64ObservableCounter("signoz.cache.gets.dropped", metric.WithDescription("GetsDropped is the number of Get calls that don't make it into internal buffers (due to contention or some other reason)"))
|
||||
getsDropped, err := meter.Int64ObservableGauge("signoz.cache.gets.dropped", metric.WithDescription("GetsDropped is the number of Get calls that don't make it into internal buffers (due to contention or some other reason)"))
|
||||
if err != nil {
|
||||
errs = errors.Join(errs, err)
|
||||
}
|
||||
|
||||
getsKept, err := meter.Int64ObservableCounter("signoz.cache.gets.kept", metric.WithDescription("GetsKept is the number of Get calls that make it into internal buffers"))
|
||||
getsKept, err := meter.Int64ObservableGauge("signoz.cache.gets.kept", metric.WithDescription("GetsKept is the number of Get calls that make it into internal buffers"))
|
||||
if err != nil {
|
||||
errs = errors.Join(errs, err)
|
||||
}
|
||||
|
||||
costUsed, err := meter.Int64ObservableGauge("signoz.cache.cost.used", metric.WithDescription("CostUsed is the current retained cost in the cache (CostAdded - CostEvicted)."))
|
||||
if err != nil {
|
||||
errs = errors.Join(errs, err)
|
||||
}
|
||||
|
||||
totalCost, err := meter.Int64ObservableGauge("signoz.cache.total.cost", metric.WithDescription("TotalCost is the configured MaxCost ceiling for the cache."))
|
||||
totalCost, err := meter.Int64ObservableGauge("signoz.cache.total.cost", metric.WithDescription("TotalCost is the available cost configured for the cache"))
|
||||
if err != nil {
|
||||
errs = errors.Join(errs, err)
|
||||
}
|
||||
@@ -111,7 +105,6 @@ func newMetrics(meter metric.Meter) (*telemetry, error) {
|
||||
setsRejected: setsRejected,
|
||||
getsDropped: getsDropped,
|
||||
getsKept: getsKept,
|
||||
costUsed: costUsed,
|
||||
totalCost: totalCost,
|
||||
}, nil
|
||||
}
|
||||
|
||||
4
pkg/cache/rediscache/provider_test.go
vendored
4
pkg/cache/rediscache/provider_test.go
vendored
@@ -29,10 +29,6 @@ func (cacheable *CacheableA) Clone() cachetypes.Cacheable {
|
||||
}
|
||||
}
|
||||
|
||||
func (cacheable *CacheableA) Cost() int64 {
|
||||
return int64(len(cacheable.Key)) + 16
|
||||
}
|
||||
|
||||
func (cacheable *CacheableA) MarshalBinary() ([]byte, error) {
|
||||
return json.Marshal(cacheable)
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ func makeChain(n int) (*tracedetailtypes.WaterfallSpan, map[string]*tracedetailt
|
||||
}
|
||||
|
||||
func getWaterfallTrace(roots []*tracedetailtypes.WaterfallSpan, spanMap map[string]*tracedetailtypes.WaterfallSpan) *tracedetailtypes.WaterfallTrace {
|
||||
return tracedetailtypes.NewWaterfallTrace(0, 0, uint64(len(spanMap)), 0, spanMap, nil, roots, false)
|
||||
return tracedetailtypes.NewWaterfallTrace(0, 0, uint64(len(spanMap)), 0, spanMap, roots, false)
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -335,8 +335,10 @@ func (q *querier) applyFormulas(ctx context.Context, results map[string]*qbtypes
|
||||
}
|
||||
case qbtypes.RequestTypeScalar:
|
||||
result := q.processScalarFormula(ctx, results, formula, req)
|
||||
// For scalar results, apply limit by processScalarFormula itself since it needs to be applied before converting back to scalar format
|
||||
results[name] = result
|
||||
if result != nil {
|
||||
result = q.applySeriesLimit(result, formula.Limit, formula.Order)
|
||||
results[name] = result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -524,9 +526,6 @@ func (q *querier) processScalarFormula(
|
||||
return nil
|
||||
}
|
||||
|
||||
// Apply ordering (and limit) before converting to scalar format.
|
||||
formulaSeries = qbtypes.ApplySeriesLimit(formulaSeries, formula.Order, formula.Limit)
|
||||
|
||||
// Convert back to scalar format
|
||||
scalarResult := &qbtypes.ScalarData{
|
||||
QueryName: formula.Name,
|
||||
|
||||
@@ -1,155 +1,15 @@
|
||||
package querier
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
// scalarInputResult builds a ScalarData result with one group column ("service")
|
||||
// and one aggregation column ("__result"), holding the provided (service, value) rows.
|
||||
func scalarInputResult(queryName string, rows []struct {
|
||||
service string
|
||||
value float64
|
||||
}) *qbtypes.Result {
|
||||
serviceKey := telemetrytypes.TelemetryFieldKey{
|
||||
Name: "service",
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
}
|
||||
resultKey := telemetrytypes.TelemetryFieldKey{
|
||||
Name: "__result",
|
||||
FieldDataType: telemetrytypes.FieldDataTypeFloat64,
|
||||
}
|
||||
|
||||
data := make([][]any, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
data = append(data, []any{r.service, r.value})
|
||||
}
|
||||
|
||||
return &qbtypes.Result{
|
||||
Value: &qbtypes.ScalarData{
|
||||
QueryName: queryName,
|
||||
Columns: []*qbtypes.ColumnDescriptor{
|
||||
{
|
||||
TelemetryFieldKey: serviceKey,
|
||||
QueryName: queryName,
|
||||
Type: qbtypes.ColumnTypeGroup,
|
||||
},
|
||||
{
|
||||
TelemetryFieldKey: resultKey,
|
||||
QueryName: queryName,
|
||||
AggregationIndex: 0,
|
||||
Type: qbtypes.ColumnTypeAggregation,
|
||||
},
|
||||
},
|
||||
Data: data,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessScalarFormula_AppliesOrderAndLimit(t *testing.T) {
|
||||
q := &querier{
|
||||
logger: instrumentationtest.New().Logger(),
|
||||
}
|
||||
|
||||
// Mimic what a dashboard emits: orderBy keyed by the formula name ("F1"),
|
||||
// which applyFormulas rewrites to __result before sorting.
|
||||
orderByFormula := func(name string, dir qbtypes.OrderDirection) []qbtypes.OrderBy {
|
||||
return []qbtypes.OrderBy{
|
||||
{
|
||||
Key: qbtypes.OrderByKey{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: name,
|
||||
},
|
||||
},
|
||||
Direction: dir,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// A+B per service: a=101, b=11, c=2
|
||||
makeInputs := func() map[string]*qbtypes.Result {
|
||||
return map[string]*qbtypes.Result{
|
||||
"A": scalarInputResult("A", []struct {
|
||||
service string
|
||||
value float64
|
||||
}{
|
||||
{"a", 100},
|
||||
{"b", 10},
|
||||
{"c", 1},
|
||||
}),
|
||||
"B": scalarInputResult("B", []struct {
|
||||
service string
|
||||
value float64
|
||||
}{
|
||||
{"a", 1},
|
||||
{"b", 0},
|
||||
{"c", 1},
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
makeReq := func(formula qbtypes.QueryBuilderFormula) *qbtypes.QueryRangeRequest {
|
||||
return &qbtypes.QueryRangeRequest{
|
||||
RequestType: qbtypes.RequestTypeScalar,
|
||||
CompositeQuery: qbtypes.CompositeQuery{
|
||||
Queries: []qbtypes.QueryEnvelope{
|
||||
{Type: qbtypes.QueryTypeBuilder, Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{Name: "A"}},
|
||||
{Type: qbtypes.QueryTypeBuilder, Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{Name: "B"}},
|
||||
{Type: qbtypes.QueryTypeFormula, Spec: formula},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("F1 desc with limit truncates and sorts", func(t *testing.T) {
|
||||
formula := qbtypes.QueryBuilderFormula{
|
||||
Name: "F1",
|
||||
Expression: "A + B",
|
||||
Order: orderByFormula("F1", qbtypes.OrderDirectionDesc),
|
||||
Limit: 2,
|
||||
}
|
||||
|
||||
out := q.applyFormulas(context.Background(), makeInputs(), makeReq(formula))
|
||||
got, ok := out["F1"]
|
||||
require.True(t, ok, "formula result missing")
|
||||
scalar, ok := got.Value.(*qbtypes.ScalarData)
|
||||
require.True(t, ok, "expected *ScalarData, got %T", got.Value)
|
||||
|
||||
// Limit=2 + F1 desc: the two largest __result rows in descending order.
|
||||
require.Len(t, scalar.Data, 2, "limit=2 was ignored before the fix")
|
||||
require.Equal(t, "a", scalar.Data[0][0])
|
||||
require.InDelta(t, 101.0, scalar.Data[0][1].(float64), 1e-9)
|
||||
require.Equal(t, "b", scalar.Data[1][0])
|
||||
require.InDelta(t, 10.0, scalar.Data[1][1].(float64), 1e-9)
|
||||
})
|
||||
|
||||
t.Run("F1 desc without limit sorts all rows", func(t *testing.T) {
|
||||
formula := qbtypes.QueryBuilderFormula{
|
||||
Name: "F1",
|
||||
Expression: "A / B",
|
||||
Order: orderByFormula("F1", qbtypes.OrderDirectionAsc),
|
||||
}
|
||||
|
||||
out := q.applyFormulas(context.Background(), makeInputs(), makeReq(formula))
|
||||
got, ok := out["F1"]
|
||||
require.True(t, ok)
|
||||
scalar, ok := got.Value.(*qbtypes.ScalarData)
|
||||
require.True(t, ok)
|
||||
|
||||
require.Len(t, scalar.Data, 2)
|
||||
require.Equal(t, "c", scalar.Data[0][0])
|
||||
require.InDelta(t, 1.0, scalar.Data[0][1].(float64), 1e-9)
|
||||
require.Equal(t, "a", scalar.Data[1][0])
|
||||
require.InDelta(t, 100.0, scalar.Data[1][1].(float64), 1e-9)
|
||||
})
|
||||
}
|
||||
|
||||
// Multiple series with different number of labels, shouldn't panic and should align labels correctly.
|
||||
func TestConvertTimeSeriesDataToScalar_RaggedLabels(t *testing.T) {
|
||||
label := func(name string, value any) *qbtypes.Label {
|
||||
|
||||
@@ -1160,6 +1160,7 @@ func (r *ClickHouseReader) GetFlamegraphSpansForTrace(ctx context.Context, orgID
|
||||
selectCols += ", attributes_string, attributes_number, attributes_bool, resources_string"
|
||||
}
|
||||
flamegraphQuery := fmt.Sprintf("SELECT %s FROM %s.%s WHERE trace_id=$1 and ts_bucket_start>=$2 and ts_bucket_start<=$3 ORDER BY timestamp ASC, name ASC", selectCols, r.TraceDB, r.traceTableName)
|
||||
fmt.Printf("============== selectCols: %s, flamegraphQuery: %s", selectCols, flamegraphQuery)
|
||||
|
||||
searchScanResponses, err := r.GetSpansForTrace(ctx, traceID, flamegraphQuery)
|
||||
if err != nil {
|
||||
|
||||
@@ -769,13 +769,6 @@ func ParseQueryRangeParams(r *http.Request) (*v3.QueryRangeParamsV3, *model.ApiE
|
||||
return nil, &model.ApiError{Typ: model.ErrorBadData, Err: err}
|
||||
}
|
||||
|
||||
// Clamp the top-level Step for PromQL
|
||||
if queryRangeParams.CompositeQuery.QueryType == v3.QueryTypePromQL {
|
||||
if minStep := common.MinAllowedStepInterval(queryRangeParams.Start, queryRangeParams.End); queryRangeParams.Step < minStep {
|
||||
queryRangeParams.Step = minStep
|
||||
}
|
||||
}
|
||||
|
||||
// prepare the variables for the corresponding query type
|
||||
formattedVars := make(map[string]interface{})
|
||||
for name, value := range queryRangeParams.Variables {
|
||||
|
||||
@@ -41,11 +41,6 @@ func (c *GetWaterfallSpansForTraceWithMetadataCache) Clone() cachetypes.Cacheabl
|
||||
}
|
||||
}
|
||||
|
||||
func (c *GetWaterfallSpansForTraceWithMetadataCache) Cost() int64 {
|
||||
const perSpanBytes = 256
|
||||
return int64(c.TotalSpans) * perSpanBytes
|
||||
}
|
||||
|
||||
func (c *GetWaterfallSpansForTraceWithMetadataCache) MarshalBinary() (data []byte, err error) {
|
||||
return json.Marshal(c)
|
||||
}
|
||||
@@ -71,16 +66,6 @@ func (c *GetFlamegraphSpansForTraceCache) Clone() cachetypes.Cacheable {
|
||||
}
|
||||
}
|
||||
|
||||
func (c *GetFlamegraphSpansForTraceCache) Cost() int64 {
|
||||
const perSpanBytes = 128
|
||||
var spans int64
|
||||
for _, row := range c.SelectedSpans {
|
||||
spans += int64(len(row))
|
||||
}
|
||||
spans += int64(len(c.TraceRoots))
|
||||
return spans * perSpanBytes
|
||||
}
|
||||
|
||||
func (c *GetFlamegraphSpansForTraceCache) MarshalBinary() (data []byte, err error) {
|
||||
return json.Marshal(c)
|
||||
}
|
||||
|
||||
@@ -18,10 +18,6 @@ type Cloneable interface {
|
||||
// Creates a deep copy of the Cacheable. This method is useful for memory caches to avoid the need for serialization/deserialization. It also prevents
|
||||
// race conditions in the memory cache.
|
||||
Clone() Cacheable
|
||||
// Cost returns the weight of this entry for cost-based cache accounting
|
||||
// and eviction. Typically derived from the approximate retained byte size,
|
||||
// but the value represents cache cost, not literal bytes.
|
||||
Cost() int64
|
||||
}
|
||||
|
||||
func NewSha1CacheKey(val string) string {
|
||||
|
||||
@@ -59,21 +59,3 @@ func (c *CachedData) Clone() cachetypes.Cacheable {
|
||||
|
||||
return clonedCachedData
|
||||
}
|
||||
|
||||
// Cost approximates the retained bytes of this CachedData for use as the
|
||||
// ristretto cache cost. The dominant contributor is the serialized bucket
|
||||
// values (json.RawMessage); other fields are fixed-size or small strings.
|
||||
func (c *CachedData) Cost() int64 {
|
||||
var size int64
|
||||
for _, b := range c.Buckets {
|
||||
if b == nil {
|
||||
continue
|
||||
}
|
||||
// Value is the bulk of the payload
|
||||
size += int64(len(b.Value))
|
||||
}
|
||||
for _, w := range c.Warnings {
|
||||
size += int64(len(w))
|
||||
}
|
||||
return size
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ func buildTraceFromSpans(spans ...*WaterfallSpan) *WaterfallTrace {
|
||||
endTime = end
|
||||
}
|
||||
}
|
||||
return NewWaterfallTrace(startTime, endTime, uint64(len(spanMap)), 0, spanMap, nil, nil, false)
|
||||
return NewWaterfallTrace(startTime, endTime, uint64(len(spanMap)), 0, spanMap, nil, false)
|
||||
}
|
||||
|
||||
var (
|
||||
|
||||
@@ -20,50 +20,45 @@ type TraceSummary struct {
|
||||
|
||||
// WaterfallTrace holds processed trace data with childern populated in spans.
|
||||
type WaterfallTrace struct {
|
||||
StartTime uint64 `json:"startTime"`
|
||||
EndTime uint64 `json:"endTime"`
|
||||
TotalSpans uint64 `json:"totalSpans"`
|
||||
TotalErrorSpans uint64 `json:"totalErrorSpans"`
|
||||
ServiceNameToTotalDurationMap map[string]uint64 `json:"serviceNameToTotalDurationMap"`
|
||||
SpanIDToSpanNodeMap map[string]*WaterfallSpan `json:"spanIdToSpanNodeMap"`
|
||||
TraceRoots []*WaterfallSpan `json:"traceRoots"`
|
||||
HasMissingSpans bool `json:"hasMissingSpans"`
|
||||
StartTime uint64 `json:"startTime"`
|
||||
EndTime uint64 `json:"endTime"`
|
||||
TotalSpans uint64 `json:"totalSpans"`
|
||||
TotalErrorSpans uint64 `json:"totalErrorSpans"`
|
||||
SpanIDToSpanNodeMap map[string]*WaterfallSpan `json:"spanIdToSpanNodeMap"`
|
||||
TraceRoots []*WaterfallSpan `json:"traceRoots"`
|
||||
HasMissingSpans bool `json:"hasMissingSpans"`
|
||||
}
|
||||
|
||||
// GettableWaterfallTrace is the response for the v3 waterfall API.
|
||||
type GettableWaterfallTrace struct {
|
||||
StartTimestampMillis uint64 `json:"startTimestampMillis"`
|
||||
EndTimestampMillis uint64 `json:"endTimestampMillis"`
|
||||
RootServiceName string `json:"rootServiceName"`
|
||||
RootServiceEntryPoint string `json:"rootServiceEntryPoint"`
|
||||
TotalSpansCount uint64 `json:"totalSpansCount"`
|
||||
TotalErrorSpansCount uint64 `json:"totalErrorSpansCount"`
|
||||
// Deprecated: use Aggregations with SpanAggregationExecutionTimePercentage on the service.name field instead.
|
||||
ServiceNameToTotalDurationMap map[string]uint64 `json:"serviceNameToTotalDurationMap"`
|
||||
Spans []*WaterfallSpan `json:"spans"`
|
||||
HasMissingSpans bool `json:"hasMissingSpans"`
|
||||
UncollapsedSpans []string `json:"uncollapsedSpans"`
|
||||
HasMore bool `json:"hasMore"`
|
||||
Aggregations []SpanAggregationResult `json:"aggregations"`
|
||||
StartTimestampMillis uint64 `json:"startTimestampMillis"`
|
||||
EndTimestampMillis uint64 `json:"endTimestampMillis"`
|
||||
RootServiceName string `json:"rootServiceName"`
|
||||
RootServiceEntryPoint string `json:"rootServiceEntryPoint"`
|
||||
TotalSpansCount uint64 `json:"totalSpansCount"`
|
||||
TotalErrorSpansCount uint64 `json:"totalErrorSpansCount"`
|
||||
Spans []*WaterfallSpan `json:"spans"`
|
||||
HasMissingSpans bool `json:"hasMissingSpans"`
|
||||
UncollapsedSpans []string `json:"uncollapsedSpans"`
|
||||
HasMore bool `json:"hasMore"`
|
||||
Aggregations []SpanAggregationResult `json:"aggregations"`
|
||||
}
|
||||
|
||||
// NewWaterfallTrace constructs a WaterfallTrace from processed span data.
|
||||
func NewWaterfallTrace(
|
||||
startTime, endTime, totalSpans, totalErrorSpans uint64,
|
||||
spanIDToSpanNodeMap map[string]*WaterfallSpan,
|
||||
serviceNameToTotalDurationMap map[string]uint64,
|
||||
traceRoots []*WaterfallSpan,
|
||||
hasMissingSpans bool,
|
||||
) *WaterfallTrace {
|
||||
return &WaterfallTrace{
|
||||
StartTime: startTime,
|
||||
EndTime: endTime,
|
||||
TotalSpans: totalSpans,
|
||||
TotalErrorSpans: totalErrorSpans,
|
||||
SpanIDToSpanNodeMap: spanIDToSpanNodeMap,
|
||||
ServiceNameToTotalDurationMap: serviceNameToTotalDurationMap,
|
||||
TraceRoots: traceRoots,
|
||||
HasMissingSpans: hasMissingSpans,
|
||||
StartTime: startTime,
|
||||
EndTime: endTime,
|
||||
TotalSpans: totalSpans,
|
||||
TotalErrorSpans: totalErrorSpans,
|
||||
SpanIDToSpanNodeMap: spanIDToSpanNodeMap,
|
||||
TraceRoots: traceRoots,
|
||||
HasMissingSpans: hasMissingSpans,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,7 +119,6 @@ func NewWaterfallTraceFromSpans(spans []StorableSpan) *WaterfallTrace {
|
||||
uint64(len(spans)),
|
||||
totalErrorSpans,
|
||||
spanIDToSpanNodeMap,
|
||||
calculateServiceTime(spanIDToSpanNodeMap),
|
||||
traceRoots,
|
||||
hasMissingSpans,
|
||||
)
|
||||
@@ -206,23 +200,19 @@ func (wt *WaterfallTrace) CalculateUncollapsedSpanIDs(uncollapsedSpanIDs []strin
|
||||
}
|
||||
|
||||
func (wt *WaterfallTrace) Clone() cachetypes.Cacheable {
|
||||
copyOfServiceNameToTotalDurationMap := make(map[string]uint64)
|
||||
maps.Copy(copyOfServiceNameToTotalDurationMap, wt.ServiceNameToTotalDurationMap)
|
||||
|
||||
copyOfSpanIDToSpanNodeMap := make(map[string]*WaterfallSpan)
|
||||
maps.Copy(copyOfSpanIDToSpanNodeMap, wt.SpanIDToSpanNodeMap)
|
||||
|
||||
copyOfTraceRoots := make([]*WaterfallSpan, len(wt.TraceRoots))
|
||||
copy(copyOfTraceRoots, wt.TraceRoots)
|
||||
return &WaterfallTrace{
|
||||
StartTime: wt.StartTime,
|
||||
EndTime: wt.EndTime,
|
||||
TotalSpans: wt.TotalSpans,
|
||||
TotalErrorSpans: wt.TotalErrorSpans,
|
||||
ServiceNameToTotalDurationMap: copyOfServiceNameToTotalDurationMap,
|
||||
SpanIDToSpanNodeMap: copyOfSpanIDToSpanNodeMap,
|
||||
TraceRoots: copyOfTraceRoots,
|
||||
HasMissingSpans: wt.HasMissingSpans,
|
||||
StartTime: wt.StartTime,
|
||||
EndTime: wt.EndTime,
|
||||
TotalSpans: wt.TotalSpans,
|
||||
TotalErrorSpans: wt.TotalErrorSpans,
|
||||
SpanIDToSpanNodeMap: copyOfSpanIDToSpanNodeMap,
|
||||
TraceRoots: copyOfTraceRoots,
|
||||
HasMissingSpans: wt.HasMissingSpans,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,11 +247,6 @@ func NewGettableWaterfallTrace(
|
||||
rootServiceEntryPoint = traceData.TraceRoots[0].Name
|
||||
}
|
||||
|
||||
serviceDurationsMillis := make(map[string]uint64, len(traceData.ServiceNameToTotalDurationMap))
|
||||
for svc, dur := range traceData.ServiceNameToTotalDurationMap {
|
||||
serviceDurationsMillis[svc] = dur / 1_000_000
|
||||
}
|
||||
|
||||
// convert start timestamp to millis because client is expecting it in millis
|
||||
for _, span := range selectedSpans {
|
||||
span.TimeUnix = span.TimeUnix / 1_000_000
|
||||
@@ -277,18 +262,17 @@ func NewGettableWaterfallTrace(
|
||||
}
|
||||
|
||||
return &GettableWaterfallTrace{
|
||||
Spans: selectedSpans,
|
||||
UncollapsedSpans: uncollapsedSpans,
|
||||
StartTimestampMillis: traceData.StartTime / 1_000_000,
|
||||
EndTimestampMillis: traceData.EndTime / 1_000_000,
|
||||
TotalSpansCount: traceData.TotalSpans,
|
||||
TotalErrorSpansCount: traceData.TotalErrorSpans,
|
||||
RootServiceName: rootServiceName,
|
||||
RootServiceEntryPoint: rootServiceEntryPoint,
|
||||
ServiceNameToTotalDurationMap: serviceDurationsMillis,
|
||||
HasMissingSpans: traceData.HasMissingSpans,
|
||||
HasMore: !selectAllSpans,
|
||||
Aggregations: aggregations,
|
||||
Spans: selectedSpans,
|
||||
UncollapsedSpans: uncollapsedSpans,
|
||||
StartTimestampMillis: traceData.StartTime / 1_000_000,
|
||||
EndTimestampMillis: traceData.EndTime / 1_000_000,
|
||||
TotalSpansCount: traceData.TotalSpans,
|
||||
TotalErrorSpansCount: traceData.TotalErrorSpans,
|
||||
RootServiceName: rootServiceName,
|
||||
RootServiceEntryPoint: rootServiceEntryPoint,
|
||||
HasMissingSpans: traceData.HasMissingSpans,
|
||||
HasMore: !selectAllSpans,
|
||||
Aggregations: aggregations,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,21 +295,6 @@ func windowAroundIndex(selectedIndex, total int, spanLimitPerRequest float64) (s
|
||||
return
|
||||
}
|
||||
|
||||
func calculateServiceTime(spanIDToSpanNodeMap map[string]*WaterfallSpan) map[string]uint64 {
|
||||
serviceSpans := make(map[string][]*WaterfallSpan)
|
||||
for _, span := range spanIDToSpanNodeMap {
|
||||
if span.ServiceName != "" {
|
||||
serviceSpans[span.ServiceName] = append(serviceSpans[span.ServiceName], span)
|
||||
}
|
||||
}
|
||||
|
||||
totalTimes := make(map[string]uint64)
|
||||
for service, spans := range serviceSpans {
|
||||
totalTimes[service] = mergeSpanIntervals(spans)
|
||||
}
|
||||
return totalTimes
|
||||
}
|
||||
|
||||
// mergeSpanIntervals computes non-overlapping execution time for a set of spans.
|
||||
func mergeSpanIntervals(spans []*WaterfallSpan) uint64 {
|
||||
if len(spans) == 0 {
|
||||
|
||||
6
tests/fixtures/querier.py
vendored
6
tests/fixtures/querier.py
vendored
@@ -200,8 +200,6 @@ def build_formula_query(
|
||||
*,
|
||||
functions: list[dict] | None = None,
|
||||
disabled: bool = False,
|
||||
order: list[dict] | None = None,
|
||||
limit: int | None = None,
|
||||
) -> dict:
|
||||
spec: dict[str, Any] = {
|
||||
"name": name,
|
||||
@@ -210,10 +208,6 @@ def build_formula_query(
|
||||
}
|
||||
if functions:
|
||||
spec["functions"] = functions
|
||||
if order:
|
||||
spec["order"] = order
|
||||
if limit is not None:
|
||||
spec["limit"] = limit
|
||||
return {"type": "builder_formula", "spec": spec}
|
||||
|
||||
|
||||
|
||||
@@ -11,11 +11,6 @@ from fixtures.logs import Logs
|
||||
from fixtures.querier import (
|
||||
assert_identical_query_response,
|
||||
assert_minutely_bucket_values,
|
||||
build_formula_query,
|
||||
build_group_by_field,
|
||||
build_logs_aggregation,
|
||||
build_order_by,
|
||||
build_scalar_query,
|
||||
find_named_result,
|
||||
index_series_by_label,
|
||||
make_query_request,
|
||||
@@ -2116,180 +2111,3 @@ def test_logs_fill_zero_formula_with_group_by(
|
||||
expected_by_ts=expectations[service_name],
|
||||
context=f"logs/fillZero/F1/{service_name}",
|
||||
)
|
||||
|
||||
|
||||
def test_logs_formula_orderby_and_limit(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_logs: Callable[[list[Logs]], None],
|
||||
) -> None:
|
||||
"""
|
||||
Test that formula results are correctly ordered and limited when
|
||||
order and limit are applied on the formula.
|
||||
"""
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
logs: list[Logs] = []
|
||||
# For service-i (i in 0..9): insert (10 - i) ERROR logs and 2 INFO logs.
|
||||
# A counts ERROR, B counts INFO, so A/B = (10 - i) / 2.
|
||||
# service-0 ratio = 5.0 (highest), service-9 ratio = 0.5 (lowest).
|
||||
for i in range(10):
|
||||
for j in range(10 - i):
|
||||
logs.append(
|
||||
Logs(
|
||||
timestamp=now - timedelta(minutes=j + 1),
|
||||
resources={"service.name": f"service-{i}"},
|
||||
attributes={"code.file": "test.py"},
|
||||
body=f"Error log {i}-{j}",
|
||||
severity_text="ERROR",
|
||||
)
|
||||
)
|
||||
for k in range(2):
|
||||
logs.append(
|
||||
Logs(
|
||||
timestamp=now - timedelta(minutes=k + 1),
|
||||
resources={"service.name": f"service-{i}"},
|
||||
attributes={"code.file": "test.py"},
|
||||
body=f"Info log {i}-{k}",
|
||||
severity_text="INFO",
|
||||
)
|
||||
)
|
||||
# Extra INFO-only services that appear in B but not in A. The formula
|
||||
for name in ("service-info-only-1", "service-info-only-2"):
|
||||
for k in range(2):
|
||||
logs.append(
|
||||
Logs(
|
||||
timestamp=now - timedelta(minutes=k + 1),
|
||||
resources={"service.name": name},
|
||||
attributes={"code.file": "test.py"},
|
||||
body=f"Info log {name}-{k}",
|
||||
severity_text="INFO",
|
||||
)
|
||||
)
|
||||
|
||||
# Logs look like this (columns = minutes before `now`; query range is
|
||||
# (now - 15m, now], so the `now` column is the exclusive upper bound and
|
||||
# no log lands there). E = ERROR, I = INFO, X = both at that minute.
|
||||
#
|
||||
# t-10 t-9 t-8 t-7 t-6 t-5 t-4 t-3 t-2 t-1 |now | A B A/B
|
||||
# service-0: E E E E E E E E X X | | 10 2 5.0
|
||||
# service-1: . E E E E E E E X X | | 9 2 4.5
|
||||
# service-2: . . E E E E E E X X | | 8 2 4.0
|
||||
# service-3: . . . E E E E E X X | | 7 2 3.5
|
||||
# service-4: . . . . E E E E X X | | 6 2 3.0
|
||||
# service-5: . . . . . E E E X X | | 5 2 2.5
|
||||
# service-6: . . . . . . E E X X | | 4 2 2.0
|
||||
# service-7: . . . . . . . E X X | | 3 2 1.5
|
||||
# service-8: . . . . . . . . X X | | 2 2 1.0
|
||||
# service-9: . . . . . . . . I X | | 1 2 0.5
|
||||
# info-only-1: . . . . . . . . I I | | 0* 2 0.0
|
||||
# info-only-2: . . . . . . . . I I | | 0* 2 0.0
|
||||
#
|
||||
# * A is missing for the info-only services; because A is count(), the
|
||||
# formula evaluator defaults missing A to 0, yielding A/B = 0.
|
||||
insert_logs(logs)
|
||||
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
result = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=int((now - timedelta(minutes=15)).timestamp() * 1000),
|
||||
end_ms=int(now.timestamp() * 1000),
|
||||
request_type="scalar",
|
||||
queries=[
|
||||
build_scalar_query(
|
||||
name="A",
|
||||
signal="logs",
|
||||
aggregations=[build_logs_aggregation("count()")],
|
||||
group_by=[build_group_by_field("service.name")],
|
||||
filter_expression="severity_text = 'ERROR'",
|
||||
disabled=True,
|
||||
),
|
||||
build_scalar_query(
|
||||
name="B",
|
||||
signal="logs",
|
||||
aggregations=[build_logs_aggregation("count()")],
|
||||
group_by=[build_group_by_field("service.name")],
|
||||
filter_expression="severity_text = 'INFO'",
|
||||
disabled=True,
|
||||
),
|
||||
build_formula_query(
|
||||
"F1",
|
||||
"A / B",
|
||||
order=[build_order_by("__result", "desc")],
|
||||
limit=3,
|
||||
),
|
||||
build_formula_query(
|
||||
"F2",
|
||||
"A / B",
|
||||
order=[build_order_by("__result", "desc")],
|
||||
),
|
||||
build_formula_query(
|
||||
"F3",
|
||||
"A / B",
|
||||
order=[build_order_by("__result", "asc")],
|
||||
limit=3,
|
||||
),
|
||||
build_formula_query(
|
||||
"F4",
|
||||
"A / B",
|
||||
order=[build_order_by("__result", "asc")],
|
||||
),
|
||||
],
|
||||
)
|
||||
assert result.status_code == HTTPStatus.OK
|
||||
assert result.json()["status"] == "success"
|
||||
|
||||
results = result.json()["data"]["data"]["results"]
|
||||
|
||||
def extract_services_and_values(query_name: str) -> tuple[list, list]:
|
||||
res = find_named_result(results, query_name)
|
||||
assert res is not None, f"Expected formula result named {query_name}"
|
||||
cols = res["columns"]
|
||||
s_col = next(i for i, c in enumerate(cols) if c["name"] == "service.name")
|
||||
v_col = next(i for i, c in enumerate(cols) if c["name"] == "__result")
|
||||
rows = res["data"]
|
||||
return [row[s_col] for row in rows], [row[v_col] for row in rows]
|
||||
|
||||
# Because A is count(), canDefaultZero["A"] is true; the formula evaluator
|
||||
# defaults A to 0 for services that exist only in B. So the two INFO-only
|
||||
# services appear in the formula result with value 0.0 (extreme bottom in
|
||||
# desc order, extreme top in asc order). Their relative ordering is not
|
||||
# deterministic across separate formula evaluations (tied values).
|
||||
info_only_services = {"service-info-only-1", "service-info-only-2"}
|
||||
|
||||
# F2: desc, no limit -> 12 rows in descending order by value.
|
||||
f2_services, f2_values = extract_services_and_values("F2")
|
||||
assert len(f2_services) == 12, f"F2: expected 12 rows with no limit, got {len(f2_services)}"
|
||||
assert f2_values == [5.0, 4.5, 4.0, 3.5, 3.0, 2.5, 2.0, 1.5, 1.0, 0.5, 0.0, 0.0], f2_values
|
||||
# Top 10 have distinct positive values -> deterministic service ordering.
|
||||
assert f2_services[:10] == [f"service-{i}" for i in range(10)], f2_services[:10]
|
||||
# Tail 2 are the INFO-only services tied at 0.0 (order between them not guaranteed).
|
||||
assert set(f2_services[10:]) == info_only_services, f2_services[10:]
|
||||
|
||||
# F1: desc + limit 3 -> must be exactly the first 3 rows of F2.
|
||||
# Top 3 are not in the tie region, so prefix equality is safe.
|
||||
f1_services, f1_values = extract_services_and_values("F1")
|
||||
assert len(f1_services) == 3, f"F1: expected 3 rows after limit, got {len(f1_services)}"
|
||||
assert f1_services == f2_services[:3], f"F1 services {f1_services} are not the prefix of F2 services {f2_services}"
|
||||
assert f1_values == f2_values[:3], f"F1 values {f1_values} are not the prefix of F2 values {f2_values}"
|
||||
|
||||
# F4: asc, no limit -> 12 rows in ascending order by value.
|
||||
f4_services, f4_values = extract_services_and_values("F4")
|
||||
assert len(f4_services) == 12, f"F4: expected 12 rows with no limit, got {len(f4_services)}"
|
||||
assert f4_values == sorted(f4_values), f"F4 not ascending: {f4_values}"
|
||||
# First 2 are the INFO-only services tied at 0.0 (order between them not guaranteed).
|
||||
assert set(f4_services[:2]) == info_only_services, f4_services[:2]
|
||||
assert f4_values[:2] == [0.0, 0.0], f4_values[:2]
|
||||
# Tail 10 are service-9 down to service-0 by value.
|
||||
assert f4_services[2:] == [f"service-{i}" for i in reversed(range(10))], f4_services[2:]
|
||||
assert f4_values[2:] == [(10 - i) / 2 for i in reversed(range(10))], f4_values[2:]
|
||||
|
||||
# F3: asc + limit 3 -> values must match F4[:3] exactly; service set must
|
||||
# match too. Direct prefix equality on services would be flaky because the
|
||||
# two tied INFO-only entries can swap order between formula evaluations.
|
||||
f3_services, f3_values = extract_services_and_values("F3")
|
||||
assert len(f3_services) == 3, f"F3: expected 3 rows after limit, got {len(f3_services)}"
|
||||
assert f3_values == f4_values[:3], f"F3 values {f3_values} do not match F4[:3] values {f4_values[:3]}"
|
||||
assert set(f3_services) == set(f4_services[:3]), f"F3 services {f3_services} do not match F4[:3] services {f4_services[:3]}"
|
||||
|
||||
Reference in New Issue
Block a user