Compare commits

..

2 Commits

Author SHA1 Message Date
Nikhil Soni
c4d3bb265d fix(flamegraph): two-pass fetch to avoid loading events for all spans
The previous approach fetched all span data (including events) in a single
query, then returned only a windowed subset (~5000 spans). For traces with
200k spans and 2 events avg, this caused ~440MB of allocation just for
events that were never sent to the client.

New flow (always applied, cache-miss path):
- Pass 1: skeleton query fetches only span_id, parent_span_id, timestamp,
  duration_nano, has_error, service_name, name for ALL spans. No events,
  no attribute maps. Builds the complete BFS tree and selects the response
  window with minimal memory.
- Pass 2: hydration query fetches events (and attribute maps if SelectFields
  is set) only for the span IDs that survive window selection and sampling
  (~5000 max for windowed traces). Uses named parameters with IN @spanIDs.

Memory impact:
- Large traces (200k spans, windowed): ~9x reduction — tree building uses
  ~40MB skeleton instead of ~400MB with events
- Small traces (all spans returned): marginal memory benefit from leaner
  tree-building phase; ~10-50ms extra latency from the second round-trip

Also inlines the trace summary query (previously inside GetSpansForTrace)
so time bounds are shared between both passes without calling the shared
helper.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 00:18:03 +05:30
Nikhil Soni
31d6bfda35 fix(flamegraph): reduce memory footprint - quick wins
- Switch tree-building from links/references to parent_span_id: eliminates
  per-span JSON parsing and removes References from the in-memory struct
  and JSON response
- Add DISTINCT ON (span_id) to the flamegraph SELECT query to avoid
  processing duplicate span rows
- Smart SelectFields column projection: only fetch attribute maps
  (string/number/bool) or resources_string when the corresponding field
  context is actually requested, rather than always fetching all four maps
- Free searchScanResponses after span construction and spanIdToSpanNodeMap
  after BFS traversal so GC can reclaim them before window selection
- Replace References-based tree-building loop with parent_span_id check,
  eliminating the O(n²) ContainsFlamegraphSpan scan for root detection
- Remove unused fields from FlamegraphSpan: TraceID (never read by the
  flamegraph frontend) and References (replaced by ParentSpanID); Children
  kept on struct for BFS but hidden from JSON (json:"-")
- Add ParentSpanID to FlamegraphSpan response (field was already declared
  in the TypeScript type but was never populated by the backend)
- Remove traceId from FlamegraphSpan TypeScript type and test mocks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 00:13:05 +05:30
34 changed files with 279 additions and 959 deletions

View File

@@ -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,

View File

@@ -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}`}

View File

@@ -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" />;
}

View File

@@ -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.

View File

@@ -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 ?? '—'}

View File

@@ -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>

View File

@@ -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',

View File

@@ -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);
});
});
});

View File

@@ -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;

View File

@@ -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]);
});
});
});

View File

@@ -142,7 +142,5 @@ export function buildTooltipContent({
}
}
items.sort((a, b) => b.value - a.value);
return items;
}

View File

@@ -11,7 +11,6 @@ function makeSpan(
): FlamegraphSpan {
return {
parentSpanId: '',
traceId: 'trace-1',
hasError: false,
serviceName: 'svc',
name: 'op',

View File

@@ -6,7 +6,6 @@ export const MOCK_SPAN: FlamegraphSpan = {
durationNano: 50_000_000, // 50ms
spanId: 'span-1',
parentSpanId: '',
traceId: 'trace-1',
hasError: false,
serviceName: 'test-service',
name: 'test-span',

View File

@@ -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%',
}}
>

View File

@@ -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) {

View File

@@ -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,

View File

@@ -23,7 +23,6 @@ export interface FlamegraphSpan {
durationNano: number;
spanId: string;
parentSpanId: string;
traceId: string;
hasError: boolean;
serviceName: string;
name: string;

View File

@@ -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'],

View File

@@ -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")
}

View File

@@ -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,

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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,

View File

@@ -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 {

View File

@@ -1140,6 +1140,8 @@ func (r *ClickHouseReader) GetFlamegraphSpansForTrace(ctx context.Context, orgID
// map[traceID][level]span
var selectedSpans = [][]*model.FlamegraphSpan{}
var traceRoots []*model.FlamegraphSpan
// time bounds for Pass 1 and Pass 2 (set on cache miss, zero on cache hit)
var tsBucketStart, tsBucketEnd int64
// get the trace tree from cache!
cachedTraceData, err := r.GetFlamegraphSpansForTraceCache(ctx, orgID, traceID)
@@ -1155,62 +1157,59 @@ func (r *ClickHouseReader) GetFlamegraphSpansForTrace(ctx context.Context, orgID
if err != nil {
r.logger.Info("cache miss for getFlamegraphSpansForTrace", "traceID", traceID)
selectCols := "timestamp, duration_nano, span_id, trace_id, has_error, links as references, resource_string_service$$name, name, events"
if len(req.SelectFields) > 0 {
selectCols += ", attributes_string, attributes_number, attributes_bool, resources_string"
// Inline summary query to get time bounds shared by Pass 1 and Pass 2.
var traceSummary model.TraceSummary
summaryQuery := fmt.Sprintf(
"SELECT trace_id, min(start) AS start, max(end) AS end, sum(num_spans) AS num_spans FROM %s.%s WHERE trace_id=$1 GROUP BY trace_id",
r.TraceDB, r.traceSummaryTable)
if summaryErr := r.db.QueryRow(ctx, summaryQuery, traceID).Scan(
&traceSummary.TraceID, &traceSummary.Start, &traceSummary.End, &traceSummary.NumSpans,
); summaryErr != nil {
if summaryErr == sql.ErrNoRows {
return trace, nil
}
r.logger.Error("Error in processing flamegraph trace summary sql query", errorsV2.Attr(summaryErr))
return nil, model.ExecutionError(fmt.Errorf("getFlamegraphSpansForTrace: error querying trace summary: %w", summaryErr))
}
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)
tsBucketStart = traceSummary.Start.Unix() - 1800
tsBucketEnd = traceSummary.End.Unix()
searchScanResponses, err := r.GetSpansForTrace(ctx, traceID, flamegraphQuery)
if err != nil {
return nil, err
// Pass 1: skeleton query — no events, no attribute maps.
// Keeps tree-building memory lean; events are fetched in Pass 2 only for
// the windowed spans that are actually returned in the response.
skeletonQuery := fmt.Sprintf(
"SELECT DISTINCT ON (span_id) timestamp, duration_nano, span_id, parent_span_id, has_error, resource_string_service$$name, name FROM %s.%s WHERE trace_id=$1 AND ts_bucket_start>=$2 AND ts_bucket_start<=$3 ORDER BY timestamp ASC, name ASC",
r.TraceDB, r.traceTableName)
var skeletonSpans []model.SpanItemV2
if skeletonErr := r.db.Select(ctx, &skeletonSpans, skeletonQuery, traceID,
strconv.FormatInt(tsBucketStart, 10), strconv.FormatInt(tsBucketEnd, 10),
); skeletonErr != nil {
r.logger.Error("Error in processing flamegraph skeleton sql query", errorsV2.Attr(skeletonErr))
return nil, model.ExecutionError(fmt.Errorf("getFlamegraphSpansForTrace: error querying skeleton spans: %w", skeletonErr))
}
if len(searchScanResponses) == 0 {
if len(skeletonSpans) == 0 {
return trace, nil
}
for _, item := range searchScanResponses {
ref := []model.OtelSpanRef{}
err := json.Unmarshal([]byte(item.References), &ref)
if err != nil {
r.logger.Error("Error unmarshalling references", errorsV2.Attr(err))
return nil, errorsV2.Newf(errorsV2.TypeInternal, errorsV2.CodeInternal, "getFlamegraphSpansForTrace: error in unmarshalling references %s", err.Error())
}
events := make([]model.Event, 0)
for _, event := range item.Events {
var eventMap model.Event
err = json.Unmarshal([]byte(event), &eventMap)
if err != nil {
r.logger.Error("Error unmarshalling events", errorsV2.Attr(err))
return nil, errorsV2.Newf(errorsV2.TypeInternal, errorsV2.CodeInternal, "getFlamegraphSpansForTrace: error in unmarshalling events %s", err.Error())
}
events = append(events, eventMap)
}
for _, item := range skeletonSpans {
jsonItem := model.FlamegraphSpan{
SpanID: item.SpanID,
TraceID: item.TraceID,
ServiceName: item.ServiceName,
Name: item.Name,
DurationNano: item.DurationNano,
HasError: item.HasError,
References: ref,
Events: events,
ParentSpanID: item.ParentSpanId,
Children: make([]*model.FlamegraphSpan, 0),
}
if len(req.SelectFields) > 0 {
jsonItem.SetRequestedFields(item, req.SelectFields)
}
// metadata calculation
startTimeUnixNano := uint64(item.TimeUnixNano.UnixNano())
if startTime == 0 || startTimeUnixNano < startTime {
startTime = startTimeUnixNano
}
if endTime == 0 || (startTimeUnixNano+jsonItem.DurationNano) > endTime {
endTime = (startTimeUnixNano + jsonItem.DurationNano)
endTime = startTimeUnixNano + jsonItem.DurationNano
}
if durationNano == 0 || jsonItem.DurationNano > durationNano {
durationNano = jsonItem.DurationNano
@@ -1219,41 +1218,34 @@ func (r *ClickHouseReader) GetFlamegraphSpansForTrace(ctx context.Context, orgID
jsonItem.TimeUnixNano = uint64(item.TimeUnixNano.UnixNano() / 1000000)
spanIdToSpanNodeMap[jsonItem.SpanID] = &jsonItem
}
skeletonSpans = nil
// traverse through the map and append each node to the children array of the parent node
// and add missing spans
// build parent-child tree using parent_span_id; insert placeholders for missing parents
for _, spanNode := range spanIdToSpanNodeMap {
hasParentSpanNode := false
for _, reference := range spanNode.References {
if reference.RefType == "CHILD_OF" && reference.SpanId != "" {
hasParentSpanNode = true
if parentNode, exists := spanIdToSpanNodeMap[reference.SpanId]; exists {
parentNode.Children = append(parentNode.Children, spanNode)
} else {
// insert the missing spans
missingSpan := model.FlamegraphSpan{
SpanID: reference.SpanId,
TraceID: spanNode.TraceID,
ServiceName: "",
Name: "Missing Span",
TimeUnixNano: spanNode.TimeUnixNano,
DurationNano: spanNode.DurationNano,
HasError: false,
Events: make([]model.Event, 0),
Children: make([]*model.FlamegraphSpan, 0),
}
missingSpan.Children = append(missingSpan.Children, spanNode)
spanIdToSpanNodeMap[missingSpan.SpanID] = &missingSpan
traceRoots = append(traceRoots, &missingSpan)
}
}
}
if !hasParentSpanNode && !tracedetail.ContainsFlamegraphSpan(traceRoots, spanNode) {
if spanNode.ParentSpanID == "" {
traceRoots = append(traceRoots, spanNode)
} else if parentNode, exists := spanIdToSpanNodeMap[spanNode.ParentSpanID]; exists {
parentNode.Children = append(parentNode.Children, spanNode)
} else {
if _, alreadyCreated := spanIdToSpanNodeMap[spanNode.ParentSpanID]; !alreadyCreated {
missingSpan := &model.FlamegraphSpan{
SpanID: spanNode.ParentSpanID,
Name: "Missing Span",
TimeUnixNano: spanNode.TimeUnixNano,
DurationNano: spanNode.DurationNano,
Events: make([]model.Event, 0),
Children: make([]*model.FlamegraphSpan, 0),
}
spanIdToSpanNodeMap[missingSpan.SpanID] = missingSpan
traceRoots = append(traceRoots, missingSpan)
}
spanIdToSpanNodeMap[spanNode.ParentSpanID].Children = append(
spanIdToSpanNodeMap[spanNode.ParentSpanID].Children, spanNode)
}
}
selectedSpans = tracedetail.GetAllSpansForFlamegraph(traceRoots, spanIdToSpanNodeMap)
spanIdToSpanNodeMap = nil
// TODO: set the trace data (model.GetFlamegraphSpansForTraceCache) in cache here
// removed existing cache usage since it was not getting used due to this bug https://github.com/SigNoz/engineering-pod/issues/4648
@@ -1276,6 +1268,74 @@ func (r *ClickHouseReader) GetFlamegraphSpansForTrace(ctx context.Context, orgID
}
r.logger.Debug("getFlamegraphSpansForTrace: processing post cache", "duration", time.Since(processingPostCache), "traceID", traceID, "totalSpans", totalSpanCount, "limit", clientLimit)
// Pass 2: hydrate events and requested attribute fields only for the selected window spans.
// tsBucketStart is non-zero only when we performed a DB fetch (cache miss path).
if err != nil && tsBucketStart != 0 {
needsAttrMaps := false
needsResourceMap := false
for _, f := range req.SelectFields {
if f.FieldContext == telemetrytypes.FieldContextAttribute {
needsAttrMaps = true
}
if f.FieldContext == telemetrytypes.FieldContextResource {
needsResourceMap = true
}
}
selectedSpanIDs := make([]string, 0)
selectedSpanMap := make(map[string]*model.FlamegraphSpan)
for _, level := range selectedSpansForRequest {
for _, span := range level {
selectedSpanIDs = append(selectedSpanIDs, span.SpanID)
selectedSpanMap[span.SpanID] = span
}
}
if len(selectedSpanIDs) > 0 {
hydrateCols := "span_id, events"
if needsAttrMaps {
hydrateCols += ", attributes_string, attributes_number, attributes_bool"
}
if needsResourceMap {
hydrateCols += ", resources_string"
}
hydrateQuery := fmt.Sprintf(
"SELECT %s FROM %s.%s WHERE trace_id=@traceID AND ts_bucket_start>=@tsStart AND ts_bucket_start<=@tsEnd AND span_id IN @spanIDs",
hydrateCols, r.TraceDB, r.traceTableName)
var hydrateRows []model.SpanItemV2
if hydrateErr := r.db.Select(ctx, &hydrateRows, hydrateQuery,
clickhouse.Named("traceID", traceID),
clickhouse.Named("tsStart", tsBucketStart),
clickhouse.Named("tsEnd", tsBucketEnd),
clickhouse.Named("spanIDs", selectedSpanIDs),
); hydrateErr != nil {
r.logger.Error("Error in processing flamegraph hydration sql query", errorsV2.Attr(hydrateErr))
return nil, model.ExecutionError(fmt.Errorf("getFlamegraphSpansForTrace: error querying events: %w", hydrateErr))
}
for _, item := range hydrateRows {
span, ok := selectedSpanMap[item.SpanID]
if !ok {
continue
}
events := make([]model.Event, 0, len(item.Events))
for _, event := range item.Events {
var eventMap model.Event
if unmarshalErr := json.Unmarshal([]byte(event), &eventMap); unmarshalErr != nil {
r.logger.Error("Error unmarshalling events", errorsV2.Attr(unmarshalErr))
return nil, errorsV2.Newf(errorsV2.TypeInternal, errorsV2.CodeInternal, "getFlamegraphSpansForTrace: error in unmarshalling events %s", unmarshalErr.Error())
}
events = append(events, eventMap)
}
span.Events = events
if len(req.SelectFields) > 0 {
span.SetRequestedFields(item, req.SelectFields)
}
}
}
}
trace.Spans = selectedSpansForRequest
trace.StartTimestampMillis = startTime / 1000000
trace.EndTimestampMillis = endTime / 1000000

View File

@@ -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 {

View File

@@ -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)
}

View File

@@ -297,14 +297,13 @@ type FlamegraphSpan struct {
TimeUnixNano uint64 `json:"timestamp"`
DurationNano uint64 `json:"durationNano"`
SpanID string `json:"spanId"`
TraceID string `json:"traceId"`
HasError bool `json:"hasError"`
ServiceName string `json:"serviceName"`
Name string `json:"name"`
Level int64 `json:"level"`
ParentSpanID string `json:"parentSpanId"`
Events []Event `json:"event"`
References []OtelSpanRef `json:"references,omitempty"`
Children []*FlamegraphSpan `json:"children"`
Children []*FlamegraphSpan `json:"-"`
Attributes map[string]any `json:"attributes,omitempty"`
Resource map[string]string `json:"resource,omitempty"`
}

View File

@@ -203,7 +203,6 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewMigrateMetaresourcesTuplesFactory(sqlstore),
sqlmigration.NewAddTagsFactory(sqlstore, sqlschema),
sqlmigration.NewAddRoleCRUDTuplesFactory(sqlstore),
sqlmigration.NewAddIntegrationDashboardsFactory(sqlstore, sqlschema),
)
}

View File

@@ -1,76 +0,0 @@
package sqlmigration
import (
"context"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type addIntegrationDashboards struct {
sqlstore sqlstore.SQLStore
sqlschema sqlschema.SQLSchema
}
func NewAddIntegrationDashboardsFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(
factory.MustNewName("add_integration_dashboards"),
func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &addIntegrationDashboards{sqlstore: sqlstore, sqlschema: sqlschema}, nil
},
)
}
func (m *addIntegrationDashboards) Register(migrations *migrate.Migrations) error {
return migrations.Register(m.Up, m.Down)
}
func (m *addIntegrationDashboards) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
// dashboard_id is knowingly kept loosing coupled with dashboard's id and is not a foreign key to dashboard's id.
sqls := m.sqlschema.Operator().CreateTable(&sqlschema.Table{
Name: "integration_dashboards",
Columns: []*sqlschema.Column{
{Name: "id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "dashboard_id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "provider", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "slug", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "created_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
{Name: "updated_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
{Name: "org_id", DataType: sqlschema.DataTypeText, Nullable: false},
},
PrimaryKeyConstraint: &sqlschema.PrimaryKeyConstraint{
ColumnNames: []sqlschema.ColumnName{"id"},
},
})
sqls = append(sqls, m.sqlschema.Operator().CreateIndex(
&sqlschema.UniqueIndex{
TableName: "integration_dashboards",
ColumnNames: []sqlschema.ColumnName{"dashboard_id"},
},
)...)
for _, sql := range sqls {
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
return err
}
}
if err := tx.Commit(); err != nil {
return err
}
return nil
}
func (m *addIntegrationDashboards) Down(context.Context, *bun.DB) error {
return nil
}

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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}

View File

@@ -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]}"