mirror of
https://github.com/SigNoz/signoz.git
synced 2026-08-25 05:50:55 +01:00
Compare commits
8 Commits
feat/semco
...
feat/trace
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8bdc8ad742 | ||
|
|
4f0193c87b | ||
|
|
afadfc6a12 | ||
|
|
fe68b8e8b7 | ||
|
|
77fbf74092 | ||
|
|
9997c3da9c | ||
|
|
485aed0e1a | ||
|
|
4d69e3f9e5 |
2
.github/workflows/goci.yaml
vendored
2
.github/workflows/goci.yaml
vendored
@@ -67,7 +67,7 @@ jobs:
|
||||
with:
|
||||
go-version: "1.24"
|
||||
- name: check-semconv-generated-files
|
||||
run: make semconv-check
|
||||
run: go run ./scripts/semconv -check
|
||||
build:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
|
||||
4
Makefile
4
Makefile
@@ -237,10 +237,6 @@ py-clean: ## Clear all pycache and pytest cache from tests directory recursively
|
||||
semconv-generate: ## Regenerate semantic-convention families for Go and TypeScript
|
||||
@go run ./scripts/semconv
|
||||
|
||||
.PHONY: semconv-check
|
||||
semconv-check: ## Fail if the generated semantic-convention files are stale
|
||||
@go run ./scripts/semconv -check
|
||||
|
||||
.PHONY: gen-mocks
|
||||
gen-mocks:
|
||||
@echo ">> Generating mocks"
|
||||
|
||||
@@ -62,6 +62,40 @@ if (typeof window.ResizeObserver === 'undefined') {
|
||||
(window as any).ResizeObserver = ResizeObserverMock;
|
||||
}
|
||||
|
||||
if (typeof globalThis.DOMRect === 'undefined') {
|
||||
(globalThis as any).DOMRect = class DOMRect {
|
||||
x = 0;
|
||||
y = 0;
|
||||
width = 0;
|
||||
height = 0;
|
||||
top = 0;
|
||||
right = 0;
|
||||
bottom = 0;
|
||||
left = 0;
|
||||
constructor(x = 0, y = 0, width = 0, height = 0) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
this.top = y;
|
||||
this.right = x + width;
|
||||
this.bottom = y + height;
|
||||
this.left = x;
|
||||
}
|
||||
toJSON(): any {
|
||||
return { x: this.x, y: this.y, width: this.width, height: this.height };
|
||||
}
|
||||
static fromRect(rect?: {
|
||||
x?: number;
|
||||
y?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}): DOMRect {
|
||||
return new DOMRect(rect?.x, rect?.y, rect?.width, rect?.height);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Patch getComputedStyle to handle CSS parsing errors from @signozhq/* packages.
|
||||
// These packages inject CSS at import time via style-inject / vite-plugin-css-injected-by-js.
|
||||
// jsdom's nwsapi cannot parse some of the injected selectors (e.g. Tailwind's :animate-in),
|
||||
|
||||
@@ -48,9 +48,9 @@
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@sentry/react": "10.57.0",
|
||||
"@sentry/vite-plugin": "5.3.0",
|
||||
"@signozhq/design-tokens": "2.1.4",
|
||||
"@signozhq/design-tokens": "2.1.6",
|
||||
"@signozhq/icons": "0.4.0",
|
||||
"@signozhq/ui": "0.0.23",
|
||||
"@signozhq/ui": "0.1.0",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"@tanstack/react-virtual": "3.13.22",
|
||||
"@uiw/codemirror-theme-copilot": "4.23.11",
|
||||
@@ -238,4 +238,4 @@
|
||||
"tmp@<0.2.6": ">=0.2.6 <0.3.0",
|
||||
"yaml@>=1.0.0 <1.10.3": ">=1.10.3 <2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
823
frontend/pnpm-lock.yaml
generated
823
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -28,6 +28,9 @@ interface FieldsSelectorProps {
|
||||
signal: DataSource;
|
||||
maxFields?: number;
|
||||
requiredFields?: readonly string[];
|
||||
// Lets users add a free-typed field which
|
||||
// does not show up in the suggestions
|
||||
allowCustomFields?: boolean;
|
||||
width?: number;
|
||||
height?: number;
|
||||
defaultPosition?: { x: number; y: number };
|
||||
@@ -46,6 +49,7 @@ function FieldsSelectorContent({
|
||||
signal,
|
||||
maxFields,
|
||||
requiredFields,
|
||||
allowCustomFields,
|
||||
width = DEFAULT_PANEL_WIDTH,
|
||||
height,
|
||||
defaultPosition,
|
||||
@@ -67,7 +71,7 @@ function FieldsSelectorContent({
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>): void => {
|
||||
const value = e.target.value.trim().toLowerCase();
|
||||
const value = e.target.value.trim();
|
||||
setInputValue(value);
|
||||
debouncedUpdate(value);
|
||||
},
|
||||
@@ -153,6 +157,7 @@ function FieldsSelectorContent({
|
||||
addedFields={draftFields}
|
||||
onAdd={handleAdd}
|
||||
isAtLimit={isAtLimit}
|
||||
allowCustomFields={allowCustomFields}
|
||||
/>
|
||||
|
||||
{hasUnsavedChanges && (
|
||||
@@ -192,7 +197,7 @@ function FieldsSelector({
|
||||
() =>
|
||||
fields.map((f) => ({
|
||||
...f,
|
||||
key: f.key ?? buildCompositeKey(f.name, f.fieldContext),
|
||||
key: buildCompositeKey(f.name, f.fieldContext),
|
||||
})),
|
||||
[fields],
|
||||
);
|
||||
|
||||
@@ -21,6 +21,7 @@ interface OtherFieldsProps {
|
||||
addedFields: TelemetryFieldKey[];
|
||||
onAdd: (field: TelemetryFieldKey) => void;
|
||||
isAtLimit: boolean;
|
||||
allowCustomFields?: boolean;
|
||||
}
|
||||
|
||||
function OtherFields({
|
||||
@@ -29,6 +30,7 @@ function OtherFields({
|
||||
addedFields,
|
||||
onAdd,
|
||||
isAtLimit,
|
||||
allowCustomFields,
|
||||
}: OtherFieldsProps): JSX.Element {
|
||||
const { data, isFetching } = useGetQueryKeySuggestions(
|
||||
{
|
||||
@@ -45,25 +47,45 @@ function OtherFields({
|
||||
},
|
||||
);
|
||||
|
||||
const otherFields: TelemetryFieldKey[] = useMemo(() => {
|
||||
const suggestions = Object.values(data?.data.data.keys || {}).flat();
|
||||
const otherFields = useMemo<TelemetryFieldKey[]>(() => {
|
||||
const rawSuggestions = Object.values(data?.data.data.keys || {}).flat();
|
||||
// Normalize: synthesize `key` once so downstream reads can trust it.
|
||||
const normalizedSuggestions: TelemetryFieldKey[] = suggestions.map(
|
||||
(attr) => ({
|
||||
...attr,
|
||||
key: buildCompositeKey(attr.name, attr.fieldContext as string),
|
||||
signal: attr.signal as SignalType,
|
||||
fieldContext: attr.fieldContext as FieldContext,
|
||||
fieldDataType: attr.fieldDataType,
|
||||
}),
|
||||
);
|
||||
const suggestions: TelemetryFieldKey[] = rawSuggestions.map((attr) => ({
|
||||
...attr,
|
||||
key: buildCompositeKey(attr.name, attr.fieldContext as string),
|
||||
signal: attr.signal as SignalType,
|
||||
fieldContext: attr.fieldContext as FieldContext,
|
||||
fieldDataType: attr.fieldDataType,
|
||||
}));
|
||||
const addedIds = new Set(
|
||||
addedFields.map((f) => f.key ?? buildCompositeKey(f.name, f.fieldContext)),
|
||||
addedFields.map((f) => buildCompositeKey(f.name, f.fieldContext)),
|
||||
);
|
||||
return normalizedSuggestions.filter(
|
||||
const available = suggestions.filter(
|
||||
(attr) => !addedIds.has(attr.key as string),
|
||||
);
|
||||
}, [data, addedFields]);
|
||||
|
||||
// Prepend the custom field when its name is not in suggestions and
|
||||
// not already added.
|
||||
const typed = debouncedInputValue.trim();
|
||||
const nameMatches = (list: TelemetryFieldKey[]): boolean =>
|
||||
list.some((f) => f.name.toLowerCase() === typed.toLowerCase());
|
||||
const showCustom =
|
||||
!!allowCustomFields &&
|
||||
typed.length > 0 &&
|
||||
!nameMatches(suggestions) &&
|
||||
!nameMatches(addedFields);
|
||||
|
||||
if (!showCustom) {
|
||||
return available;
|
||||
}
|
||||
const customField: TelemetryFieldKey = {
|
||||
name: typed,
|
||||
fieldContext: '',
|
||||
fieldDataType: '',
|
||||
key: buildCompositeKey(typed, ''),
|
||||
};
|
||||
return [customField, ...available];
|
||||
}, [data, addedFields, allowCustomFields, debouncedInputValue]);
|
||||
|
||||
if (isFetching) {
|
||||
return (
|
||||
|
||||
@@ -11,7 +11,7 @@ const makeField = (name: string, fieldContext = 'log'): TelemetryFieldKey => ({
|
||||
signal: 'logs',
|
||||
fieldContext: fieldContext as TelemetryFieldKey['fieldContext'],
|
||||
fieldDataType: 'string',
|
||||
key: `${fieldContext}.${name}`,
|
||||
key: `${fieldContext}:${name}`,
|
||||
});
|
||||
|
||||
describe('AddedFields — requiredFields', () => {
|
||||
@@ -33,7 +33,7 @@ describe('AddedFields — requiredFields', () => {
|
||||
inputValue=""
|
||||
fields={fields}
|
||||
onFieldsChange={jest.fn()}
|
||||
requiredFields={['log.a', 'log.c']}
|
||||
requiredFields={['log:a', 'log:c']}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -50,7 +50,7 @@ describe('AddedFields — requiredFields', () => {
|
||||
inputValue=""
|
||||
fields={fields}
|
||||
onFieldsChange={jest.fn()}
|
||||
requiredFields={['log.a']}
|
||||
requiredFields={['log:a']}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -68,7 +68,7 @@ describe('AddedFields — requiredFields', () => {
|
||||
inputValue=""
|
||||
fields={fields}
|
||||
onFieldsChange={jest.fn()}
|
||||
requiredFields={['log.body']}
|
||||
requiredFields={['log:body']}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -101,11 +101,11 @@ describe('AddedFields — requiredFields', () => {
|
||||
inputValue=""
|
||||
fields={fields}
|
||||
onFieldsChange={jest.fn()}
|
||||
requiredFields={['log.body']}
|
||||
requiredFields={['log:body']}
|
||||
/>,
|
||||
);
|
||||
|
||||
// 'log.body' locked, 'log.body_extra' removable.
|
||||
// 'log:body' locked, 'log:body_extra' removable.
|
||||
expect(screen.getAllByRole('button', { name: /remove/i })).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { act, fireEvent, render, screen } from 'tests/test-utils';
|
||||
import { useGetQueryKeySuggestions } from 'hooks/querySuggestions/useGetQueryKeySuggestions';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import FieldsSelector from '../FieldsSelector';
|
||||
|
||||
jest.mock('hooks/querySuggestions/useGetQueryKeySuggestions');
|
||||
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
...jest.requireActual('@signozhq/ui/sonner'),
|
||||
toast: { success: jest.fn(), error: jest.fn() },
|
||||
}));
|
||||
|
||||
// FloatingPanel is a react-rnd/portal shell — presentation only. Render its
|
||||
// children directly so the test exercises the column-editing behavior.
|
||||
jest.mock('periscope/components/FloatingPanel', () => ({
|
||||
FloatingPanel: ({ children }: { children: React.ReactNode }): JSX.Element => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
const mockSuggestions = (names: string[]): void => {
|
||||
(useGetQueryKeySuggestions as jest.Mock).mockReturnValue({
|
||||
data: {
|
||||
data: {
|
||||
data: {
|
||||
keys: {
|
||||
attributeKeys: names.map((name) => ({
|
||||
name,
|
||||
signal: 'logs',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: '',
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
isFetching: false,
|
||||
});
|
||||
};
|
||||
|
||||
const field = (name: string, fieldContext = 'log'): TelemetryFieldKey => ({
|
||||
name,
|
||||
signal: 'logs',
|
||||
fieldContext: fieldContext as TelemetryFieldKey['fieldContext'],
|
||||
fieldDataType: 'string',
|
||||
});
|
||||
|
||||
const renderPanel = (
|
||||
props: Partial<React.ComponentProps<typeof FieldsSelector>> = {},
|
||||
): { onFieldsChange: jest.Mock } => {
|
||||
const onFieldsChange = jest.fn();
|
||||
render(
|
||||
<FieldsSelector
|
||||
isOpen
|
||||
title="Edit columns"
|
||||
fields={props.fields ?? []}
|
||||
onFieldsChange={onFieldsChange}
|
||||
onClose={jest.fn()}
|
||||
signal={DataSource.LOGS}
|
||||
allowCustomFields
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
return { onFieldsChange };
|
||||
};
|
||||
|
||||
// Type into the search box and flush the 400ms debounce so OtherFields (driven
|
||||
// by the debounced value) recomputes.
|
||||
const typeSearch = (value: string): void => {
|
||||
const input = screen.getByPlaceholderText('Search for a field...');
|
||||
act(() => {
|
||||
fireEvent.change(input, { target: { value } });
|
||||
});
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(400);
|
||||
});
|
||||
};
|
||||
|
||||
describe('FieldsSelector — edit columns (integration)', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
mockSuggestions([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.runOnlyPendingTimers();
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('adds a free-typed field end to end and saves the synthesized key', () => {
|
||||
const { onFieldsChange } = renderPanel({ fields: [field('body')] });
|
||||
|
||||
typeSearch('orderId');
|
||||
|
||||
// custom option surfaces in OTHER FIELDS (only Add button, no suggestions)
|
||||
expect(screen.getByText('orderId')).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /^add$/i }));
|
||||
});
|
||||
|
||||
// moved into ADDED FIELDS → OTHER FIELDS has nothing left to offer
|
||||
expect(screen.getByText('No values found')).toBeInTheDocument();
|
||||
|
||||
// Save commits the draft
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /save changes/i }));
|
||||
});
|
||||
|
||||
expect(onFieldsChange).toHaveBeenCalledTimes(1);
|
||||
const saved = onFieldsChange.mock.calls[0][0] as TelemetryFieldKey[];
|
||||
expect(saved).toStrictEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: 'orderId',
|
||||
fieldContext: '',
|
||||
fieldDataType: '',
|
||||
key: 'orderId',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('adds a suggested field: it moves from OTHER FIELDS into ADDED FIELDS', () => {
|
||||
mockSuggestions(['service.name']);
|
||||
const { onFieldsChange } = renderPanel({ fields: [] });
|
||||
|
||||
const addButton = screen.getByRole('button', { name: /^add$/i });
|
||||
act(() => {
|
||||
fireEvent.click(addButton);
|
||||
});
|
||||
|
||||
// now removable in ADDED FIELDS, no longer offered in OTHER FIELDS
|
||||
expect(screen.getByRole('button', { name: /remove/i })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /^add$/i }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /save changes/i }));
|
||||
});
|
||||
const saved = onFieldsChange.mock.calls[0][0] as TelemetryFieldKey[];
|
||||
expect(saved.map((f) => f.name)).toContain('service.name');
|
||||
});
|
||||
|
||||
it('hides the custom option when the typed name is already added', () => {
|
||||
renderPanel({ fields: [field('orderId')] });
|
||||
|
||||
typeSearch('ORDERID');
|
||||
|
||||
// exact name already added → nothing left to offer in OTHER FIELDS
|
||||
expect(screen.queryByText('ORDERID')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('No values found')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not offer a custom option when allowCustomFields is off', () => {
|
||||
renderPanel({ fields: [], allowCustomFields: false });
|
||||
|
||||
typeSearch('unknown.a.b.c');
|
||||
|
||||
// no custom row and nothing addable
|
||||
expect(screen.queryByText('unknown.a.b.c')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /^add$/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('discards an added field, reverting the draft', () => {
|
||||
const { onFieldsChange } = renderPanel({ fields: [field('body')] });
|
||||
|
||||
typeSearch('orderId');
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /^add$/i }));
|
||||
});
|
||||
|
||||
// clear the search so the added list is not filtered
|
||||
typeSearch('');
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /discard/i }));
|
||||
});
|
||||
|
||||
expect(screen.queryByText('orderId')).not.toBeInTheDocument();
|
||||
expect(onFieldsChange).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { fireEvent, render, screen } from 'tests/test-utils';
|
||||
import { useGetQueryKeySuggestions } from 'hooks/querySuggestions/useGetQueryKeySuggestions';
|
||||
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import OtherFields from '../OtherFields';
|
||||
|
||||
jest.mock('hooks/querySuggestions/useGetQueryKeySuggestions');
|
||||
|
||||
const mockSuggestions = (names: string[]): void => {
|
||||
(useGetQueryKeySuggestions as jest.Mock).mockReturnValue({
|
||||
data: {
|
||||
data: {
|
||||
data: {
|
||||
keys: {
|
||||
attributeKeys: names.map((name) => ({
|
||||
name,
|
||||
signal: 'logs',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: '',
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
isFetching: false,
|
||||
});
|
||||
};
|
||||
|
||||
const renderOtherFields = (
|
||||
props: Partial<React.ComponentProps<typeof OtherFields>> = {},
|
||||
): { onAdd: jest.Mock } => {
|
||||
const onAdd = jest.fn();
|
||||
render(
|
||||
<OtherFields
|
||||
signal={DataSource.LOGS}
|
||||
debouncedInputValue=""
|
||||
addedFields={[]}
|
||||
onAdd={onAdd}
|
||||
isAtLimit={false}
|
||||
allowCustomFields
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
return { onAdd };
|
||||
};
|
||||
|
||||
const addedField = (name: string): TelemetryFieldKey => ({
|
||||
name,
|
||||
signal: 'logs',
|
||||
fieldContext: '',
|
||||
fieldDataType: '',
|
||||
key: name,
|
||||
});
|
||||
|
||||
describe('OtherFields — custom (free-typed) option', () => {
|
||||
beforeEach(() => {
|
||||
mockSuggestions([]);
|
||||
});
|
||||
|
||||
it('shows a custom option for a typed name that is not a suggestion', () => {
|
||||
renderOtherFields({ debouncedInputValue: 'unknown.a.b.c' });
|
||||
|
||||
expect(screen.getByText('unknown.a.b.c')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /add/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('synthesizes the field with raw name, empty context/type, on add', () => {
|
||||
const { onAdd } = renderOtherFields({ debouncedInputValue: 'orderId' });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /add/i }));
|
||||
|
||||
expect(onAdd).toHaveBeenCalledWith({
|
||||
name: 'orderId',
|
||||
fieldContext: '',
|
||||
fieldDataType: '',
|
||||
key: 'orderId',
|
||||
});
|
||||
});
|
||||
|
||||
it('hides the custom option when an exact suggestion exists (case-insensitive)', () => {
|
||||
mockSuggestions(['orderId']);
|
||||
renderOtherFields({ debouncedInputValue: 'orderid' });
|
||||
|
||||
// the real suggestion shows, the lowercased custom name does not
|
||||
expect(screen.getByText('orderId')).toBeInTheDocument();
|
||||
expect(screen.queryByText('orderid')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the custom option when the name is already added (case-insensitive)', () => {
|
||||
renderOtherFields({
|
||||
debouncedInputValue: 'ORDERID',
|
||||
addedFields: [addedField('orderId')],
|
||||
});
|
||||
|
||||
expect(screen.queryByText('ORDERID')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('No values found')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show the custom option when allowCustomFields is off', () => {
|
||||
renderOtherFields({
|
||||
debouncedInputValue: 'unknown.a.b.c',
|
||||
allowCustomFields: false,
|
||||
});
|
||||
|
||||
expect(screen.queryByText('unknown.a.b.c')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('No values found')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show the custom option for an empty input', () => {
|
||||
renderOtherFields({ debouncedInputValue: ' ' });
|
||||
|
||||
expect(screen.getByText('No values found')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the custom option at the field limit but hides its Add button', () => {
|
||||
renderOtherFields({ debouncedInputValue: 'unknown.a.b.c', isAtLimit: true });
|
||||
|
||||
// same as every other row at the limit: name shown, no Add button
|
||||
expect(screen.getByText('unknown.a.b.c')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /add/i }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -51,13 +51,13 @@ describe('useLogsTableColumns — selectColumns-order respected', () => {
|
||||
);
|
||||
|
||||
// body/timestamp appear where the caller placed them, keyed by their
|
||||
// composite IDs ('log.*'); contextless user fields collapse to bare name.
|
||||
// composite IDs ('log:*'); contextless user fields collapse to bare name.
|
||||
expect(result.current.map((c) => c.id)).toStrictEqual([
|
||||
'state-indicator',
|
||||
'service.name',
|
||||
'log.body',
|
||||
'log:body',
|
||||
'request.id',
|
||||
'log.timestamp',
|
||||
'log:timestamp',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -70,14 +70,14 @@ describe('useLogsTableColumns — selectColumns-order respected', () => {
|
||||
);
|
||||
|
||||
const byId = new Map(result.current.map((c) => [c.id, c]));
|
||||
// Attribute variant is its own column, not a duplicate 'log.body'.
|
||||
// Attribute variant is its own column, not a duplicate 'log:body'.
|
||||
expect(result.current.map((c) => c.id)).toStrictEqual([
|
||||
'state-indicator',
|
||||
'log.body',
|
||||
'attribute.body',
|
||||
'log:body',
|
||||
'attribute:body',
|
||||
]);
|
||||
expect(byId.get('log.body')?.enableRemove).toBe(false);
|
||||
expect(byId.get('attribute.body')?.enableRemove).toBe(true);
|
||||
expect(byId.get('log:body')?.enableRemove).toBe(false);
|
||||
expect(byId.get('attribute:body')?.enableRemove).toBe(true);
|
||||
});
|
||||
|
||||
it('applies the same distinct-column treatment to timestamp variants', () => {
|
||||
@@ -91,11 +91,11 @@ describe('useLogsTableColumns — selectColumns-order respected', () => {
|
||||
const byId = new Map(result.current.map((c) => [c.id, c]));
|
||||
expect(result.current.map((c) => c.id)).toStrictEqual([
|
||||
'state-indicator',
|
||||
'log.timestamp',
|
||||
'attribute.timestamp',
|
||||
'log:timestamp',
|
||||
'attribute:timestamp',
|
||||
]);
|
||||
expect(byId.get('log.timestamp')?.enableRemove).toBe(false);
|
||||
expect(byId.get('attribute.timestamp')?.enableRemove).toBe(true);
|
||||
expect(byId.get('log:timestamp')?.enableRemove).toBe(false);
|
||||
expect(byId.get('attribute:timestamp')?.enableRemove).toBe(true);
|
||||
});
|
||||
|
||||
it('skips the synthetic "id" field name', () => {
|
||||
@@ -127,10 +127,10 @@ describe('useLogsTableColumns — selectColumns-order respected', () => {
|
||||
|
||||
const byId = new Map(result.current.map((c) => [c.id, c]));
|
||||
// body + timestamp are locked from the table-X removal pathway.
|
||||
expect(byId.get('log.body')?.canBeHidden).toBe(false);
|
||||
expect(byId.get('log.body')?.enableRemove).toBe(false);
|
||||
expect(byId.get('log.timestamp')?.canBeHidden).toBe(false);
|
||||
expect(byId.get('log.timestamp')?.enableRemove).toBe(false);
|
||||
expect(byId.get('log:body')?.canBeHidden).toBe(false);
|
||||
expect(byId.get('log:body')?.enableRemove).toBe(false);
|
||||
expect(byId.get('log:timestamp')?.canBeHidden).toBe(false);
|
||||
expect(byId.get('log:timestamp')?.enableRemove).toBe(false);
|
||||
// User-added fields stay removable. User field has type='' so composite
|
||||
// collapses to bare name.
|
||||
expect(byId.get('user_field')?.enableRemove).toBe(true);
|
||||
|
||||
@@ -44,6 +44,13 @@
|
||||
--tanstack-first-column-header-bg,
|
||||
var(--tanstack-table-header-cell-bg, var(--l2-background))
|
||||
) !important;
|
||||
padding-left: var(
|
||||
--tanstack-cell-header-padding-left-first-column,
|
||||
var(
|
||||
--tanstack-cell-header-padding-left-override,
|
||||
var(--tanstack-cell-padding-left, 0.3rem)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -161,7 +161,7 @@
|
||||
.tableHeaderCell {
|
||||
padding: var(--tanstack-cell-padding-top) var(--tanstack-cell-padding-right)
|
||||
var(--tanstack-cell-padding-bottom) var(--tanstack-cell-padding-left);
|
||||
height: 36px;
|
||||
height: var(--tanstack-table-header-height, 36px);
|
||||
text-align: left;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
|
||||
@@ -664,6 +664,7 @@ function TanStackTableInner<TData, TItemKey = string>(
|
||||
value={limit?.toString()}
|
||||
defaultValue="10"
|
||||
onChange={(value): void => {
|
||||
value ??= '10';
|
||||
setLimit(+value);
|
||||
pagination.onLimitChange?.(+value);
|
||||
if (page !== 1) {
|
||||
|
||||
@@ -1,72 +1,32 @@
|
||||
// Code generated by scripts/semconv. DO NOT EDIT.
|
||||
|
||||
// An empty contexts/signals/applyToMetrics array places no constraint on
|
||||
// that axis.
|
||||
export type SemconvMember = {
|
||||
readonly name: string;
|
||||
readonly contexts: readonly string[];
|
||||
readonly signals: readonly string[];
|
||||
readonly applyToMetrics: readonly string[];
|
||||
};
|
||||
|
||||
export type SemconvFamily = {
|
||||
readonly current: string;
|
||||
readonly old: readonly string[];
|
||||
readonly kind: 'attribute' | 'metric';
|
||||
readonly members: readonly SemconvMember[];
|
||||
readonly contexts: readonly string[];
|
||||
readonly signals: readonly string[];
|
||||
readonly applyToMetrics: readonly string[];
|
||||
readonly valueMap: Readonly<Record<string, string>>;
|
||||
};
|
||||
|
||||
export const SEMCONV_FAMILIES: readonly SemconvFamily[] = [
|
||||
{
|
||||
current: 'container.cpu.usage',
|
||||
kind: 'metric',
|
||||
members: [
|
||||
{ name: 'container.cpu.utilization', contexts: [], signals: [], applyToMetrics: [] },
|
||||
],
|
||||
current: 'db.system.name',
|
||||
old: ['db.system'],
|
||||
kind: 'attribute',
|
||||
contexts: [],
|
||||
signals: [],
|
||||
valueMap: {},
|
||||
},
|
||||
{
|
||||
current: 'db.system.name',
|
||||
kind: 'attribute',
|
||||
members: [
|
||||
{ name: 'db.system', contexts: [], signals: [], applyToMetrics: [] },
|
||||
],
|
||||
contexts: [],
|
||||
signals: ['logs', 'traces'],
|
||||
applyToMetrics: [],
|
||||
valueMap: {},
|
||||
},
|
||||
{
|
||||
current: 'deployment.environment.name',
|
||||
old: ['deployment.environment'],
|
||||
kind: 'attribute',
|
||||
members: [
|
||||
{ name: 'deployment.environment', contexts: [], signals: [], applyToMetrics: [] },
|
||||
],
|
||||
contexts: [],
|
||||
signals: ['logs', 'metrics', 'traces'],
|
||||
valueMap: {},
|
||||
},
|
||||
{
|
||||
current: 'k8s.node.cpu.usage',
|
||||
kind: 'metric',
|
||||
members: [
|
||||
{ name: 'k8s.node.cpu.utilization', contexts: [], signals: [], applyToMetrics: [] },
|
||||
],
|
||||
contexts: [],
|
||||
signals: [],
|
||||
valueMap: {},
|
||||
},
|
||||
{
|
||||
current: 'k8s.pod.cpu.usage',
|
||||
kind: 'metric',
|
||||
members: [
|
||||
{ name: 'k8s.pod.cpu.utilization', contexts: [], signals: [], applyToMetrics: [] },
|
||||
],
|
||||
contexts: [],
|
||||
signals: [],
|
||||
applyToMetrics: [],
|
||||
valueMap: {},
|
||||
},
|
||||
] as const;
|
||||
|
||||
@@ -11,6 +11,7 @@ export enum LOCALSTORAGE {
|
||||
TRACES_LIST_OPTIONS = 'TRACES_LIST_OPTIONS',
|
||||
GRAPH_VISIBILITY_STATES = 'GRAPH_VISIBILITY_STATES',
|
||||
TRACES_LIST_COLUMNS = 'TRACES_LIST_COLUMNS',
|
||||
TRACES_VIEW_COLUMNS = 'TRACES_VIEW_COLUMNS',
|
||||
LOGS_LIST_COLUMNS = 'LOGS_LIST_COLUMNS',
|
||||
LOGS_LIST_COLUMN_SIZING = 'LOGS_LIST_COLUMN_SIZING',
|
||||
LOGGED_IN_USER_NAME = 'LOGGED_IN_USER_NAME',
|
||||
|
||||
@@ -5,6 +5,7 @@ import BarChart from 'container/DashboardContainer/visualization/charts/BarChart
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
import {
|
||||
LegendPosition,
|
||||
TooltipRenderArgs,
|
||||
@@ -131,9 +132,9 @@ export function BillingUsageGraph(props: BillingUsageGraphProps): JSX.Element {
|
||||
<div ref={graphRef} className={styles.graphContainer}>
|
||||
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
|
||||
<BarChart
|
||||
stack={StackMode.Normal}
|
||||
config={config}
|
||||
data={chartData}
|
||||
isStackedBarChart
|
||||
legendConfig={{ position: LegendPosition.BOTTOM }}
|
||||
customTooltip={renderBillingTooltip}
|
||||
width={containerDimensions.width}
|
||||
|
||||
@@ -58,26 +58,17 @@ describe('prepareBillingBarConfig', () => {
|
||||
expect(config.series?.[4]?.stroke).toBe(Color.BG_AMBER_500);
|
||||
});
|
||||
|
||||
it('sets stacking bands, padding, and focus alpha for behavioral parity', () => {
|
||||
it('sets padding and focus alpha for behavioral parity', () => {
|
||||
const builder = prepareBillingBarConfig({
|
||||
...baseProps,
|
||||
apiResponse: makeApiResponse(['Logs', 'Traces', 'Metrics']),
|
||||
});
|
||||
const config = builder.getConfig();
|
||||
expect(config.bands).toStrictEqual([{ series: [1, 2] }, { series: [2, 3] }]);
|
||||
// Stacking bands come from the chart now — see useChartStacking.
|
||||
expect(config.padding).toStrictEqual([32, 32, 16, 16]);
|
||||
expect(config.focus).toStrictEqual({ alpha: 0.3 });
|
||||
});
|
||||
|
||||
it('sets no bands when result is empty', () => {
|
||||
const builder = prepareBillingBarConfig({
|
||||
...baseProps,
|
||||
apiResponse: makeApiResponse([]),
|
||||
});
|
||||
const config = builder.getConfig();
|
||||
expect(config.bands).toBeUndefined();
|
||||
});
|
||||
|
||||
it('uses queryName as label when legend is undefined', () => {
|
||||
const apiResponse: MetricRangePayloadProps = {
|
||||
data: {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import type { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getInitialStackedBands } from 'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils';
|
||||
import { buildBaseConfig } from 'container/DashboardContainer/visualization/panels/utils/baseConfigBuilder';
|
||||
import { DrawStyle } from 'lib/uPlotV2/config/types';
|
||||
import type { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
@@ -63,7 +62,6 @@ export function prepareBillingBarConfig({
|
||||
});
|
||||
});
|
||||
|
||||
builder.setBands(getInitialStackedBands(results.length));
|
||||
builder.setPadding([32, 32, 16, 16]);
|
||||
builder.setFocus({ alpha: 0.3 });
|
||||
|
||||
|
||||
@@ -6,25 +6,24 @@ import {
|
||||
TooltipRenderArgs,
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
|
||||
import { useBarChartStacking } from '../../hooks/useBarChartStacking';
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
|
||||
import { BarChartProps } from '../types';
|
||||
|
||||
export default function BarChart(props: BarChartProps): JSX.Element {
|
||||
const {
|
||||
children,
|
||||
isStackedBarChart,
|
||||
customTooltip,
|
||||
config,
|
||||
data,
|
||||
stack = StackMode.None,
|
||||
pinnedTooltipElement,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const chartData = useBarChartStacking({
|
||||
data,
|
||||
isStackedBarChart,
|
||||
config,
|
||||
});
|
||||
// Written during render so it lands before UPlotChart's effect reads the config,
|
||||
// which derives the fill bands, percent axis unit and percent range from it.
|
||||
config.setStackMode(stack);
|
||||
|
||||
const renderTooltip = useCallback(
|
||||
(props: TooltipRenderArgs): React.ReactNode => {
|
||||
@@ -37,7 +36,6 @@ export default function BarChart(props: BarChartProps): JSX.Element {
|
||||
timezone: rest.timezone,
|
||||
yAxisUnit: rest.yAxisUnit,
|
||||
decimalPrecision: rest.decimalPrecision,
|
||||
isStackedBarChart: isStackedBarChart,
|
||||
canPinTooltip: rest.canPinTooltip,
|
||||
renderTooltipFooter: rest.renderTooltipFooter,
|
||||
};
|
||||
@@ -48,7 +46,6 @@ export default function BarChart(props: BarChartProps): JSX.Element {
|
||||
rest.timezone,
|
||||
rest.yAxisUnit,
|
||||
rest.decimalPrecision,
|
||||
isStackedBarChart,
|
||||
rest.canPinTooltip,
|
||||
rest.renderTooltipFooter,
|
||||
],
|
||||
@@ -58,7 +55,7 @@ export default function BarChart(props: BarChartProps): JSX.Element {
|
||||
<ChartWrapper
|
||||
{...rest}
|
||||
config={config}
|
||||
data={chartData}
|
||||
data={data}
|
||||
customTooltip={renderTooltip}
|
||||
pinnedTooltipElement={pinnedTooltipElement}
|
||||
>
|
||||
|
||||
@@ -6,12 +6,15 @@ import {
|
||||
TooltipRenderArgs,
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
import UPlotChart from 'lib/uPlotV2/components/UPlotChart/UPlotChart';
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
import { prepareAlignedData } from 'lib/uPlotV2/components/UPlotChart/utils';
|
||||
import { PlotContextProvider } from 'lib/uPlotV2/context/PlotContext';
|
||||
import TooltipPlugin from 'lib/uPlotV2/plugins/TooltipPlugin/TooltipPlugin';
|
||||
import noop from 'lodash-es/noop';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { ChartProps } from '../types';
|
||||
import { ChartWrapperProps } from '../types';
|
||||
import { useChartStacking } from './useChartStacking';
|
||||
|
||||
const TOOLTIP_WIDTH_PADDING = 120;
|
||||
const TOOLTIP_MIN_WIDTH = 300;
|
||||
@@ -39,9 +42,20 @@ export default function ChartWrapper({
|
||||
pinnedTooltipElement,
|
||||
tooltipPortalRoot,
|
||||
'data-testid': testId,
|
||||
}: ChartProps): JSX.Element {
|
||||
}: ChartWrapperProps): JSX.Element {
|
||||
const plotInstanceRef = useRef<uPlot | null>(null);
|
||||
|
||||
const stack = config.getStackMode();
|
||||
const chartData = useChartStacking({ data, config });
|
||||
|
||||
// Tooltips need pre-stack values, gap-processed exactly as UPlotChart processes the
|
||||
// plot data — otherwise the cursor's index addresses a shorter array.
|
||||
const unstackedData = useMemo(
|
||||
() =>
|
||||
stack === StackMode.None ? undefined : prepareAlignedData({ data, config }),
|
||||
[data, config, stack],
|
||||
);
|
||||
|
||||
const legendComponent = useCallback(
|
||||
(averageLegendWidth: number): React.ReactNode => {
|
||||
if (!showLegend) {
|
||||
@@ -61,11 +75,11 @@ export default function ChartWrapper({
|
||||
const renderTooltipCallback = useCallback(
|
||||
(args: TooltipRenderArgs): React.ReactNode => {
|
||||
if (customTooltip) {
|
||||
return customTooltip(args);
|
||||
return customTooltip({ ...args, unstackedData });
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[customTooltip],
|
||||
[customTooltip, unstackedData],
|
||||
);
|
||||
|
||||
const syncMetadata = useMemo(
|
||||
@@ -91,7 +105,7 @@ export default function ChartWrapper({
|
||||
{({ chartWidth, chartHeight, averageLegendWidth }): JSX.Element => (
|
||||
<UPlotChart
|
||||
config={config}
|
||||
data={data}
|
||||
data={chartData}
|
||||
width={chartWidth}
|
||||
height={chartHeight}
|
||||
plotRef={(plot): void => {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { useChartStacking } from '../useChartStacking';
|
||||
|
||||
type Hooks = Record<string, (...args: unknown[]) => void>;
|
||||
|
||||
function createConfig(stack: StackMode): {
|
||||
config: UPlotConfigBuilder;
|
||||
hooks: Hooks;
|
||||
} {
|
||||
const hooks: Hooks = {};
|
||||
const config = {
|
||||
getStackMode: (): StackMode => stack,
|
||||
addHook: jest.fn((type: string, hook: (...args: unknown[]) => void) => {
|
||||
hooks[type] = hook;
|
||||
return jest.fn();
|
||||
}),
|
||||
} as unknown as UPlotConfigBuilder;
|
||||
return { config, hooks };
|
||||
}
|
||||
|
||||
const data = [[1], [30], [10]] as unknown as uPlot.AlignedData;
|
||||
|
||||
describe('useChartStacking', () => {
|
||||
it('returns the data untouched and registers nothing when the config says `none`', () => {
|
||||
const { config } = createConfig(StackMode.None);
|
||||
const { result } = renderHook(() => useChartStacking({ data, config }));
|
||||
|
||||
expect(result.current).toBe(data);
|
||||
expect(config.addHook).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('treats a missing config as unstacked', () => {
|
||||
const { result } = renderHook(() => useChartStacking({ data, config: null }));
|
||||
|
||||
expect(result.current).toBe(data);
|
||||
});
|
||||
|
||||
it('accumulates raw values when the config declares `normal`', () => {
|
||||
const { config } = createConfig(StackMode.Normal);
|
||||
const { result } = renderHook(() => useChartStacking({ data, config }));
|
||||
|
||||
expect(result.current).toStrictEqual([[1], [40], [10]]);
|
||||
});
|
||||
|
||||
it('rescales each column to its total when the config declares `percent`', () => {
|
||||
const { config } = createConfig(StackMode.Percent);
|
||||
const { result } = renderHook(() => useChartStacking({ data, config }));
|
||||
|
||||
expect(result.current).toStrictEqual([[1], [100], [25]]);
|
||||
});
|
||||
|
||||
it('registers the uPlot hooks that re-stack on data and visibility changes', () => {
|
||||
const { config } = createConfig(StackMode.Normal);
|
||||
renderHook(() => useChartStacking({ data, config }));
|
||||
|
||||
expect(
|
||||
(config.addHook as jest.Mock).mock.calls.map(([type]) => type),
|
||||
).toStrictEqual(['setData', 'setSeries']);
|
||||
});
|
||||
|
||||
it('re-stacks from the raw values when the legend hides a series', () => {
|
||||
const { config, hooks } = createConfig(StackMode.Normal);
|
||||
renderHook(() => useChartStacking({ data, config }));
|
||||
|
||||
const plot = {
|
||||
data: [[1]],
|
||||
series: [{}, { show: true }, { show: false }],
|
||||
delBand: jest.fn(),
|
||||
addBand: jest.fn(),
|
||||
setData: jest.fn(),
|
||||
};
|
||||
hooks.setSeries(plot, 2, { show: false });
|
||||
|
||||
// The hidden series keeps its raw value and stops contributing to the total.
|
||||
expect(plot.setData).toHaveBeenCalledWith([[1], [30], [10]]);
|
||||
expect(plot.delBand).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it('ignores a focus-only setSeries so hovering does not re-stack', () => {
|
||||
const { config, hooks } = createConfig(StackMode.Normal);
|
||||
renderHook(() => useChartStacking({ data, config }));
|
||||
|
||||
const plot = {
|
||||
data: [[1]],
|
||||
series: [{}, { show: true }, { show: true }],
|
||||
delBand: jest.fn(),
|
||||
addBand: jest.fn(),
|
||||
setData: jest.fn(),
|
||||
};
|
||||
hooks.setSeries(plot, 1, { focus: true });
|
||||
|
||||
expect(plot.setData).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
import {
|
||||
MutableRefObject,
|
||||
useCallback,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
import { has } from 'lodash-es';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { stackSeries } from '../utils/stackSeriesUtils';
|
||||
|
||||
/** Returns true if the series at the given index is hidden (e.g. via legend toggle). */
|
||||
function isSeriesHidden(plot: uPlot, seriesIndex: number): boolean {
|
||||
return !plot.series[seriesIndex]?.show;
|
||||
}
|
||||
|
||||
function canApplyStacking(
|
||||
unstackedData: uPlot.AlignedData | null,
|
||||
plot: uPlot,
|
||||
isUpdating: boolean,
|
||||
): boolean {
|
||||
return (
|
||||
!isUpdating &&
|
||||
!!unstackedData &&
|
||||
!!plot.data &&
|
||||
unstackedData[0]?.length === plot.data[0]?.length
|
||||
);
|
||||
}
|
||||
|
||||
function setupStackingHooks(
|
||||
config: UPlotConfigBuilder,
|
||||
updateStacksInChart: (plot: uPlot) => void,
|
||||
isUpdatingRef: MutableRefObject<boolean>,
|
||||
): () => void {
|
||||
const onDataChange = (plot: uPlot): void => {
|
||||
if (!isUpdatingRef.current) {
|
||||
updateStacksInChart(plot);
|
||||
}
|
||||
};
|
||||
|
||||
const onSeriesVisibilityChange = (
|
||||
plot: uPlot,
|
||||
_seriesIdx: number | null,
|
||||
opts: uPlot.Series,
|
||||
): void => {
|
||||
// uPlot fires setSeries for hover focus too; only visibility changes restack.
|
||||
if (!has(opts, 'focus')) {
|
||||
updateStacksInChart(plot);
|
||||
}
|
||||
};
|
||||
|
||||
const removeSetDataHook = config.addHook('setData', onDataChange);
|
||||
const removeSetSeriesHook = config.addHook(
|
||||
'setSeries',
|
||||
onSeriesVisibilityChange,
|
||||
);
|
||||
|
||||
return (): void => {
|
||||
removeSetDataHook?.();
|
||||
removeSetSeriesHook?.();
|
||||
};
|
||||
}
|
||||
|
||||
export interface UseChartStackingParams {
|
||||
data: uPlot.AlignedData;
|
||||
config: UPlotConfigBuilder | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stacks a chart's data for the mode declared on its config, and re-stacks on data or
|
||||
* visibility changes. The pre-stack values live in a ref because the uPlot hooks that
|
||||
* read them run outside React's render cycle.
|
||||
*/
|
||||
export function useChartStacking({
|
||||
data,
|
||||
config,
|
||||
}: UseChartStackingParams): uPlot.AlignedData {
|
||||
const stack = config?.getStackMode() ?? StackMode.None;
|
||||
const unstackedDataRef = useRef<uPlot.AlignedData | null>(null);
|
||||
unstackedDataRef.current = stack === 'none' ? null : data;
|
||||
|
||||
// Guards the re-entrant setData below, which would otherwise re-trigger our own hook.
|
||||
const isUpdatingChartRef = useRef(false);
|
||||
|
||||
const chartData = useMemo((): uPlot.AlignedData => {
|
||||
if (stack === StackMode.None || !data || data.length < 2) {
|
||||
return data;
|
||||
}
|
||||
const noSeriesHidden = (): boolean => false; // include all series in initial stack
|
||||
return stackSeries(data, noSeriesHidden, stack).data;
|
||||
}, [data, stack]);
|
||||
|
||||
const updateStacksInChart = useCallback(
|
||||
(plot: uPlot): void => {
|
||||
const unstacked = unstackedDataRef.current;
|
||||
if (
|
||||
!unstacked ||
|
||||
!canApplyStacking(unstacked, plot, isUpdatingChartRef.current)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldExcludeSeries = (idx: number): boolean =>
|
||||
isSeriesHidden(plot, idx);
|
||||
const { data: stacked, bands } = stackSeries(
|
||||
unstacked,
|
||||
shouldExcludeSeries,
|
||||
stack,
|
||||
);
|
||||
|
||||
plot.delBand(null);
|
||||
bands.forEach((band: uPlot.Band) => plot.addBand(band));
|
||||
|
||||
isUpdatingChartRef.current = true;
|
||||
plot.setData(stacked);
|
||||
isUpdatingChartRef.current = false;
|
||||
},
|
||||
[stack],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (stack === StackMode.None || !config) {
|
||||
return undefined;
|
||||
}
|
||||
return setupStackingHooks(config, updateStacksInChart, isUpdatingChartRef);
|
||||
}, [stack, config, updateStacksInChart]);
|
||||
|
||||
return chartData;
|
||||
}
|
||||
@@ -6,10 +6,16 @@ import {
|
||||
TooltipRenderArgs,
|
||||
} from 'lib/uPlotV2/components/types';
|
||||
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
|
||||
import { TimeSeriesChartProps } from '../types';
|
||||
|
||||
export default function TimeSeries(props: TimeSeriesChartProps): JSX.Element {
|
||||
const { children, customTooltip, ...rest } = props;
|
||||
const { children, customTooltip, stack = StackMode.None, ...rest } = props;
|
||||
|
||||
// Written during render so it lands before UPlotChart's effect reads the config,
|
||||
// which derives the fill bands, percent axis unit and percent range from it.
|
||||
rest.config.setStackMode(stack);
|
||||
|
||||
const renderTooltip = useCallback(
|
||||
(props: TooltipRenderArgs): React.ReactNode => {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
ChartClickData,
|
||||
} from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import type { StackMode } from 'lib/uPlotV2/config/types';
|
||||
|
||||
interface BaseChartProps {
|
||||
width: number;
|
||||
@@ -52,27 +53,26 @@ interface UPlotChartDataProps {
|
||||
groupByPerQuery?: Record<string, BaseAutocompleteData[]>;
|
||||
}
|
||||
|
||||
export interface TimeSeriesChartProps
|
||||
extends BaseChartProps, UPlotBasedChartProps, UPlotChartDataProps {
|
||||
/** Everything the shared uPlot shell consumes; each chart's props narrow it. */
|
||||
export interface ChartWrapperProps
|
||||
extends BaseChartProps, UPlotBasedChartProps, UPlotChartDataProps {}
|
||||
|
||||
export interface TimeSeriesChartProps extends ChartWrapperProps {
|
||||
timezone?: Timezone;
|
||||
/** How series compose. Defaults to `none`, which draws them independently. */
|
||||
stack?: StackMode;
|
||||
}
|
||||
|
||||
export interface HistogramChartProps
|
||||
extends BaseChartProps, UPlotBasedChartProps, UPlotChartDataProps {
|
||||
export interface BarChartProps extends ChartWrapperProps {
|
||||
timezone?: Timezone;
|
||||
/** How series compose. Defaults to `none`, which draws them independently. */
|
||||
stack?: StackMode;
|
||||
}
|
||||
|
||||
export interface HistogramChartProps extends ChartWrapperProps {
|
||||
isQueriesMerged?: boolean;
|
||||
}
|
||||
|
||||
export interface BarChartProps
|
||||
extends BaseChartProps, UPlotBasedChartProps, UPlotChartDataProps {
|
||||
isStackedBarChart?: boolean;
|
||||
timezone?: Timezone;
|
||||
}
|
||||
|
||||
export type ChartProps =
|
||||
| TimeSeriesChartProps
|
||||
| BarChartProps
|
||||
| HistogramChartProps;
|
||||
|
||||
/**
|
||||
* One resolved pie/donut slice: a display label, its (already parsed) positive
|
||||
* numeric value, and the colour used for the arc + legend swatch.
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { AlignedData } from 'uplot';
|
||||
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
|
||||
import { stackSeries } from '../stackSeriesUtils';
|
||||
|
||||
const includeAll = (): boolean => false;
|
||||
|
||||
// Stacking is top-down: the first series carries the column total, the last its own
|
||||
// raw value. Every expectation below reads in that order.
|
||||
describe('stackSeries', () => {
|
||||
it('is a no-op under `none`, returning the data and no bands', () => {
|
||||
const data: AlignedData = [[1], [30], [10]];
|
||||
|
||||
const { data: result, bands } = stackSeries(data, includeAll, StackMode.None);
|
||||
|
||||
expect(result).toBe(data);
|
||||
expect(bands).toStrictEqual([]);
|
||||
});
|
||||
|
||||
describe('normal', () => {
|
||||
it('accumulates raw values from the bottom series upward', () => {
|
||||
const data: AlignedData = [
|
||||
[1, 2],
|
||||
[10, 20],
|
||||
[1, 2],
|
||||
];
|
||||
|
||||
expect(stackSeries(data, includeAll, StackMode.Normal).data).toStrictEqual([
|
||||
[1, 2],
|
||||
[11, 22],
|
||||
[1, 2],
|
||||
]);
|
||||
});
|
||||
|
||||
it('treats nulls as 0 without breaking the running total', () => {
|
||||
const data: AlignedData = [
|
||||
[1, 2],
|
||||
[10, null],
|
||||
[1, 2],
|
||||
];
|
||||
|
||||
expect(stackSeries(data, includeAll, StackMode.Normal).data).toStrictEqual([
|
||||
[1, 2],
|
||||
[11, 2],
|
||||
[1, 2],
|
||||
]);
|
||||
});
|
||||
|
||||
it('emits one band per adjacent pair of participating series', () => {
|
||||
const data: AlignedData = [[1], [10], [5], [1]];
|
||||
|
||||
expect(stackSeries(data, includeAll, StackMode.Normal).bands).toStrictEqual([
|
||||
{ series: [1, 2] },
|
||||
{ series: [2, 3] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('copies omitted series through unstacked and skips their bands', () => {
|
||||
const data: AlignedData = [[1], [10], [5], [1]];
|
||||
const omitMiddle = (seriesIndex: number): boolean => seriesIndex === 2;
|
||||
|
||||
const { data: stacked, bands } = stackSeries(
|
||||
data,
|
||||
omitMiddle,
|
||||
StackMode.Normal,
|
||||
);
|
||||
|
||||
expect(stacked).toStrictEqual([[1], [11], [5], [1]]);
|
||||
expect(bands).toStrictEqual([{ series: [1, 3] }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('percent', () => {
|
||||
it('rescales each column to its total so the top series reads 100', () => {
|
||||
const data: AlignedData = [
|
||||
[1, 2],
|
||||
[30, 10],
|
||||
[10, 10],
|
||||
];
|
||||
|
||||
expect(stackSeries(data, includeAll, StackMode.Percent).data).toStrictEqual([
|
||||
[1, 2],
|
||||
[100, 100],
|
||||
[25, 50],
|
||||
]);
|
||||
});
|
||||
|
||||
it('normalises per column, so an identical series differs across x', () => {
|
||||
const data: AlignedData = [
|
||||
[1, 2],
|
||||
[1, 3],
|
||||
[1, 1],
|
||||
];
|
||||
|
||||
expect(stackSeries(data, includeAll, StackMode.Percent).data).toStrictEqual([
|
||||
[1, 2],
|
||||
[100, 100],
|
||||
[50, 25],
|
||||
]);
|
||||
});
|
||||
|
||||
it('excludes omitted series from the total, so the visible ones still reach 100', () => {
|
||||
const data: AlignedData = [[1], [30], [10], [60]];
|
||||
const omitLast = (seriesIndex: number): boolean => seriesIndex === 3;
|
||||
|
||||
expect(stackSeries(data, omitLast, StackMode.Percent).data).toStrictEqual([
|
||||
[1],
|
||||
[100],
|
||||
[25],
|
||||
[60],
|
||||
]);
|
||||
});
|
||||
|
||||
it('yields 0 for a column whose participating series sum to zero', () => {
|
||||
const data: AlignedData = [
|
||||
[1, 2],
|
||||
[0, 5],
|
||||
[0, 5],
|
||||
];
|
||||
|
||||
expect(stackSeries(data, includeAll, StackMode.Percent).data).toStrictEqual([
|
||||
[1, 2],
|
||||
[0, 100],
|
||||
[0, 50],
|
||||
]);
|
||||
});
|
||||
|
||||
it('divides by the signed total when a column mixes signs', () => {
|
||||
// 30 + (-10) = 20, so the shares are 150% and -50% and still sum to 100.
|
||||
const data: AlignedData = [[1], [30], [-10]];
|
||||
|
||||
expect(stackSeries(data, includeAll, StackMode.Percent).data).toStrictEqual([
|
||||
[1],
|
||||
[100],
|
||||
[-50],
|
||||
]);
|
||||
});
|
||||
|
||||
it('yields 0 across a column whose signed total cancels to zero', () => {
|
||||
const data: AlignedData = [[1], [10], [-10]];
|
||||
|
||||
expect(stackSeries(data, includeAll, StackMode.Percent).data).toStrictEqual([
|
||||
[1],
|
||||
[0],
|
||||
[0],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults to normal when no mode is given', () => {
|
||||
const data: AlignedData = [[1], [30], [10]];
|
||||
|
||||
expect(stackSeries(data, includeAll).data).toStrictEqual(
|
||||
stackSeries(data, includeAll, StackMode.Normal).data,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,117 +0,0 @@
|
||||
import { AlignedData } from 'uplot';
|
||||
|
||||
import { getInitialStackedBands, stack } from '../stackUtils';
|
||||
|
||||
describe('stackUtils', () => {
|
||||
describe('stack', () => {
|
||||
const neverOmit = (): boolean => false;
|
||||
|
||||
it('preserves time axis as first row', () => {
|
||||
const data: AlignedData = [
|
||||
[100, 200, 300],
|
||||
[1, 2, 3],
|
||||
[4, 5, 6],
|
||||
];
|
||||
const { data: result } = stack(data, neverOmit);
|
||||
expect(result[0]).toStrictEqual([100, 200, 300]);
|
||||
});
|
||||
|
||||
it('stacks value series cumulatively (last = raw, first = total)', () => {
|
||||
// Time, then 3 value series. Stack order: last series stays raw, then we add upward.
|
||||
const data: AlignedData = [
|
||||
[0, 1, 2],
|
||||
[1, 2, 3], // series 1
|
||||
[4, 5, 6], // series 2
|
||||
[7, 8, 9], // series 3
|
||||
];
|
||||
const { data: result } = stack(data, neverOmit);
|
||||
// result[1] = s1+s2+s3, result[2] = s2+s3, result[3] = s3
|
||||
expect(result[1]).toStrictEqual([12, 15, 18]); // 1+4+7, 2+5+8, 3+6+9
|
||||
expect(result[2]).toStrictEqual([11, 13, 15]); // 4+7, 5+8, 6+9
|
||||
expect(result[3]).toStrictEqual([7, 8, 9]);
|
||||
});
|
||||
|
||||
it('treats null values as 0 when stacking', () => {
|
||||
const data: AlignedData = [
|
||||
[0, 1],
|
||||
[1, null],
|
||||
[null, 10],
|
||||
];
|
||||
const { data: result } = stack(data, neverOmit);
|
||||
expect(result[1]).toStrictEqual([1, 10]); // total
|
||||
expect(result[2]).toStrictEqual([0, 10]); // last series with null→0
|
||||
});
|
||||
|
||||
it('copies omitted series as-is without accumulating', () => {
|
||||
// Omit series 2 (index 2); series 1 and 3 are stacked.
|
||||
const data: AlignedData = [
|
||||
[0, 1],
|
||||
[10, 20], // series 1
|
||||
[100, 200], // series 2 - omitted
|
||||
[1, 2], // series 3
|
||||
];
|
||||
const omitSeries2 = (i: number): boolean => i === 2;
|
||||
const { data: result } = stack(data, omitSeries2);
|
||||
// series 3 raw: [1, 2]; series 2 omitted: [100, 200] as-is; series 1 stacked with s3: [11, 22]
|
||||
expect(result[1]).toStrictEqual([11, 22]); // 10+1, 20+2
|
||||
expect(result[2]).toStrictEqual([100, 200]); // copied, not stacked
|
||||
expect(result[3]).toStrictEqual([1, 2]);
|
||||
});
|
||||
|
||||
it('returns bands between consecutive visible series when none omitted', () => {
|
||||
const data: AlignedData = [
|
||||
[0, 1],
|
||||
[1, 2],
|
||||
[3, 4],
|
||||
[5, 6],
|
||||
];
|
||||
const { bands } = stack(data, neverOmit);
|
||||
expect(bands).toStrictEqual([{ series: [1, 2] }, { series: [2, 3] }]);
|
||||
});
|
||||
|
||||
it('returns bands only between visible series when some are omitted', () => {
|
||||
// 4 value series; omit index 2. Visible: 1, 3, 4. Bands: [1,3], [3,4]
|
||||
const data: AlignedData = [[0], [1], [2], [3], [4]];
|
||||
const omitSeries2 = (i: number): boolean => i === 2;
|
||||
const { bands } = stack(data, omitSeries2);
|
||||
expect(bands).toStrictEqual([{ series: [1, 3] }, { series: [3, 4] }]);
|
||||
});
|
||||
|
||||
it('returns empty bands when only one value series', () => {
|
||||
const data: AlignedData = [
|
||||
[0, 1],
|
||||
[1, 2],
|
||||
];
|
||||
const { bands } = stack(data, neverOmit);
|
||||
expect(bands).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getInitialStackedBands', () => {
|
||||
it('returns one band between each consecutive pair for seriesCount 3', () => {
|
||||
expect(getInitialStackedBands(3)).toStrictEqual([
|
||||
{ series: [1, 2] },
|
||||
{ series: [2, 3] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns empty array for seriesCount 0 or 1', () => {
|
||||
expect(getInitialStackedBands(0)).toStrictEqual([]);
|
||||
expect(getInitialStackedBands(1)).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('returns single band for seriesCount 2', () => {
|
||||
expect(getInitialStackedBands(2)).toStrictEqual([{ series: [1, 2] }]);
|
||||
});
|
||||
|
||||
it('returns bands [1,2], [2,3], ..., [n-1, n] for seriesCount n', () => {
|
||||
const bands = getInitialStackedBands(5);
|
||||
expect(bands).toStrictEqual([
|
||||
{ series: [1, 2] },
|
||||
{ series: [2, 3] },
|
||||
{ series: [3, 4] },
|
||||
{ series: [4, 5] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,20 @@
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
import uPlot, { AlignedData } from 'uplot';
|
||||
|
||||
/**
|
||||
* Stack data cumulatively (top-down: first series = top, last = bottom).
|
||||
* When `omit(seriesIndex)` returns true, that series is excluded from stacking.
|
||||
* When `omit(seriesIndex)` returns true, that series keeps its raw values and
|
||||
* contributes nothing to the total. `None` is a no-op.
|
||||
*/
|
||||
export function stackSeries(
|
||||
data: AlignedData,
|
||||
omit: (seriesIndex: number) => boolean,
|
||||
mode: StackMode = StackMode.Normal,
|
||||
): { data: AlignedData; bands: uPlot.Band[] } {
|
||||
if (mode === StackMode.None) {
|
||||
return { data, bands: [] };
|
||||
}
|
||||
|
||||
const timeAxis = data[0];
|
||||
const pointCount = timeAxis.length;
|
||||
const valueSeriesCount = data.length - 1; // exclude time axis
|
||||
@@ -17,6 +24,7 @@ export function stackSeries(
|
||||
valueSeriesCount,
|
||||
pointCount,
|
||||
omit,
|
||||
mode,
|
||||
});
|
||||
const bands = buildFillBands(valueSeriesCount + 1, omit); // +1 for 1-based series indices
|
||||
|
||||
@@ -31,6 +39,46 @@ interface BuildStackedSeriesParams {
|
||||
valueSeriesCount: number;
|
||||
pointCount: number;
|
||||
omit: (seriesIndex: number) => boolean;
|
||||
mode: StackMode;
|
||||
}
|
||||
|
||||
/** Per-point total. Mixed-sign columns sum signed, as "share of total" implies. */
|
||||
function columnTotals({
|
||||
data,
|
||||
valueSeriesCount,
|
||||
pointCount,
|
||||
omit,
|
||||
}: Omit<BuildStackedSeriesParams, 'mode'>): number[] {
|
||||
const totals = Array(pointCount).fill(0) as number[];
|
||||
|
||||
for (let seriesIndex = 1; seriesIndex <= valueSeriesCount; seriesIndex++) {
|
||||
if (omit(seriesIndex)) {
|
||||
continue;
|
||||
}
|
||||
const rawValues = data[seriesIndex] as (number | null)[];
|
||||
rawValues.forEach((rawValue, pointIndex) => {
|
||||
totals[pointIndex] += rawValue == null ? 0 : Number(rawValue);
|
||||
});
|
||||
}
|
||||
|
||||
return totals;
|
||||
}
|
||||
|
||||
/** A column whose participating series sum to 0 has no share to divide, so every slice is 0. */
|
||||
function toPercent(value: number, total: number): number {
|
||||
return total === 0 ? 0 : (value / total) * 100;
|
||||
}
|
||||
|
||||
/** What a raw value adds to the stack at a given point. */
|
||||
type Contribution = (value: number, pointIndex: number) => number;
|
||||
|
||||
function contributionForMode(params: BuildStackedSeriesParams): Contribution {
|
||||
if (params.mode !== StackMode.Percent) {
|
||||
return (value): number => value;
|
||||
}
|
||||
// Resolved up front: totals span series the accumulation below has not reached yet.
|
||||
const totals = columnTotals(params);
|
||||
return (value, pointIndex): number => toPercent(value, totals[pointIndex]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,9 +90,17 @@ function buildStackedSeries({
|
||||
valueSeriesCount,
|
||||
pointCount,
|
||||
omit,
|
||||
mode,
|
||||
}: BuildStackedSeriesParams): (number | null)[][] {
|
||||
const stackedSeries: (number | null)[][] = Array(valueSeriesCount);
|
||||
const cumulativeSums = Array(pointCount).fill(0) as number[];
|
||||
const contributionOf = contributionForMode({
|
||||
data,
|
||||
valueSeriesCount,
|
||||
pointCount,
|
||||
omit,
|
||||
mode,
|
||||
});
|
||||
|
||||
for (let seriesIndex = valueSeriesCount; seriesIndex >= 1; seriesIndex--) {
|
||||
const rawValues = data[seriesIndex] as (number | null)[];
|
||||
@@ -54,7 +110,10 @@ function buildStackedSeries({
|
||||
} else {
|
||||
stackedSeries[seriesIndex - 1] = rawValues.map((rawValue, pointIndex) => {
|
||||
const numericValue = rawValue == null ? 0 : Number(rawValue);
|
||||
return (cumulativeSums[pointIndex] += numericValue);
|
||||
return (cumulativeSums[pointIndex] += contributionOf(
|
||||
numericValue,
|
||||
pointIndex,
|
||||
));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -101,16 +160,3 @@ function findNextVisibleSeriesIndex(
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns band indices for initial stacked state (no series omitted).
|
||||
* Top-down: first series at top, band fills between consecutive series.
|
||||
* uPlot band format: [upperSeriesIdx, lowerSeriesIdx].
|
||||
*/
|
||||
export function getInitialStackedBands(seriesCount: number): uPlot.Band[] {
|
||||
const bands: uPlot.Band[] = [];
|
||||
for (let seriesIndex = 1; seriesIndex < seriesCount; seriesIndex++) {
|
||||
bands.push({ series: [seriesIndex, seriesIndex + 1] });
|
||||
}
|
||||
return bands;
|
||||
}
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
import uPlot, { AlignedData } from 'uplot';
|
||||
|
||||
/**
|
||||
* Stack data cumulatively (top-down: first series = top, last = bottom).
|
||||
* When `omit(seriesIndex)` returns true, that series is excluded from stacking.
|
||||
*/
|
||||
export function stack(
|
||||
data: AlignedData,
|
||||
omit: (seriesIndex: number) => boolean,
|
||||
): { data: AlignedData; bands: uPlot.Band[] } {
|
||||
const timeAxis = data[0];
|
||||
const pointCount = timeAxis.length;
|
||||
const valueSeriesCount = data.length - 1; // exclude time axis
|
||||
|
||||
const stackedSeries = buildStackedSeries({
|
||||
data,
|
||||
valueSeriesCount,
|
||||
pointCount,
|
||||
omit,
|
||||
});
|
||||
const bands = buildFillBands(valueSeriesCount + 1, omit); // +1 for 1-based series indices
|
||||
|
||||
return {
|
||||
data: [timeAxis, ...stackedSeries] as AlignedData,
|
||||
bands,
|
||||
};
|
||||
}
|
||||
|
||||
interface BuildStackedSeriesParams {
|
||||
data: AlignedData;
|
||||
valueSeriesCount: number;
|
||||
pointCount: number;
|
||||
omit: (seriesIndex: number) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Accumulate from last series upward: last series = raw values, first = total.
|
||||
* Omitted series are copied as-is (no accumulation).
|
||||
*/
|
||||
function buildStackedSeries({
|
||||
data,
|
||||
valueSeriesCount,
|
||||
pointCount,
|
||||
omit,
|
||||
}: BuildStackedSeriesParams): (number | null)[][] {
|
||||
const stackedSeries: (number | null)[][] = Array(valueSeriesCount);
|
||||
const cumulativeSums = Array(pointCount).fill(0) as number[];
|
||||
|
||||
for (let seriesIndex = valueSeriesCount; seriesIndex >= 1; seriesIndex--) {
|
||||
const rawValues = data[seriesIndex] as (number | null)[];
|
||||
|
||||
if (omit(seriesIndex)) {
|
||||
stackedSeries[seriesIndex - 1] = rawValues;
|
||||
} else {
|
||||
stackedSeries[seriesIndex - 1] = rawValues.map((rawValue, pointIndex) => {
|
||||
const numericValue = rawValue == null ? 0 : Number(rawValue);
|
||||
return (cumulativeSums[pointIndex] += numericValue);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return stackedSeries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bands define fill between consecutive visible series for stacked appearance.
|
||||
* uPlot format: [upperSeriesIdx, lowerSeriesIdx].
|
||||
*/
|
||||
function buildFillBands(
|
||||
seriesLength: number,
|
||||
omit: (seriesIndex: number) => boolean,
|
||||
): uPlot.Band[] {
|
||||
const bands: uPlot.Band[] = [];
|
||||
|
||||
for (let seriesIndex = 1; seriesIndex < seriesLength; seriesIndex++) {
|
||||
if (omit(seriesIndex)) {
|
||||
continue;
|
||||
}
|
||||
const nextVisibleSeriesIndex = findNextVisibleSeriesIndex(
|
||||
seriesLength,
|
||||
seriesIndex,
|
||||
omit,
|
||||
);
|
||||
if (nextVisibleSeriesIndex !== -1) {
|
||||
bands.push({ series: [seriesIndex, nextVisibleSeriesIndex] });
|
||||
}
|
||||
}
|
||||
|
||||
return bands;
|
||||
}
|
||||
|
||||
function findNextVisibleSeriesIndex(
|
||||
seriesLength: number,
|
||||
afterIndex: number,
|
||||
omit: (seriesIndex: number) => boolean,
|
||||
): number {
|
||||
for (let i = afterIndex + 1; i < seriesLength; i++) {
|
||||
if (!omit(i)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns band indices for initial stacked state (no series omitted).
|
||||
* Top-down: first series at top, band fills between consecutive series.
|
||||
* uPlot band format: [upperSeriesIdx, lowerSeriesIdx].
|
||||
*/
|
||||
export function getInitialStackedBands(seriesCount: number): uPlot.Band[] {
|
||||
const bands: uPlot.Band[] = [];
|
||||
for (let seriesIndex = 1; seriesIndex < seriesCount; seriesIndex++) {
|
||||
bands.push({ series: [seriesIndex, seriesIndex + 1] });
|
||||
}
|
||||
return bands;
|
||||
}
|
||||
@@ -1,313 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import type { UseBarChartStackingParams } from '../useBarChartStacking';
|
||||
import { useBarChartStacking } from '../useBarChartStacking';
|
||||
|
||||
type MockConfig = { addHook: jest.Mock };
|
||||
|
||||
function asConfig(c: MockConfig): UseBarChartStackingParams['config'] {
|
||||
return c as unknown as UseBarChartStackingParams['config'];
|
||||
}
|
||||
|
||||
function createMockConfig(): {
|
||||
config: MockConfig;
|
||||
invokeSetData: (plot: uPlot) => void;
|
||||
invokeSetSeries: (
|
||||
plot: uPlot,
|
||||
seriesIndex: number | null,
|
||||
opts: Partial<uPlot.Series> & { focus?: boolean },
|
||||
) => void;
|
||||
removeSetData: jest.Mock;
|
||||
removeSetSeries: jest.Mock;
|
||||
} {
|
||||
let setDataHandler: ((plot: uPlot) => void) | null = null;
|
||||
let setSeriesHandler:
|
||||
| ((plot: uPlot, seriesIndex: number | null, opts: uPlot.Series) => void)
|
||||
| null = null;
|
||||
|
||||
const removeSetData = jest.fn();
|
||||
const removeSetSeries = jest.fn();
|
||||
|
||||
const addHook = jest.fn(
|
||||
(
|
||||
hookName: string,
|
||||
handler: (plot: uPlot, ...args: unknown[]) => void,
|
||||
): (() => void) => {
|
||||
if (hookName === 'setData') {
|
||||
setDataHandler = handler as (plot: uPlot) => void;
|
||||
return removeSetData;
|
||||
}
|
||||
if (hookName === 'setSeries') {
|
||||
setSeriesHandler = handler as (
|
||||
plot: uPlot,
|
||||
seriesIndex: number | null,
|
||||
opts: uPlot.Series,
|
||||
) => void;
|
||||
return removeSetSeries;
|
||||
}
|
||||
return jest.fn();
|
||||
},
|
||||
);
|
||||
|
||||
const config: MockConfig = { addHook };
|
||||
|
||||
const invokeSetData = (plot: uPlot): void => {
|
||||
setDataHandler?.(plot);
|
||||
};
|
||||
|
||||
const invokeSetSeries = (
|
||||
plot: uPlot,
|
||||
seriesIndex: number | null,
|
||||
opts: Partial<uPlot.Series> & { focus?: boolean },
|
||||
): void => {
|
||||
setSeriesHandler?.(plot, seriesIndex, opts as uPlot.Series);
|
||||
};
|
||||
|
||||
return {
|
||||
config,
|
||||
invokeSetData,
|
||||
invokeSetSeries,
|
||||
removeSetData,
|
||||
removeSetSeries,
|
||||
};
|
||||
}
|
||||
|
||||
function createMockPlot(overrides: Partial<uPlot> = {}): uPlot {
|
||||
return {
|
||||
data: [
|
||||
[0, 1, 2],
|
||||
[1, 2, 3],
|
||||
[4, 5, 6],
|
||||
],
|
||||
series: [{ show: true }, { show: true }, { show: true }],
|
||||
delBand: jest.fn(),
|
||||
addBand: jest.fn(),
|
||||
setData: jest.fn(),
|
||||
...overrides,
|
||||
} as unknown as uPlot;
|
||||
}
|
||||
|
||||
describe('useBarChartStacking', () => {
|
||||
it('returns data as-is when isStackedBarChart is false', () => {
|
||||
const data: uPlot.AlignedData = [
|
||||
[100, 200],
|
||||
[1, 2],
|
||||
[3, 4],
|
||||
];
|
||||
const { result } = renderHook(() =>
|
||||
useBarChartStacking({
|
||||
data,
|
||||
isStackedBarChart: false,
|
||||
config: null,
|
||||
}),
|
||||
);
|
||||
expect(result.current).toBe(data);
|
||||
});
|
||||
|
||||
it('returns data as-is when config is null and isStackedBarChart is true', () => {
|
||||
const data: uPlot.AlignedData = [
|
||||
[0, 1],
|
||||
[1, 2],
|
||||
[4, 5],
|
||||
];
|
||||
const { result } = renderHook(() =>
|
||||
useBarChartStacking({
|
||||
data,
|
||||
isStackedBarChart: true,
|
||||
config: null,
|
||||
}),
|
||||
);
|
||||
// Still returns stacked data (computed in useMemo); no hooks registered
|
||||
expect(result.current[0]).toStrictEqual([0, 1]);
|
||||
expect(result.current[1]).toStrictEqual([5, 7]); // stacked
|
||||
expect(result.current[2]).toStrictEqual([4, 5]);
|
||||
});
|
||||
|
||||
it('returns stacked data when isStackedBarChart is true and multiple value series', () => {
|
||||
const data: uPlot.AlignedData = [
|
||||
[0, 1, 2],
|
||||
[1, 2, 3],
|
||||
[4, 5, 6],
|
||||
[7, 8, 9],
|
||||
];
|
||||
const { result } = renderHook(() =>
|
||||
useBarChartStacking({
|
||||
data,
|
||||
isStackedBarChart: true,
|
||||
config: null,
|
||||
}),
|
||||
);
|
||||
expect(result.current[0]).toStrictEqual([0, 1, 2]);
|
||||
expect(result.current[1]).toStrictEqual([12, 15, 18]); // s1+s2+s3
|
||||
expect(result.current[2]).toStrictEqual([11, 13, 15]); // s2+s3
|
||||
expect(result.current[3]).toStrictEqual([7, 8, 9]);
|
||||
});
|
||||
|
||||
it('returns data as-is when only one value series (no stacking needed)', () => {
|
||||
const data: uPlot.AlignedData = [
|
||||
[0, 1],
|
||||
[1, 2],
|
||||
];
|
||||
const { result } = renderHook(() =>
|
||||
useBarChartStacking({
|
||||
data,
|
||||
isStackedBarChart: true,
|
||||
config: null,
|
||||
}),
|
||||
);
|
||||
expect(result.current).toStrictEqual(data);
|
||||
});
|
||||
|
||||
it('registers setData and setSeries hooks when isStackedBarChart and config provided', () => {
|
||||
const { config } = createMockConfig();
|
||||
const data: uPlot.AlignedData = [
|
||||
[0, 1],
|
||||
[1, 2],
|
||||
[3, 4],
|
||||
];
|
||||
|
||||
renderHook(() =>
|
||||
useBarChartStacking({
|
||||
data,
|
||||
isStackedBarChart: true,
|
||||
config: asConfig(config),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(config.addHook).toHaveBeenCalledWith('setData', expect.any(Function));
|
||||
expect(config.addHook).toHaveBeenCalledWith(
|
||||
'setSeries',
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not register hooks when isStackedBarChart is false', () => {
|
||||
const { config } = createMockConfig();
|
||||
const data: uPlot.AlignedData = [
|
||||
[0, 1],
|
||||
[1, 2],
|
||||
[3, 4],
|
||||
];
|
||||
|
||||
renderHook(() =>
|
||||
useBarChartStacking({
|
||||
data,
|
||||
isStackedBarChart: false,
|
||||
config: asConfig(config),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(config.addHook).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls cleanup when unmounted', () => {
|
||||
const { config, removeSetData, removeSetSeries } = createMockConfig();
|
||||
const data: uPlot.AlignedData = [
|
||||
[0, 1],
|
||||
[1, 2],
|
||||
[3, 4],
|
||||
];
|
||||
|
||||
const { unmount } = renderHook(() =>
|
||||
useBarChartStacking({
|
||||
data,
|
||||
isStackedBarChart: true,
|
||||
config: asConfig(config),
|
||||
}),
|
||||
);
|
||||
|
||||
unmount();
|
||||
|
||||
expect(removeSetData).toHaveBeenCalled();
|
||||
expect(removeSetSeries).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('re-stacks and updates plot when setData hook is invoked', () => {
|
||||
const { config, invokeSetData } = createMockConfig();
|
||||
const data: uPlot.AlignedData = [
|
||||
[0, 1, 2],
|
||||
[1, 2, 3],
|
||||
[4, 5, 6],
|
||||
];
|
||||
const plot = createMockPlot({
|
||||
data: [
|
||||
[0, 1, 2],
|
||||
[5, 7, 9],
|
||||
[4, 5, 6],
|
||||
],
|
||||
});
|
||||
|
||||
renderHook(() =>
|
||||
useBarChartStacking({
|
||||
data,
|
||||
isStackedBarChart: true,
|
||||
config: asConfig(config),
|
||||
}),
|
||||
);
|
||||
|
||||
invokeSetData(plot);
|
||||
|
||||
expect(plot.delBand).toHaveBeenCalledWith(null);
|
||||
expect(plot.addBand).toHaveBeenCalled();
|
||||
expect(plot.setData).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
[0, 1, 2],
|
||||
expect.any(Array), // stacked row 1
|
||||
expect.any(Array), // stacked row 2
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('re-stacks when setSeries hook is invoked (e.g. legend toggle)', () => {
|
||||
const { config, invokeSetSeries } = createMockConfig();
|
||||
const data: uPlot.AlignedData = [
|
||||
[0, 1],
|
||||
[10, 20],
|
||||
[5, 10],
|
||||
];
|
||||
// Plot data must match unstacked length so canApplyStacking passes
|
||||
const plot = createMockPlot({
|
||||
data: [
|
||||
[0, 1],
|
||||
[15, 30],
|
||||
[5, 10],
|
||||
],
|
||||
});
|
||||
|
||||
renderHook(() =>
|
||||
useBarChartStacking({
|
||||
data,
|
||||
isStackedBarChart: true,
|
||||
config: asConfig(config),
|
||||
}),
|
||||
);
|
||||
|
||||
invokeSetSeries(plot, 1, { show: false });
|
||||
|
||||
expect(plot.setData).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not re-stack when setSeries is called with focus option', () => {
|
||||
const { config, invokeSetSeries } = createMockConfig();
|
||||
const data: uPlot.AlignedData = [
|
||||
[0, 1],
|
||||
[1, 2],
|
||||
[3, 4],
|
||||
];
|
||||
const plot = createMockPlot();
|
||||
|
||||
renderHook(() =>
|
||||
useBarChartStacking({
|
||||
data,
|
||||
isStackedBarChart: true,
|
||||
config: asConfig(config),
|
||||
}),
|
||||
);
|
||||
|
||||
(plot.setData as jest.Mock).mockClear();
|
||||
invokeSetSeries(plot, 1, { focus: true } as uPlot.Series);
|
||||
|
||||
expect(plot.setData).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,125 +0,0 @@
|
||||
import {
|
||||
MutableRefObject,
|
||||
useCallback,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import { has } from 'lodash-es';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { stackSeries } from '../charts/utils/stackSeriesUtils';
|
||||
|
||||
/** Returns true if the series at the given index is hidden (e.g. via legend toggle). */
|
||||
function isSeriesHidden(plot: uPlot, seriesIndex: number): boolean {
|
||||
return !plot.series[seriesIndex]?.show;
|
||||
}
|
||||
|
||||
function canApplyStacking(
|
||||
unstackedData: uPlot.AlignedData | null,
|
||||
plot: uPlot,
|
||||
isUpdating: boolean,
|
||||
): boolean {
|
||||
return (
|
||||
!isUpdating &&
|
||||
!!unstackedData &&
|
||||
!!plot.data &&
|
||||
unstackedData[0]?.length === plot.data[0]?.length
|
||||
);
|
||||
}
|
||||
|
||||
function setupStackingHooks(
|
||||
config: UPlotConfigBuilder,
|
||||
applyStackingToChart: (plot: uPlot) => void,
|
||||
isUpdatingRef: MutableRefObject<boolean>,
|
||||
): () => void {
|
||||
const onDataChange = (plot: uPlot): void => {
|
||||
if (!isUpdatingRef.current) {
|
||||
applyStackingToChart(plot);
|
||||
}
|
||||
};
|
||||
|
||||
const onSeriesVisibilityChange = (
|
||||
plot: uPlot,
|
||||
_seriesIdx: number | null,
|
||||
opts: uPlot.Series,
|
||||
): void => {
|
||||
if (!has(opts, 'focus')) {
|
||||
applyStackingToChart(plot);
|
||||
}
|
||||
};
|
||||
|
||||
const removeSetDataHook = config.addHook('setData', onDataChange);
|
||||
const removeSetSeriesHook = config.addHook(
|
||||
'setSeries',
|
||||
onSeriesVisibilityChange,
|
||||
);
|
||||
|
||||
return (): void => {
|
||||
removeSetDataHook?.();
|
||||
removeSetSeriesHook?.();
|
||||
};
|
||||
}
|
||||
|
||||
export interface UseBarChartStackingParams {
|
||||
data: uPlot.AlignedData;
|
||||
isStackedBarChart?: boolean;
|
||||
config: UPlotConfigBuilder | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles stacking for bar charts: computes initial stacked data and re-stacks
|
||||
* when data or series visibility changes (e.g. legend toggles).
|
||||
*/
|
||||
export function useBarChartStacking({
|
||||
data,
|
||||
isStackedBarChart = false,
|
||||
config,
|
||||
}: UseBarChartStackingParams): uPlot.AlignedData {
|
||||
// Store unstacked source data so uPlot hooks can access it (hooks run outside React's render cycle)
|
||||
const unstackedDataRef = useRef<uPlot.AlignedData | null>(null);
|
||||
unstackedDataRef.current = isStackedBarChart ? data : null;
|
||||
|
||||
// Prevents re-entrant calls when we update chart data (avoids infinite loop in setData hook)
|
||||
const isUpdatingChartRef = useRef(false);
|
||||
|
||||
const chartData = useMemo((): uPlot.AlignedData => {
|
||||
if (!isStackedBarChart || !data || data.length < 2) {
|
||||
return data;
|
||||
}
|
||||
const noSeriesHidden = (): boolean => false; // include all series in initial stack
|
||||
const { data: stacked } = stackSeries(data, noSeriesHidden);
|
||||
return stacked;
|
||||
}, [data, isStackedBarChart]);
|
||||
|
||||
const applyStackingToChart = useCallback((plot: uPlot): void => {
|
||||
const unstacked = unstackedDataRef.current;
|
||||
if (
|
||||
!unstacked ||
|
||||
!canApplyStacking(unstacked, plot, isUpdatingChartRef.current)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldExcludeSeries = (idx: number): boolean =>
|
||||
isSeriesHidden(plot, idx);
|
||||
const { data: stacked, bands } = stackSeries(unstacked, shouldExcludeSeries);
|
||||
|
||||
plot.delBand(null);
|
||||
bands.forEach((band: uPlot.Band) => plot.addBand(band));
|
||||
|
||||
isUpdatingChartRef.current = true;
|
||||
plot.setData(stacked);
|
||||
isUpdatingChartRef.current = false;
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isStackedBarChart || !config) {
|
||||
return undefined;
|
||||
}
|
||||
return setupStackingHooks(config, applyStackingToChart, isUpdatingChartRef);
|
||||
}, [isStackedBarChart, config, applyStackingToChart]);
|
||||
|
||||
return chartData;
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import { prepareBarPanelConfig } from './utils';
|
||||
import '../Panel.styles.scss';
|
||||
import TooltipFooter from '../components/TooltipFooter';
|
||||
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
|
||||
function BarPanel(props: PanelWrapperProps): JSX.Element {
|
||||
const {
|
||||
@@ -147,6 +148,7 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
|
||||
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
|
||||
<BarChart
|
||||
key={`${syncMode}-${syncFilterMode}`}
|
||||
stack={widget.stackedBarChart ? StackMode.Normal : StackMode.None}
|
||||
config={config}
|
||||
legendConfig={{
|
||||
position: widget?.legendPosition ?? LegendPosition.BOTTOM,
|
||||
@@ -159,7 +161,6 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
|
||||
height={containerDimensions.height}
|
||||
layoutChildren={layoutChildren}
|
||||
groupByPerQuery={groupByPerQuery}
|
||||
isStackedBarChart={widget.stackedBarChart ?? false}
|
||||
yAxisUnit={widget.yAxisUnit}
|
||||
decimalPrecision={widget.decimalPrecision}
|
||||
timezone={timezone}
|
||||
|
||||
@@ -35,20 +35,10 @@ jest.mock('lib/getLabelName', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock(
|
||||
'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils',
|
||||
() => ({
|
||||
getInitialStackedBands: jest.fn().mockReturnValue([]),
|
||||
}),
|
||||
);
|
||||
|
||||
const getLegendMock = jest.requireMock('lib/dashboard/getQueryResults')
|
||||
.getLegend as jest.Mock;
|
||||
const getLabelNameMock = jest.requireMock('lib/getLabelName')
|
||||
.default as jest.Mock;
|
||||
const getInitialStackedBandsMock = jest.requireMock(
|
||||
'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils',
|
||||
).getInitialStackedBands as jest.Mock;
|
||||
|
||||
const createApiResponse = (
|
||||
result: MetricRangePayloadProps['data']['result'] = [],
|
||||
@@ -247,36 +237,5 @@ describe('BarPanel utils', () => {
|
||||
}).getConfig();
|
||||
expect(config.series?.[1]).toMatchObject({ stroke: '#ff0000' });
|
||||
});
|
||||
|
||||
it('calls getInitialStackedBands when widget is stackedBarChart', () => {
|
||||
const widget = createWidget({ stackedBarChart: true });
|
||||
const apiResponse = createApiResponse([
|
||||
{
|
||||
metric: {},
|
||||
queryName: 'Q1',
|
||||
values: [[1000, '1']],
|
||||
} as MetricRangePayloadProps['data']['result'][0],
|
||||
{
|
||||
metric: {},
|
||||
queryName: 'Q2',
|
||||
values: [[1000, '2']],
|
||||
} as MetricRangePayloadProps['data']['result'][0],
|
||||
]);
|
||||
prepareBarPanelConfig({ ...baseParams, widget, apiResponse });
|
||||
// seriesCount = result.length + 1 = 3
|
||||
expect(getInitialStackedBandsMock).toHaveBeenCalledWith(3);
|
||||
});
|
||||
|
||||
it('does not call getInitialStackedBands for non-stacked chart', () => {
|
||||
const apiResponse = createApiResponse([
|
||||
{
|
||||
metric: {},
|
||||
queryName: 'Q1',
|
||||
values: [[1000, '1']],
|
||||
} as MetricRangePayloadProps['data']['result'][0],
|
||||
]);
|
||||
prepareBarPanelConfig({ ...baseParams, apiResponse });
|
||||
expect(getInitialStackedBandsMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { ExecStats } from 'api/v5/v5';
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getInitialStackedBands } from 'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils';
|
||||
import { getLegend } from 'lib/dashboard/getQueryResults';
|
||||
import getLabelName from 'lib/getLabelName';
|
||||
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
|
||||
@@ -69,11 +68,6 @@ export function prepareBarPanelConfig({
|
||||
return builder;
|
||||
}
|
||||
|
||||
if (widget.stackedBarChart) {
|
||||
const seriesCount = (apiResponse.data.result.length ?? 0) + 1; // +1 for 1-based uPlot series indices
|
||||
builder.setBands(getInitialStackedBands(seriesCount));
|
||||
}
|
||||
|
||||
apiResponse.data.result.forEach((series) => {
|
||||
const baseLabelName = getLabelName(
|
||||
series.metric,
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-2) var(--spacing-2);
|
||||
|
||||
--tab-content-padding: 0;
|
||||
--tab-text-color: var(--l1-foreground);
|
||||
--tab-active-text-color: var(--l1-foreground);
|
||||
--tabs-content-padding: 0;
|
||||
--tabs-text-color: var(--l1-foreground);
|
||||
--tabs-active-text-color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.pageError {
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
height: 100%;
|
||||
margin-top: var(--spacing-2);
|
||||
margin-left: var(--spacing-2);
|
||||
--tab-text-color: var(--l1-foreground);
|
||||
--tab-active-text-color: var(--l1-foreground);
|
||||
--tabs-text-color: var(--l1-foreground);
|
||||
--tabs-active-text-color: var(--l1-foreground);
|
||||
[role='tabpanel'] {
|
||||
margin: 0;
|
||||
padding: var(--spacing-0) var(--spacing-4);
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
--tab-content-padding: 0;
|
||||
--tabs-content-padding: 0;
|
||||
margin-top: var(--spacing-3);
|
||||
--tab-text-color: var(--l1-foreground);
|
||||
--tab-active-text-color: var(--l1-foreground);
|
||||
--tabs-text-color: var(--l1-foreground);
|
||||
--tabs-active-text-color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.tabLabel {
|
||||
|
||||
@@ -275,6 +275,7 @@ function LiveLogsContainer({
|
||||
onClose={(): void => setIsFieldsSelectorOpen(false)}
|
||||
signal={DataSource.LOGS}
|
||||
requiredFields={LOGS_REQUIRED_COLUMNS}
|
||||
allowCustomFields
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -113,6 +113,7 @@ function LogsActionsContainer({
|
||||
onClose={(): void => setIsFieldsSelectorOpen(false)}
|
||||
signal={DataSource.LOGS}
|
||||
requiredFields={LOGS_REQUIRED_COLUMNS}
|
||||
allowCustomFields
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
}
|
||||
|
||||
// Remove default tab content padding/margin — the card provides spacing.
|
||||
--tab-content-padding: 0;
|
||||
--tab-content-margin: var(--spacing-4) 0 0;
|
||||
--tabs-content-padding: 0;
|
||||
--tabs-content-margin: var(--spacing-4) 0 0;
|
||||
}
|
||||
|
||||
.mcp-client-tabs {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import useUrlYAxisUnit from 'hooks/useUrlYAxisUnit';
|
||||
import { LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
import { prepareChartData } from 'lib/uPlotV2/utils/dataUtils';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { AppState } from 'store/reducers';
|
||||
@@ -137,6 +138,7 @@ function TimeSeries({
|
||||
key={`${WIDGET_ID}-${index}`}
|
||||
>
|
||||
<BarChart
|
||||
stack={StackMode.Normal}
|
||||
config={chart.config}
|
||||
legendConfig={{
|
||||
position: LegendPosition.BOTTOM,
|
||||
@@ -144,7 +146,6 @@ function TimeSeries({
|
||||
data={chart.chartData as uPlot.AlignedData}
|
||||
width={containerDimensions.width}
|
||||
height={containerDimensions.height}
|
||||
isStackedBarChart
|
||||
yAxisUnit={yAxisUnit || 'short'}
|
||||
timezone={timezone}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getInitialStackedBands } from 'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils';
|
||||
import { getLegend } from 'lib/dashboard/getQueryResults';
|
||||
import getLabelName from 'lib/getLabelName';
|
||||
import {
|
||||
@@ -89,9 +88,6 @@ export function buildMeterChartConfig({
|
||||
return builder;
|
||||
}
|
||||
|
||||
const seriesCount = (apiResponse.data.result.length ?? 0) + 1;
|
||||
builder.setBands(getInitialStackedBands(seriesCount));
|
||||
|
||||
apiResponse.data.result.forEach((series) => {
|
||||
const baseLabelName = getLabelName(
|
||||
series.metric,
|
||||
|
||||
@@ -296,12 +296,12 @@ describe('useOptionsMenu', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
// New order: [attribute.service.name, log.body, resource.service.name, log.timestamp]
|
||||
// New order: [attribute:service.name, log:body, resource:service.name, log:timestamp]
|
||||
result.current.config.addColumn?.onReorder([
|
||||
'attribute.service.name',
|
||||
'log.body',
|
||||
'resource.service.name',
|
||||
'log.timestamp',
|
||||
'attribute:service.name',
|
||||
'log:body',
|
||||
'resource:service.name',
|
||||
'log:timestamp',
|
||||
]);
|
||||
|
||||
expect(mockUpdateColumns).toHaveBeenCalledTimes(1);
|
||||
@@ -309,13 +309,13 @@ describe('useOptionsMenu', () => {
|
||||
expect(
|
||||
reordered.map(
|
||||
(c: { name: string; fieldContext: string }) =>
|
||||
`${c.fieldContext}.${c.name}`,
|
||||
`${c.fieldContext}:${c.name}`,
|
||||
),
|
||||
).toStrictEqual([
|
||||
'attribute.service.name',
|
||||
'log.body',
|
||||
'resource.service.name',
|
||||
'log.timestamp',
|
||||
'attribute:service.name',
|
||||
'log:body',
|
||||
'resource:service.name',
|
||||
'log:timestamp',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -329,11 +329,11 @@ describe('useOptionsMenu', () => {
|
||||
|
||||
result.current.config.addColumn?.onReorder([
|
||||
'state-indicator',
|
||||
'log.timestamp',
|
||||
'log:timestamp',
|
||||
'unknown.composite',
|
||||
'log.body',
|
||||
'resource.service.name',
|
||||
'attribute.service.name',
|
||||
'log:body',
|
||||
'resource:service.name',
|
||||
'attribute:service.name',
|
||||
]);
|
||||
|
||||
const reordered = mockUpdateColumns.mock.calls[0][0];
|
||||
@@ -341,13 +341,13 @@ describe('useOptionsMenu', () => {
|
||||
expect(
|
||||
reordered.map(
|
||||
(c: { name: string; fieldContext: string }) =>
|
||||
`${c.fieldContext}.${c.name}`,
|
||||
`${c.fieldContext}:${c.name}`,
|
||||
),
|
||||
).toStrictEqual([
|
||||
'log.timestamp',
|
||||
'log.body',
|
||||
'resource.service.name',
|
||||
'attribute.service.name',
|
||||
'log:timestamp',
|
||||
'log:body',
|
||||
'resource:service.name',
|
||||
'attribute:service.name',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -359,17 +359,17 @@ describe('useOptionsMenu', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
// Removing 'resource.service.name' should drop ONLY the resource variant.
|
||||
result.current.config.addColumn?.onRemove('resource.service.name');
|
||||
// Removing 'resource:service.name' should drop ONLY the resource variant.
|
||||
result.current.config.addColumn?.onRemove('resource:service.name');
|
||||
|
||||
expect(mockUpdateColumns).toHaveBeenCalledTimes(1);
|
||||
const remaining = mockUpdateColumns.mock.calls[0][0];
|
||||
expect(
|
||||
remaining.map(
|
||||
(c: { name: string; fieldContext: string }) =>
|
||||
`${c.fieldContext}.${c.name}`,
|
||||
`${c.fieldContext}:${c.name}`,
|
||||
),
|
||||
).toStrictEqual(['log.body', 'attribute.service.name', 'log.timestamp']);
|
||||
).toStrictEqual(['log:body', 'attribute:service.name', 'log:timestamp']);
|
||||
});
|
||||
|
||||
it('removing by a non-matching composite ID is a no-op (filter returns the full list)', () => {
|
||||
|
||||
@@ -16,7 +16,7 @@ export const getOptionsFromKeys = (
|
||||
};
|
||||
|
||||
// Composite identity for a column. Disambiguates same-name fields across
|
||||
// different fieldContexts (e.g. resource.service.name vs attribute.service.name).
|
||||
// different fieldContexts (e.g. resource:service.name vs attribute:service.name).
|
||||
// Falls back to bare name when context is missing.
|
||||
export const buildCompositeKey = (name: string, context?: string): string =>
|
||||
context ? `${context}.${name}` : name;
|
||||
context ? `${context}:${name}` : name;
|
||||
|
||||
@@ -35,7 +35,7 @@ exports[`Value panel wrappper tests should render tooltip when there are conflic
|
||||
class="c0"
|
||||
>
|
||||
<p
|
||||
class="_typography_ulrzs_1"
|
||||
class="_typography_j4pmm_1"
|
||||
data-slot="typography"
|
||||
data-variant="text"
|
||||
/>
|
||||
@@ -50,7 +50,7 @@ exports[`Value panel wrappper tests should render tooltip when there are conflic
|
||||
class="value-text-container"
|
||||
>
|
||||
<p
|
||||
class="_typography_ulrzs_1 value-graph-text"
|
||||
class="_typography_j4pmm_1 value-graph-text"
|
||||
data-slot="typography"
|
||||
data-testid="value-graph-text"
|
||||
data-variant="text"
|
||||
@@ -59,7 +59,7 @@ exports[`Value panel wrappper tests should render tooltip when there are conflic
|
||||
295.43
|
||||
</p>
|
||||
<p
|
||||
class="_typography_ulrzs_1 value-graph-unit"
|
||||
class="_typography_j4pmm_1 value-graph-unit"
|
||||
data-slot="typography"
|
||||
data-testid="value-graph-suffix-unit"
|
||||
data-variant="text"
|
||||
|
||||
@@ -22,11 +22,11 @@ exports[`PipelinePage container test should render DragAction section 1`] = `
|
||||
class="c0"
|
||||
>
|
||||
<div
|
||||
class="_switch-wrapper_jbsv7_1"
|
||||
class="_switch-wrapper_1a8sn_6"
|
||||
>
|
||||
<button
|
||||
aria-checked="true"
|
||||
class="_switch_jbsv7_1"
|
||||
class="_switch_1a8sn_6"
|
||||
data-color="robin"
|
||||
data-state="checked"
|
||||
id=":r0:"
|
||||
@@ -35,7 +35,7 @@ exports[`PipelinePage container test should render DragAction section 1`] = `
|
||||
value="on"
|
||||
>
|
||||
<span
|
||||
class="_switch__thumb_jbsv7_59"
|
||||
class="_switch__thumb_1a8sn_71"
|
||||
data-state="checked"
|
||||
/>
|
||||
</button>
|
||||
|
||||
@@ -74,7 +74,7 @@ exports[`PipelinePage container test should render PipelinePageLayout section 1`
|
||||
/>
|
||||
<div>
|
||||
<p
|
||||
class="_typography_ulrzs_1"
|
||||
class="_typography_j4pmm_1"
|
||||
data-slot="typography"
|
||||
data-variant="text"
|
||||
>
|
||||
|
||||
@@ -105,7 +105,7 @@
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
--tab-content-padding: 0px;
|
||||
--tabs-content-padding: 0px;
|
||||
|
||||
[role='tabpanel'] {
|
||||
display: flex;
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: calc(100vh - 240px);
|
||||
min-height: 400px;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination';
|
||||
|
||||
export const defaultSelectedColumns: string[] = [
|
||||
@@ -10,3 +11,9 @@ export const defaultSelectedColumns: string[] = [
|
||||
];
|
||||
|
||||
export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];
|
||||
|
||||
// Pinned timestamp column
|
||||
export const TIMESTAMP_FIELD = {
|
||||
name: 'timestamp',
|
||||
fieldContext: 'span',
|
||||
} as TelemetryFieldKey;
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { ENVIRONMENT } from 'constants/env';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { VirtuosoMockContext } from 'react-virtuoso';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
|
||||
import ListView from './index';
|
||||
|
||||
// globalTime starts with loading:true, which gates the list query. Force just that
|
||||
// slice's loading to false so the query fires; every other selector is untouched.
|
||||
jest.mock('react-redux', () => {
|
||||
const actual = jest.requireActual('react-redux');
|
||||
return {
|
||||
...actual,
|
||||
useSelector: (selector: (state: unknown) => unknown): unknown => {
|
||||
const result = actual.useSelector(selector);
|
||||
if (result && typeof result === 'object' && 'loading' in result) {
|
||||
return { ...result, loading: false };
|
||||
}
|
||||
return result;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// List columns come from the options menu (server-synced preferences). Pin them
|
||||
// so the query fires and the expected columns render, independent of that API.
|
||||
jest.mock('container/OptionsMenu/useOptionsMenu', () => ({
|
||||
__esModule: true,
|
||||
default: (): unknown => ({
|
||||
options: {
|
||||
selectColumns: [
|
||||
{ name: 'service.name', fieldContext: 'resource' },
|
||||
{ name: 'name', fieldContext: 'span' },
|
||||
{ name: 'duration_nano', fieldContext: 'span' },
|
||||
{ name: 'http_method', fieldContext: 'span' },
|
||||
{ name: 'response_status_code', fieldContext: 'span' },
|
||||
],
|
||||
},
|
||||
config: { addColumn: { onRemove: jest.fn() } },
|
||||
}),
|
||||
}));
|
||||
|
||||
const BASE_URL = ENVIRONMENT.baseURL;
|
||||
const QUERY_RANGE_URL = `${BASE_URL}/api/v5/query_range`;
|
||||
|
||||
const listRows = [
|
||||
{
|
||||
timestamp: '2024-07-19T08:39:58.735245Z',
|
||||
data: {
|
||||
'service.name': 'frontend',
|
||||
name: 'HTTP GET',
|
||||
duration_nano: 55306000,
|
||||
http_method: 'GET',
|
||||
response_status_code: '200',
|
||||
span_id: '772c4d29dd9076ac',
|
||||
trace_id: '0000000000000000344ded1387b08a7e',
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamp: '2024-07-19T08:39:59.949129915Z',
|
||||
data: {
|
||||
'service.name': 'demo-app',
|
||||
name: 'authenticate_check_db',
|
||||
duration_nano: 790949390,
|
||||
// empty status fields to assert the "-" cell
|
||||
http_method: '',
|
||||
response_status_code: '',
|
||||
span_id: '5704353737b6778e',
|
||||
trace_id: 'a364a8e15af3e9a8c866e0528db8b637',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const listResponse = (rows: unknown[]): Record<string, unknown> => ({
|
||||
data: { type: 'raw', data: { results: [{ queryName: 'A', rows }] } },
|
||||
});
|
||||
|
||||
const mockSuccess = (rows: unknown[] = listRows): void => {
|
||||
server.use(
|
||||
rest.post(QUERY_RANGE_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(listResponse(rows))),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const renderListView = (): ReturnType<typeof render> =>
|
||||
render(
|
||||
<VirtuosoMockContext.Provider value={{ viewportHeight: 500, itemHeight: 54 }}>
|
||||
<ListView
|
||||
isFilterApplied={false}
|
||||
setWarning={jest.fn()}
|
||||
setIsLoadingQueries={jest.fn()}
|
||||
/>
|
||||
</VirtuosoMockContext.Provider>,
|
||||
{},
|
||||
{
|
||||
initialRoute: '/traces-explorer',
|
||||
queryBuilderOverrides: {
|
||||
panelType: PANEL_TYPES.LIST,
|
||||
stagedQuery: initialQueriesMap.traces,
|
||||
currentQuery: initialQueriesMap.traces,
|
||||
redirectWithQueryBuilderData: jest.fn(),
|
||||
} as any,
|
||||
},
|
||||
);
|
||||
|
||||
describe('Traces ListView - Data Loaded', () => {
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('renders backend rows in FieldCell format', async () => {
|
||||
mockSuccess();
|
||||
renderListView();
|
||||
|
||||
// plain-text columns
|
||||
await expect(screen.findByText('frontend')).resolves.toBeInTheDocument();
|
||||
expect(screen.getByText('authenticate_check_db')).toBeInTheDocument();
|
||||
|
||||
// duration_nano renders in milliseconds
|
||||
expect(screen.getAllByTestId('duration_nano')[0]).toHaveTextContent(/ms$/);
|
||||
|
||||
// http_method / response_status_code render as badges
|
||||
expect(screen.getAllByTestId('http_method')[0]).toHaveTextContent('GET');
|
||||
expect(screen.getAllByTestId('response_status_code')[0]).toHaveTextContent(
|
||||
'200',
|
||||
);
|
||||
|
||||
// empty status fields render "-"
|
||||
expect(screen.getAllByText('-').length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
@@ -12,16 +12,18 @@ import {
|
||||
import { useSelector } from 'react-redux';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
|
||||
import { ResizeTable } from 'components/ResizeTable';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView/types';
|
||||
import TracesTable from 'container/TracesExplorer/TracesTable/TracesTable';
|
||||
import {
|
||||
getFieldColumn,
|
||||
TracesTableRow,
|
||||
} from 'container/TracesExplorer/TracesTable/getFieldColumn';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
|
||||
import NoLogs from 'container/NoLogs/NoLogs';
|
||||
import { useOptionsMenu } from 'container/OptionsMenu';
|
||||
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import TraceExplorerControls from 'container/TracesExplorer/Controls';
|
||||
@@ -32,20 +34,22 @@ import { Pagination } from 'hooks/queryPagination';
|
||||
import { getDefaultPaginationConfig } from 'hooks/queryPagination/utils';
|
||||
import useUrlQueryData from 'hooks/useUrlQueryData';
|
||||
import { ArrowUp10, Minus } from '@signozhq/icons';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { Warning } from 'types/api';
|
||||
import APIError from 'types/api/error';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import { TracesLoading } from '../TraceLoading/TraceLoading';
|
||||
import { defaultSelectedColumns, PER_PAGE_OPTIONS } from './configs';
|
||||
import { Container, tableStyles } from './styles';
|
||||
import { getListColumns, transformDataWithDate } from './utils';
|
||||
import {
|
||||
defaultSelectedColumns,
|
||||
PER_PAGE_OPTIONS,
|
||||
TIMESTAMP_FIELD,
|
||||
} from './configs';
|
||||
import { getTraceLink, transformSpanRows } from './utils';
|
||||
|
||||
import './ListView.styles.scss';
|
||||
|
||||
import styles from './ListView.module.scss';
|
||||
|
||||
interface ListViewProps {
|
||||
isFilterApplied: boolean;
|
||||
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
|
||||
@@ -93,7 +97,7 @@ function ListView({
|
||||
[stagedQuery, orderBy],
|
||||
);
|
||||
|
||||
// TEMP — remove after traces moves to TanStack table.
|
||||
// Stable sorted-name signature for the queryKey.
|
||||
// - Drag updates selectColumns; raw queryKey would churn on reorder.
|
||||
// - Trace API fetches only listed columns → add/remove must refetch.
|
||||
// - Sorted-name signature: stable on reorder, changes on add/remove.
|
||||
@@ -186,60 +190,42 @@ function ListView({
|
||||
[queryTableDataResult],
|
||||
);
|
||||
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
getListColumns(
|
||||
options?.selectColumns || [],
|
||||
formatTimezoneAdjustedTimestamp,
|
||||
const columns = useMemo<TableColumnDef<TracesTableRow>[]>(() => {
|
||||
const fields = [
|
||||
TIMESTAMP_FIELD,
|
||||
...(options?.selectColumns ?? []).filter(
|
||||
(field) => field.name !== TIMESTAMP_FIELD.name,
|
||||
),
|
||||
[options?.selectColumns, formatTimezoneAdjustedTimestamp],
|
||||
);
|
||||
];
|
||||
return fields.map((field) => getFieldColumn(field));
|
||||
}, [options?.selectColumns]);
|
||||
|
||||
const transformedQueryTableData = useMemo(
|
||||
() => transformDataWithDate(queryTableData) || [],
|
||||
const rows = useMemo(
|
||||
() => transformSpanRows(queryTableData),
|
||||
[queryTableData],
|
||||
);
|
||||
|
||||
const handleDragColumn = useCallback(
|
||||
(fromIndex: number, toIndex: number): void => {
|
||||
const reordered = [...columns];
|
||||
const [moved] = reordered.splice(fromIndex, 1);
|
||||
reordered.splice(toIndex, 0, moved);
|
||||
// `key` is the composite (fieldContext.name) — disambiguates same-name fields.
|
||||
const orderedIds = reordered
|
||||
.map((c) => String(c.key || ('dataIndex' in c && c.dataIndex) || ''))
|
||||
.filter(Boolean);
|
||||
config?.addColumn?.onReorder(orderedIds);
|
||||
const handleColumnOrderChange = useCallback(
|
||||
(cols: TableColumnDef<TracesTableRow>[]): void => {
|
||||
config?.addColumn?.onReorder(cols.map((c) => c.id));
|
||||
},
|
||||
[columns, config],
|
||||
[config],
|
||||
);
|
||||
|
||||
const handleOrderChange = useCallback((value: string) => {
|
||||
setOrderBy(value);
|
||||
}, []);
|
||||
|
||||
const isDataAbsent =
|
||||
!isLoading &&
|
||||
!isFetching &&
|
||||
!isError &&
|
||||
transformedQueryTableData.length === 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!isLoading &&
|
||||
!isFetching &&
|
||||
!isError &&
|
||||
transformedQueryTableData.length !== 0
|
||||
) {
|
||||
logEvent('Traces Explorer: Data present', {
|
||||
if (!isLoading && !isFetching && !isError && rows.length !== 0) {
|
||||
void logEvent('Traces Explorer: Data present', {
|
||||
panelType,
|
||||
});
|
||||
}
|
||||
}, [isLoading, isFetching, isError, transformedQueryTableData, panelType]);
|
||||
}, [isLoading, isFetching, isError, rows, panelType]);
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div className={styles.container}>
|
||||
<div className="trace-explorer-controls">
|
||||
<div className="order-by-container">
|
||||
<div className="order-by-label">
|
||||
@@ -266,33 +252,21 @@ function ListView({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isError && error && <ErrorInPlace error={error as APIError} />}
|
||||
|
||||
{(isLoading || (isFetching && transformedQueryTableData.length === 0)) && (
|
||||
<TracesLoading />
|
||||
)}
|
||||
|
||||
{isDataAbsent && !isFilterApplied && (
|
||||
<NoLogs dataSource={DataSource.TRACES} />
|
||||
)}
|
||||
|
||||
{isDataAbsent && isFilterApplied && (
|
||||
<EmptyLogsSearch dataSource={DataSource.TRACES} panelType="LIST" />
|
||||
)}
|
||||
|
||||
{!isError && transformedQueryTableData.length !== 0 && (
|
||||
<ResizeTable
|
||||
tableLayout="fixed"
|
||||
pagination={false}
|
||||
scroll={{ x: 'max-content' }}
|
||||
loading={isFetching}
|
||||
style={tableStyles}
|
||||
dataSource={transformedQueryTableData}
|
||||
columns={columns}
|
||||
onDragColumn={handleDragColumn}
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
<TracesTable
|
||||
data={rows}
|
||||
columns={columns}
|
||||
columnStorageKey={LOCALSTORAGE.TRACES_LIST_COLUMNS}
|
||||
panelType="LIST"
|
||||
getRowHref={getTraceLink}
|
||||
isLoading={isLoading}
|
||||
isFetching={isFetching}
|
||||
isError={isError}
|
||||
error={error}
|
||||
isFilterApplied={isFilterApplied}
|
||||
onColumnOrderChange={handleColumnOrderChange}
|
||||
onColumnRemove={config?.addColumn?.onRemove}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { TableColumnsType as ColumnsType } from 'antd';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import type { TracesTableRow } from 'container/TracesExplorer/TracesTable/getFieldColumn';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { buildCompositeKey } from 'container/OptionsMenu/utils';
|
||||
@@ -41,12 +42,23 @@ export const transformDataWithDate = (
|
||||
data[0]?.list?.map(({ data, timestamp }) => ({ ...data, date: timestamp })) ||
|
||||
[];
|
||||
|
||||
export const getTraceLink = (record: RowData): string =>
|
||||
`${ROUTES.TRACE}/${record.traceID || record.trace_id}${formUrlParams({
|
||||
spanId: record.spanID || record.span_id,
|
||||
export const getTraceLink = (record: Record<string, unknown>): string => {
|
||||
function readId(value: unknown): string {
|
||||
if (typeof value === 'string' || typeof value === 'number') {
|
||||
return String(value);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
const traceId = readId(record.traceID) || readId(record.trace_id);
|
||||
const spanId = readId(record.spanID) || readId(record.span_id);
|
||||
|
||||
return `${ROUTES.TRACE}/${traceId}${formUrlParams({
|
||||
spanId,
|
||||
levelUp: 0,
|
||||
levelDown: 0,
|
||||
})}`;
|
||||
};
|
||||
|
||||
export const getListColumns = (
|
||||
selectedColumns: TelemetryFieldKey[],
|
||||
@@ -136,3 +148,21 @@ export const getListColumns = (
|
||||
|
||||
return [...initialColumns, ...columns];
|
||||
};
|
||||
|
||||
// Reshapes the query-range list payload into table rows. `id` mirrors span_id so
|
||||
// TanStack sees genuine row changes on orderBy toggles instead of falling back to
|
||||
// positional ids; `timestamp` is lifted from the wrapping ListItem.
|
||||
export const transformSpanRows = (data: QueryDataV3[]): TracesTableRow[] => {
|
||||
const list = data[0]?.list;
|
||||
if (!list) {
|
||||
return [];
|
||||
}
|
||||
return list.map((item) => {
|
||||
const row = item.data as Record<string, unknown>;
|
||||
return {
|
||||
...row,
|
||||
timestamp: item.timestamp,
|
||||
id: row.span_id,
|
||||
};
|
||||
}) as TracesTableRow[];
|
||||
};
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { generatePath, Link } from 'react-router-dom';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
|
||||
import {
|
||||
DURATION_FIELD_NAMES,
|
||||
STATUS_FIELD_NAMES,
|
||||
TIMESTAMP_FIELD_NAMES,
|
||||
TRACE_ID_FIELD_NAMES,
|
||||
} from './constants';
|
||||
import { stringifyCellValue } from './utils';
|
||||
|
||||
type FieldCellProps = {
|
||||
name: string;
|
||||
value: unknown;
|
||||
};
|
||||
|
||||
function FieldCell({ name, value }: FieldCellProps): JSX.Element {
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
if (TIMESTAMP_FIELD_NAMES.has(name)) {
|
||||
const ts = value as string | number;
|
||||
const formatted =
|
||||
typeof ts === 'string'
|
||||
? formatTimezoneAdjustedTimestamp(ts, DATE_TIME_FORMATS.ISO_DATETIME_MS)
|
||||
: formatTimezoneAdjustedTimestamp(
|
||||
ts / 1e6,
|
||||
DATE_TIME_FORMATS.ISO_DATETIME_MS,
|
||||
);
|
||||
const text = String(formatted);
|
||||
return <TanStackTable.Text title={text}>{text}</TanStackTable.Text>;
|
||||
}
|
||||
|
||||
if (value === '' || value == null) {
|
||||
return <TanStackTable.Text data-testid={name}>-</TanStackTable.Text>;
|
||||
}
|
||||
|
||||
const text = stringifyCellValue(value);
|
||||
|
||||
if (TRACE_ID_FIELD_NAMES.has(name)) {
|
||||
return (
|
||||
<Link
|
||||
to={generatePath(ROUTES.TRACE_DETAIL, { id: text })}
|
||||
data-testid="trace-id"
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
>
|
||||
{text}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
if (STATUS_FIELD_NAMES.has(name)) {
|
||||
return (
|
||||
<Badge data-testid={name} color="sakura" variant="outline">
|
||||
{text}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
if (DURATION_FIELD_NAMES.has(name)) {
|
||||
return (
|
||||
<TanStackTable.Text data-testid={name}>{getMs(text)}ms</TanStackTable.Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TanStackTable.Text data-testid={name} title={text}>
|
||||
{text}
|
||||
</TanStackTable.Text>
|
||||
);
|
||||
}
|
||||
|
||||
export default FieldCell;
|
||||
@@ -0,0 +1,26 @@
|
||||
.tableWrapper {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tracesTable {
|
||||
--tanstack-table-row-height: 54px;
|
||||
--tanstack-table-header-height: 54px;
|
||||
|
||||
--tanstack-cell-padding-top-override: 5px;
|
||||
--tanstack-cell-padding-bottom-override: 5px;
|
||||
--tanstack-cell-padding-right-override: 15px;
|
||||
|
||||
--tanstack-cell-padding-left-override: 15px;
|
||||
--tanstack-cell-header-padding-left-override: 5px;
|
||||
|
||||
--tanstack-cell-header-padding-left-first-column: 15px;
|
||||
|
||||
--tanstack-plain-body-line-clamp: 1;
|
||||
|
||||
--tanstack-table-cell-bg: var(--l2-background);
|
||||
--tanstack-table-header-cell-bg: var(--l1-background-hover);
|
||||
--tanstack-table-row-hover-bg: var(--l1-background-hover);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import TanStackTable from 'components/TanStackTableView';
|
||||
import type {
|
||||
CellTypographySize,
|
||||
TableColumnDef,
|
||||
} from 'components/TanStackTableView/types';
|
||||
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
|
||||
import NoLogs from 'container/NoLogs/NoLogs';
|
||||
import { TracesLoading } from 'container/TracesExplorer/TraceLoading/TraceLoading';
|
||||
import APIError from 'types/api/error';
|
||||
import { DataSource, PanelTypeKeys } from 'types/common/queryBuilder';
|
||||
import { getAbsoluteUrl } from 'utils/basePath';
|
||||
|
||||
import type { TracesTableRow } from './getFieldColumn';
|
||||
import styles from './TracesTable.module.scss';
|
||||
|
||||
export type TracesTableProps = {
|
||||
data: TracesTableRow[];
|
||||
columns: TableColumnDef<TracesTableRow>[];
|
||||
columnStorageKey?: string;
|
||||
respectColumnOrder?: boolean;
|
||||
panelType: PanelTypeKeys;
|
||||
/** Builds the trace-detail href for a row; drives row click + cmd/ctrl-click. */
|
||||
getRowHref: (row: TracesTableRow) => string;
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
isError: boolean;
|
||||
error: APIError | Error | null;
|
||||
isFilterApplied: boolean;
|
||||
onColumnOrderChange?: (cols: TableColumnDef<TracesTableRow>[]) => void;
|
||||
onColumnRemove?: (columnId: string) => void;
|
||||
cellTypographySize?: CellTypographySize;
|
||||
};
|
||||
|
||||
function TracesTable({
|
||||
data,
|
||||
columns,
|
||||
columnStorageKey,
|
||||
respectColumnOrder = false,
|
||||
panelType,
|
||||
getRowHref,
|
||||
isLoading,
|
||||
isFetching,
|
||||
isError,
|
||||
error,
|
||||
isFilterApplied,
|
||||
onColumnOrderChange,
|
||||
onColumnRemove,
|
||||
cellTypographySize = 'medium',
|
||||
}: TracesTableProps): JSX.Element {
|
||||
const history = useHistory();
|
||||
|
||||
const isDataAbsent =
|
||||
!isLoading && !isFetching && !isError && data.length === 0;
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(row: TracesTableRow): void => {
|
||||
history.push(getRowHref(row));
|
||||
},
|
||||
[history, getRowHref],
|
||||
);
|
||||
|
||||
const handleRowClickNewTab = useCallback(
|
||||
(row: TracesTableRow): void => {
|
||||
window.open(getAbsoluteUrl(getRowHref(row)), '_blank', 'noopener');
|
||||
},
|
||||
[getRowHref],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isError && error && <ErrorInPlace error={error as APIError} />}
|
||||
|
||||
{(isLoading || (isFetching && data.length === 0)) && <TracesLoading />}
|
||||
|
||||
{isDataAbsent && !isFilterApplied && (
|
||||
<NoLogs dataSource={DataSource.TRACES} />
|
||||
)}
|
||||
|
||||
{isDataAbsent && isFilterApplied && (
|
||||
<EmptyLogsSearch dataSource={DataSource.TRACES} panelType={panelType} />
|
||||
)}
|
||||
|
||||
{!isError && data.length !== 0 && (
|
||||
<div className={styles.tableWrapper}>
|
||||
<TanStackTable<TracesTableRow>
|
||||
data={data}
|
||||
columns={columns}
|
||||
className={styles.tracesTable}
|
||||
columnStorageKey={columnStorageKey}
|
||||
respectColumnOrder={respectColumnOrder}
|
||||
isLoading={isFetching}
|
||||
cellTypographySize={cellTypographySize}
|
||||
onColumnOrderChange={onColumnOrderChange}
|
||||
onColumnRemove={onColumnRemove}
|
||||
onRowClick={handleRowClick}
|
||||
onRowClickNewTab={handleRowClickNewTab}
|
||||
getRowTestId={(row): string => `traces-table-row-${row.id}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
TracesTable.defaultProps = {
|
||||
columnStorageKey: undefined,
|
||||
respectColumnOrder: false,
|
||||
onColumnOrderChange: undefined,
|
||||
onColumnRemove: undefined,
|
||||
cellTypographySize: 'medium',
|
||||
};
|
||||
|
||||
export default TracesTable;
|
||||
@@ -0,0 +1,18 @@
|
||||
// Field-name allowlists that drive signal-specific cell rendering. Both legacy
|
||||
// camelCase and snake_case variants are listed because the API has shipped both.
|
||||
export const TIMESTAMP_FIELD_NAMES = new Set(['timestamp']);
|
||||
|
||||
export const STATUS_FIELD_NAMES = new Set([
|
||||
'httpMethod',
|
||||
'http_method',
|
||||
'http.method',
|
||||
'http.request.method',
|
||||
'responseStatusCode',
|
||||
'response_status_code',
|
||||
'http.status_code',
|
||||
'http.response.status_code',
|
||||
]);
|
||||
|
||||
export const DURATION_FIELD_NAMES = new Set(['durationNano', 'duration_nano']);
|
||||
|
||||
export const TRACE_ID_FIELD_NAMES = new Set(['traceID', 'trace_id']);
|
||||
@@ -0,0 +1,26 @@
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView/types';
|
||||
import { buildCompositeKey } from 'container/OptionsMenu/utils';
|
||||
|
||||
import { TIMESTAMP_FIELD_NAMES } from './constants';
|
||||
import FieldCell from './FieldCell';
|
||||
|
||||
export type TracesTableRow = { id: string } & Record<string, unknown>;
|
||||
|
||||
export function getFieldColumn(
|
||||
field: TelemetryFieldKey,
|
||||
): TableColumnDef<TracesTableRow> {
|
||||
const { name, fieldContext } = field;
|
||||
const isTimestamp = TIMESTAMP_FIELD_NAMES.has(name);
|
||||
|
||||
return {
|
||||
id: buildCompositeKey(name, fieldContext),
|
||||
header: name,
|
||||
accessorFn: (row): unknown => row[name],
|
||||
enableMove: !isTimestamp,
|
||||
enableRemove: !isTimestamp,
|
||||
canBeHidden: !isTimestamp,
|
||||
width: { min: 192 },
|
||||
cell: ({ value }): JSX.Element => <FieldCell name={name} value={value} />,
|
||||
};
|
||||
}
|
||||
12
frontend/src/container/TracesExplorer/TracesTable/utils.ts
Normal file
12
frontend/src/container/TracesExplorer/TracesTable/utils.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export function stringifyCellValue(value: unknown): string {
|
||||
if (value == null) {
|
||||
return '';
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
return String(value);
|
||||
}
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
// Page chain isn't a flex column, so anchor the virtualized table against the viewport.
|
||||
height: calc(100vh - 240px);
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.actionsContainer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
@@ -1,50 +1,25 @@
|
||||
import { generatePath, Link } from 'react-router-dom';
|
||||
import type { TableColumnsType as ColumnsType } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { getMs } from 'container/Trace/Filters/Panel/PanelBody/Duration/util';
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView/types';
|
||||
import {
|
||||
getFieldColumn,
|
||||
TracesTableRow,
|
||||
} from 'container/TracesExplorer/TracesTable/getFieldColumn';
|
||||
import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination';
|
||||
import { ListItem } from 'types/api/widgets/getQuery';
|
||||
|
||||
export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];
|
||||
|
||||
export const columns: ColumnsType<ListItem['data']> = [
|
||||
{
|
||||
title: 'Root Service Name',
|
||||
dataIndex: 'service.name',
|
||||
key: 'serviceName',
|
||||
},
|
||||
{
|
||||
title: 'Root Operation Name',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
},
|
||||
{
|
||||
title: 'Root Duration (in ms)',
|
||||
dataIndex: 'duration_nano',
|
||||
key: 'durationNano',
|
||||
render: (duration: number): JSX.Element => (
|
||||
<Typography>{getMs(String(duration))}ms</Typography>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'No of Spans',
|
||||
dataIndex: 'span_count',
|
||||
key: 'span_count',
|
||||
},
|
||||
{
|
||||
title: 'TraceID',
|
||||
dataIndex: 'trace_id',
|
||||
key: 'traceID',
|
||||
render: (traceID: string): JSX.Element => (
|
||||
<Link
|
||||
to={generatePath(ROUTES.TRACE_DETAIL, {
|
||||
id: traceID,
|
||||
})}
|
||||
data-testid="trace-id"
|
||||
>
|
||||
{traceID}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
];
|
||||
const TRACE_FIELDS = [
|
||||
{ name: 'service.name', fieldContext: 'resource' },
|
||||
{ name: 'name' },
|
||||
{ name: 'duration_nano' },
|
||||
{ name: 'span_count' },
|
||||
{ name: 'trace_id' },
|
||||
] as TelemetryFieldKey[];
|
||||
|
||||
export const columns: TableColumnDef<TracesTableRow>[] = TRACE_FIELDS.map(
|
||||
(field) => ({
|
||||
...getFieldColumn(field),
|
||||
enableRemove: false,
|
||||
canBeHidden: false,
|
||||
}),
|
||||
);
|
||||
|
||||
136
frontend/src/container/TracesExplorer/TracesView/index.test.tsx
Normal file
136
frontend/src/container/TracesExplorer/TracesView/index.test.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
import { ENVIRONMENT } from 'constants/env';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { VirtuosoMockContext } from 'react-virtuoso';
|
||||
import { render, screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import TracesView from './index';
|
||||
|
||||
const BASE_URL = ENVIRONMENT.baseURL;
|
||||
const QUERY_RANGE_URL = `${BASE_URL}/api/v5/query_range`;
|
||||
|
||||
const groupedRows = [
|
||||
{
|
||||
timestamp: '2024-07-19T08:39:58.735245Z',
|
||||
data: {
|
||||
'service.name': 'frontend',
|
||||
name: 'HTTP GET',
|
||||
duration_nano: 55306000,
|
||||
span_count: 8,
|
||||
trace_id: '0000000000000000344ded1387b08a7e',
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamp: '2024-07-19T08:39:59.949129915Z',
|
||||
data: {
|
||||
'service.name': 'demo-app',
|
||||
// intentionally empty to assert the "-" cell
|
||||
name: '',
|
||||
duration_nano: 790949390,
|
||||
span_count: 3,
|
||||
trace_id: 'a364a8e15af3e9a8c866e0528db8b637',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const groupedResponse = (rows: unknown[]): Record<string, unknown> => ({
|
||||
data: { type: 'trace', data: { results: [{ queryName: 'A', rows }] } },
|
||||
});
|
||||
|
||||
const mockSuccess = (rows: unknown[] = groupedRows): void => {
|
||||
server.use(
|
||||
rest.post(QUERY_RANGE_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(groupedResponse(rows))),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const mockError = (): void => {
|
||||
server.use(
|
||||
rest.post(QUERY_RANGE_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(500), ctx.json({ status: 'error', error: 'boom' })),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const renderTracesView = (
|
||||
props: Record<string, unknown> = {},
|
||||
): ReturnType<typeof render> =>
|
||||
render(
|
||||
<VirtuosoMockContext.Provider value={{ viewportHeight: 500, itemHeight: 54 }}>
|
||||
<TracesView
|
||||
isFilterApplied={false}
|
||||
setWarning={jest.fn()}
|
||||
setIsLoadingQueries={jest.fn()}
|
||||
{...props}
|
||||
/>
|
||||
</VirtuosoMockContext.Provider>,
|
||||
{},
|
||||
{
|
||||
initialRoute: '/traces-explorer',
|
||||
queryBuilderOverrides: {
|
||||
panelType: PANEL_TYPES.TRACE,
|
||||
stagedQuery: initialQueriesMap.traces,
|
||||
currentQuery: initialQueriesMap.traces,
|
||||
} as any,
|
||||
},
|
||||
);
|
||||
|
||||
describe('TracesView (grouped root-span table)', () => {
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('renders backend rows in FieldCell format', async () => {
|
||||
mockSuccess();
|
||||
renderTracesView();
|
||||
|
||||
// service.name + name render as plain text
|
||||
await expect(screen.findByText('frontend')).resolves.toBeInTheDocument();
|
||||
expect(screen.getByText('HTTP GET')).toBeInTheDocument();
|
||||
|
||||
// duration_nano renders in milliseconds
|
||||
expect(screen.getAllByTestId('duration_nano')[0]).toHaveTextContent(/ms$/);
|
||||
|
||||
// span_count renders as text
|
||||
expect(screen.getByText('8')).toBeInTheDocument();
|
||||
|
||||
// empty field renders "-"
|
||||
expect(screen.getAllByText('-').length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// trace_id renders as a link to the trace detail
|
||||
const traceLinks = screen.getAllByTestId('trace-id');
|
||||
expect(traceLinks[0]).toHaveAttribute(
|
||||
'href',
|
||||
expect.stringContaining('/trace/0000000000000000344ded1387b08a7e'),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the empty state and keeps the toolbar when there are no rows', async () => {
|
||||
mockSuccess([]);
|
||||
renderTracesView();
|
||||
|
||||
// toolbar (un-gated) stays visible regardless of data
|
||||
expect(
|
||||
screen.getByText(/This tab only shows Root Spans/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /previous/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /next/i })).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/No traces yet/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the toolbar visible on API error', async () => {
|
||||
mockError();
|
||||
renderTracesView();
|
||||
|
||||
expect(
|
||||
screen.getByText(/This tab only shows Root Spans/i),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /previous/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /next/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import {
|
||||
Dispatch,
|
||||
memo,
|
||||
@@ -12,30 +11,29 @@ import { useSelector } from 'react-redux';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import { ResizeTable } from 'components/ResizeTable';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
|
||||
import NoLogs from 'container/NoLogs/NoLogs';
|
||||
import { getListViewQuery } from 'container/TracesExplorer/explorerUtils';
|
||||
import { getTraceLink } from 'container/TracesExplorer/ListView/utils';
|
||||
import TracesTable from 'container/TracesExplorer/TracesTable/TracesTable';
|
||||
import { TracesTableRow } from 'container/TracesExplorer/TracesTable/getFieldColumn';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { Pagination } from 'hooks/queryPagination';
|
||||
import useUrlQueryData from 'hooks/useUrlQueryData';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { Warning } from 'types/api';
|
||||
import APIError from 'types/api/error';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import DOCLINKS from 'utils/docLinks';
|
||||
|
||||
import TraceExplorerControls from '../Controls';
|
||||
import { TracesLoading } from '../TraceLoading/TraceLoading';
|
||||
import { columns, PER_PAGE_OPTIONS } from './configs';
|
||||
import { ActionsContainer, Container } from './styles';
|
||||
|
||||
import styles from './TracesView.module.scss';
|
||||
|
||||
interface TracesViewProps {
|
||||
isFilterApplied: boolean;
|
||||
@@ -119,8 +117,13 @@ function TracesView({
|
||||
}, [data?.payload, data?.warning]);
|
||||
|
||||
const responseData = data?.payload?.data?.newResult?.data?.result[0]?.list;
|
||||
const tableData = useMemo(
|
||||
() => responseData?.map((listItem) => listItem.data),
|
||||
|
||||
const rows = useMemo<TracesTableRow[]>(
|
||||
() =>
|
||||
(responseData ?? []).map((item) => {
|
||||
const row = item.data;
|
||||
return { ...row, id: row.trace_id };
|
||||
}) as TracesTableRow[],
|
||||
[responseData],
|
||||
);
|
||||
|
||||
@@ -133,71 +136,52 @@ function TracesView({
|
||||
}, [isLoading, isFetching, setIsLoadingQueries]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isFetching && !isError && (tableData || []).length !== 0) {
|
||||
logEvent('Traces Explorer: Data present', {
|
||||
if (!isLoading && !isFetching && !isError && rows.length !== 0) {
|
||||
void logEvent('Traces Explorer: Data present', {
|
||||
panelType: 'TRACE',
|
||||
});
|
||||
}
|
||||
}, [isLoading, isFetching, isError, panelType, tableData]);
|
||||
}, [isLoading, isFetching, isError, rows.length]);
|
||||
|
||||
return (
|
||||
<Container>
|
||||
{(tableData || []).length !== 0 && (
|
||||
<ActionsContainer>
|
||||
<Typography>
|
||||
This tab only shows Root Spans. More details
|
||||
<Typography.Link href={DOCLINKS.TRACES_DETAILS_LINK} target="_blank">
|
||||
{' '}
|
||||
here
|
||||
</Typography.Link>
|
||||
</Typography>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.actionsContainer}>
|
||||
<Typography>
|
||||
This tab only shows Root Spans. More details
|
||||
<Typography.Link href={DOCLINKS.TRACES_DETAILS_LINK} target="_blank">
|
||||
{' '}
|
||||
here
|
||||
</Typography.Link>
|
||||
</Typography>
|
||||
|
||||
<div className="trace-explorer-controls">
|
||||
<DownloadOptionsMenu
|
||||
dataSource={DataSource.TRACES}
|
||||
panelType={PANEL_TYPES.TRACE}
|
||||
/>
|
||||
<div className="trace-explorer-controls">
|
||||
<DownloadOptionsMenu
|
||||
dataSource={DataSource.TRACES}
|
||||
panelType={PANEL_TYPES.TRACE}
|
||||
/>
|
||||
|
||||
<TraceExplorerControls
|
||||
isLoading={isLoading}
|
||||
totalCount={responseData?.length || 0}
|
||||
perPageOptions={PER_PAGE_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
</ActionsContainer>
|
||||
)}
|
||||
<TraceExplorerControls
|
||||
isLoading={isLoading}
|
||||
totalCount={rows.length}
|
||||
perPageOptions={PER_PAGE_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isError && error && <ErrorInPlace error={error as APIError} />}
|
||||
|
||||
{(isLoading || (isFetching && (tableData || []).length === 0)) && (
|
||||
<TracesLoading />
|
||||
)}
|
||||
|
||||
{!isLoading &&
|
||||
!isFetching &&
|
||||
!isError &&
|
||||
!isFilterApplied &&
|
||||
(tableData || []).length === 0 && <NoLogs dataSource={DataSource.TRACES} />}
|
||||
|
||||
{!isLoading &&
|
||||
!isFetching &&
|
||||
(tableData || []).length === 0 &&
|
||||
!isError &&
|
||||
isFilterApplied && (
|
||||
<EmptyLogsSearch dataSource={DataSource.TRACES} panelType="TRACE" />
|
||||
)}
|
||||
|
||||
{(tableData || []).length !== 0 && (
|
||||
<ResizeTable
|
||||
loading={isLoading}
|
||||
columns={columns}
|
||||
tableLayout="fixed"
|
||||
dataSource={tableData}
|
||||
scroll={{ x: true }}
|
||||
pagination={false}
|
||||
/>
|
||||
)}
|
||||
</Container>
|
||||
<TracesTable
|
||||
data={rows}
|
||||
columns={columns}
|
||||
columnStorageKey={LOCALSTORAGE.TRACES_VIEW_COLUMNS}
|
||||
respectColumnOrder
|
||||
panelType="TRACE"
|
||||
getRowHref={getTraceLink}
|
||||
isLoading={isLoading}
|
||||
isFetching={isFetching}
|
||||
isError={isError}
|
||||
error={error}
|
||||
isFilterApplied={isFilterApplied}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const Container = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
export const ActionsContainer = styled.div`
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
`;
|
||||
@@ -35,7 +35,7 @@
|
||||
}
|
||||
|
||||
.filterSelect {
|
||||
min-width: 300px;
|
||||
min-width: 400px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@@ -57,8 +57,6 @@
|
||||
|
||||
--tanstack-cell-padding-top-override: 5px;
|
||||
--tanstack-cell-padding-bottom-override: 5px;
|
||||
--tanstack-cell-padding-left-override: 5px;
|
||||
--tanstack-cell-padding-right-override: 5px;
|
||||
|
||||
--tanstack-cell-padding-left-override: 16px;
|
||||
--tanstack-cell-padding-right-override: 16px;
|
||||
|
||||
@@ -9,6 +9,7 @@ export default function BarChartTooltip(props: BarTooltipProps): JSX.Element {
|
||||
(): TooltipContentItem[] =>
|
||||
buildTooltipContent({
|
||||
data: props.uPlotInstance.data,
|
||||
unstackedData: props.unstackedData,
|
||||
series: props.uPlotInstance.series,
|
||||
dataIndexes: props.dataIndexes,
|
||||
activeSeriesIndex: props.seriesIndex,
|
||||
@@ -21,6 +22,7 @@ export default function BarChartTooltip(props: BarTooltipProps): JSX.Element {
|
||||
}),
|
||||
[
|
||||
props.uPlotInstance,
|
||||
props.unstackedData,
|
||||
props.seriesIndex,
|
||||
props.dataIndexes,
|
||||
props.yAxisUnit,
|
||||
|
||||
@@ -11,6 +11,7 @@ export default function TimeSeriesTooltip(
|
||||
(): TooltipContentItem[] =>
|
||||
buildTooltipContent({
|
||||
data: props.uPlotInstance.data,
|
||||
unstackedData: props.unstackedData,
|
||||
series: props.uPlotInstance.series,
|
||||
dataIndexes: props.dataIndexes,
|
||||
activeSeriesIndex: props.seriesIndex,
|
||||
@@ -22,6 +23,7 @@ export default function TimeSeriesTooltip(
|
||||
}),
|
||||
[
|
||||
props.uPlotInstance,
|
||||
props.unstackedData,
|
||||
props.seriesIndex,
|
||||
props.dataIndexes,
|
||||
props.yAxisUnit,
|
||||
|
||||
@@ -72,6 +72,35 @@ describe('Tooltip utils', () => {
|
||||
expect(result).toBe(20);
|
||||
});
|
||||
|
||||
it('reports the pre-stack value, identically for normal and percent', () => {
|
||||
const unstackedData: AlignedData = [[0], [30], [10]];
|
||||
const series = [{}, { show: true }, { show: true }] as Series[];
|
||||
const read = (data: AlignedData): number | null =>
|
||||
getTooltipBaseValue({
|
||||
data,
|
||||
unstackedData,
|
||||
index: 1,
|
||||
dataIndex: 0,
|
||||
isStackedBarChart: true,
|
||||
series,
|
||||
});
|
||||
|
||||
expect(read([[0], [40], [10]])).toBe(30);
|
||||
expect(read([[0], [100], [25]])).toBe(30);
|
||||
});
|
||||
|
||||
it('falls back to subtraction when no pre-stack data is given', () => {
|
||||
const result = getTooltipBaseValue({
|
||||
data: [[0], [40], [10]],
|
||||
index: 1,
|
||||
dataIndex: 0,
|
||||
isStackedBarChart: true,
|
||||
series: [{}, { show: true }, { show: true }] as Series[],
|
||||
});
|
||||
|
||||
expect(result).toBe(30);
|
||||
});
|
||||
|
||||
it('returns null when value is missing', () => {
|
||||
const data: AlignedData = [
|
||||
[0, 1],
|
||||
|
||||
@@ -23,17 +23,25 @@ export function resolveSeriesColor(
|
||||
|
||||
export function getTooltipBaseValue({
|
||||
data,
|
||||
unstackedData,
|
||||
index,
|
||||
dataIndex,
|
||||
isStackedBarChart,
|
||||
series,
|
||||
}: {
|
||||
data: AlignedData;
|
||||
unstackedData?: AlignedData;
|
||||
index: number;
|
||||
dataIndex: number;
|
||||
isStackedBarChart?: boolean;
|
||||
series?: Series[];
|
||||
}): number | null {
|
||||
// The subtraction below only recovers the raw value under `normal` stacking.
|
||||
const unstackedSeries = unstackedData?.[index];
|
||||
if (unstackedSeries) {
|
||||
return unstackedSeries[dataIndex] ?? null;
|
||||
}
|
||||
|
||||
let baseValue = data[index][dataIndex] ?? null;
|
||||
// Top-down stacking (first series at top): raw = stacked[i] - stacked[nextVisible].
|
||||
// When series are hidden, we must use the next *visible* series, not index+1,
|
||||
@@ -56,6 +64,7 @@ export function getTooltipBaseValue({
|
||||
|
||||
export function buildTooltipContent({
|
||||
data,
|
||||
unstackedData,
|
||||
series,
|
||||
dataIndexes,
|
||||
activeSeriesIndex,
|
||||
@@ -67,6 +76,7 @@ export function buildTooltipContent({
|
||||
syncFilterMode,
|
||||
}: {
|
||||
data: AlignedData;
|
||||
unstackedData?: AlignedData;
|
||||
series: Series[];
|
||||
dataIndexes: Array<number | null>;
|
||||
activeSeriesIndex: number | null;
|
||||
@@ -115,6 +125,7 @@ export function buildTooltipContent({
|
||||
|
||||
const baseValue = getTooltipBaseValue({
|
||||
data,
|
||||
unstackedData,
|
||||
index: seriesIndex,
|
||||
dataIndex,
|
||||
isStackedBarChart,
|
||||
|
||||
@@ -69,6 +69,11 @@ export interface TooltipRenderArgs {
|
||||
syncedSeriesIndexes?: number[] | null;
|
||||
/** Receiver-side filter mode for the synced tooltip. Defaults to Filtered. */
|
||||
syncFilterMode?: SyncTooltipFilterMode;
|
||||
/**
|
||||
* Pre-stack values, injected by `ChartWrapper`. `Percent` discards the column total,
|
||||
* so the raw value cannot be recovered from the plot's own cumulative data.
|
||||
*/
|
||||
unstackedData?: uPlot.AlignedData;
|
||||
}
|
||||
|
||||
export interface IRenderTooltipFooterArgs {
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
ConfigBuilderProps,
|
||||
LegendItem,
|
||||
SelectionPreferencesSource,
|
||||
StackMode,
|
||||
} from './types';
|
||||
import { AxisProps, UPlotAxisBuilder } from './UPlotAxisBuilder';
|
||||
import { ScaleProps, UPlotScaleBuilder } from './UPlotScaleBuilder';
|
||||
@@ -28,6 +29,11 @@ import { SeriesProps, UPlotSeriesBuilder } from './UPlotSeriesBuilder';
|
||||
/**
|
||||
* Type definitions for uPlot option objects
|
||||
*/
|
||||
/** Renders a 0–100 number as `50%`, unlike the 0–1 `percentunit`. */
|
||||
const PERCENT_AXIS_UNIT = 'percent';
|
||||
|
||||
const PERCENT_AXIS_MAX = 100;
|
||||
|
||||
type LegendConfig = {
|
||||
show?: boolean;
|
||||
live?: boolean;
|
||||
@@ -57,6 +63,8 @@ export class UPlotConfigBuilder extends ConfigBuilder<
|
||||
|
||||
private bands: uPlot.Band[] = [];
|
||||
|
||||
private stackMode: StackMode = StackMode.None;
|
||||
|
||||
private cursor: Cursor | undefined;
|
||||
|
||||
private hooks: Hooks.Arrays = {};
|
||||
@@ -143,6 +151,15 @@ export class UPlotConfigBuilder extends ConfigBuilder<
|
||||
this.axes[scaleKey] = new UPlotAxisBuilder(props);
|
||||
}
|
||||
|
||||
/** Drives the fill bands, the percent axis unit and the percent range below. */
|
||||
setStackMode(stackMode: StackMode): void {
|
||||
this.stackMode = stackMode;
|
||||
}
|
||||
|
||||
getStackMode(): StackMode {
|
||||
return this.stackMode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or merge a scale configuration
|
||||
*/
|
||||
@@ -211,6 +228,41 @@ export class UPlotConfigBuilder extends ConfigBuilder<
|
||||
this.bands = bands;
|
||||
}
|
||||
|
||||
/**
|
||||
* The panel's own limits are in the source unit, which means nothing once values are
|
||||
* normalised. Soft rather than hard, so mixed-sign shares outside 0–100 stay visible.
|
||||
*/
|
||||
private resolveScale(scale: UPlotScaleBuilder): UPlotScaleBuilder {
|
||||
if (this.stackMode !== StackMode.Percent || scale.props.scaleKey !== 'y') {
|
||||
return scale;
|
||||
}
|
||||
return new UPlotScaleBuilder({
|
||||
...scale.props,
|
||||
min: undefined,
|
||||
max: undefined,
|
||||
softMin: 0,
|
||||
softMax: PERCENT_AXIS_MAX,
|
||||
// Thresholds still draw, but a 500ms one must not stretch the axis to 0–500.
|
||||
thresholds: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/** Explicit bands win; otherwise a stack fills between consecutive series. */
|
||||
private resolveBands(): uPlot.Band[] | undefined {
|
||||
if (this.bands.length > 0) {
|
||||
return this.bands;
|
||||
}
|
||||
if (this.stackMode === StackMode.None || this.series.length < 2) {
|
||||
return undefined;
|
||||
}
|
||||
return (
|
||||
this.series
|
||||
.slice(0, -1)
|
||||
// uPlot series are 1-based (index 0 is the timestamp axis).
|
||||
.map((_, index) => ({ series: [index + 1, index + 2] as [number, number] }))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set cursor configuration
|
||||
*/
|
||||
@@ -444,9 +496,19 @@ export class UPlotConfigBuilder extends ConfigBuilder<
|
||||
};
|
||||
}),
|
||||
];
|
||||
config.axes = Object.values(this.axes).map((a) => a.getConfig());
|
||||
config.axes = Object.entries(this.axes).map(([scaleKey, axis]) => {
|
||||
if (scaleKey !== 'y' || this.stackMode !== StackMode.Percent) {
|
||||
return axis.getConfig();
|
||||
}
|
||||
// Ticks read as percentages; the panel unit still applies to tooltips and
|
||||
// thresholds, so build from a copy rather than touching the axis props.
|
||||
return new UPlotAxisBuilder({
|
||||
...axis.props,
|
||||
yAxisUnit: PERCENT_AXIS_UNIT,
|
||||
}).getConfig();
|
||||
});
|
||||
config.scales = this.scales.reduce(
|
||||
(acc, s) => ({ ...acc, ...s.getConfig() }),
|
||||
(acc, s) => ({ ...acc, ...this.resolveScale(s).getConfig() }),
|
||||
{} as Record<string, uPlot.Scale>,
|
||||
);
|
||||
|
||||
@@ -456,7 +518,7 @@ export class UPlotConfigBuilder extends ConfigBuilder<
|
||||
config.cursor = this.getCursorConfig();
|
||||
config.tzDate = this.tzDate;
|
||||
config.plugins = this.plugins.length > 0 ? this.plugins : undefined;
|
||||
config.bands = this.bands.length > 0 ? this.bands : undefined;
|
||||
config.bands = this.resolveBands();
|
||||
|
||||
if (Array.isArray(this.padding)) {
|
||||
config.padding = this.padding;
|
||||
|
||||
@@ -56,17 +56,6 @@ export class UPlotScaleBuilder extends ConfigBuilder<
|
||||
maxTime = fallbackMax;
|
||||
}
|
||||
|
||||
// Align max time to "endTime - 1 minute", rounded down to minute precision
|
||||
// This matches legacy getXAxisScale behavior and avoids empty space at the right edge
|
||||
const oneMinuteAgoTimestamp = (maxTime - 60) * 1000;
|
||||
const currentDate = new Date(oneMinuteAgoTimestamp);
|
||||
|
||||
currentDate.setSeconds(0);
|
||||
currentDate.setMilliseconds(0);
|
||||
|
||||
const unixTimestampSeconds = Math.floor(currentDate.getTime() / 1000);
|
||||
maxTime = unixTimestampSeconds;
|
||||
|
||||
return {
|
||||
[scaleKey]: {
|
||||
time: true,
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
STEP_INTERVAL_MULTIPLIER,
|
||||
} from '../../constants';
|
||||
import type { SeriesProps } from '../types';
|
||||
import { DrawStyle, SelectionPreferencesSource } from '../types';
|
||||
import { DrawStyle, SelectionPreferencesSource, StackMode } from '../types';
|
||||
import { UPlotConfigBuilder } from '../UPlotConfigBuilder';
|
||||
|
||||
// Mock only the real boundary that hits localStorage
|
||||
@@ -496,3 +496,161 @@ describe('UPlotConfigBuilder', () => {
|
||||
expect(config.bands).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('UPlotConfigBuilder stacking', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
getStoredSeriesVisibilityMock.getStoredSeriesVisibility.mockReturnValue([]);
|
||||
});
|
||||
|
||||
/**
|
||||
* Soft limits end up captured in the scale's range closure, so the only way to read
|
||||
* them back is to run it and inspect the range config it hands uPlot.
|
||||
*/
|
||||
function scaleSoftLimits(
|
||||
builder: UPlotConfigBuilder,
|
||||
scaleKey: string,
|
||||
): { min: number; max: number } {
|
||||
const rangeNum = jest.fn().mockReturnValue([0, 0]);
|
||||
(uPlot as unknown as { rangeNum: unknown }).rangeNum = rangeNum;
|
||||
|
||||
const range = builder.getConfig().scales?.[scaleKey]?.range as (
|
||||
u: unknown,
|
||||
min: number,
|
||||
max: number,
|
||||
key: string,
|
||||
) => void;
|
||||
range({ scales: { [scaleKey]: { distr: 1 } } }, 40, 60, scaleKey);
|
||||
|
||||
const [, , rangeConfig] = rangeNum.mock.calls[0] as [
|
||||
number,
|
||||
number,
|
||||
{ min: { soft: number }; max: { soft: number } },
|
||||
];
|
||||
return { min: rangeConfig.min.soft, max: rangeConfig.max.soft };
|
||||
}
|
||||
|
||||
/** Renders y-axis ticks the way uPlot would, so unit formatting is observable. */
|
||||
function yAxisTicks(builder: UPlotConfigBuilder, ticks: number[]): string[] {
|
||||
const yAxis = builder.getConfig().axes?.find((a) => a.scale === 'y');
|
||||
const values = yAxis?.values as (
|
||||
u: unknown,
|
||||
splits: number[],
|
||||
) => (string | null)[];
|
||||
return values(null, ticks).map((v) => String(v));
|
||||
}
|
||||
|
||||
function builderFor(stack?: StackMode, seriesCount = 3): UPlotConfigBuilder {
|
||||
const builder = new UPlotConfigBuilder({ id: 'stack-test' });
|
||||
if (stack) {
|
||||
builder.setStackMode(stack);
|
||||
}
|
||||
builder.addAxis({ scaleKey: 'y', show: true, side: 3, yAxisUnit: 'ms' });
|
||||
for (let i = 0; i < seriesCount; i++) {
|
||||
builder.addSeries({
|
||||
scaleKey: 'y',
|
||||
label: `S${i}`,
|
||||
drawStyle: DrawStyle.Bar,
|
||||
colorMapping: {},
|
||||
isDarkMode: false,
|
||||
} as SeriesProps);
|
||||
}
|
||||
return builder;
|
||||
}
|
||||
|
||||
it('defaults to no stacking, so no bands and the panel unit on the axis', () => {
|
||||
const builder = builderFor();
|
||||
|
||||
expect(builder.getStackMode()).toBe('none');
|
||||
expect(builder.getConfig().bands).toBeUndefined();
|
||||
expect(yAxisTicks(builder, [1000])).toStrictEqual(['1 s']);
|
||||
});
|
||||
|
||||
it('derives one band per adjacent series pair once a stack is declared', () => {
|
||||
expect(builderFor(StackMode.Normal).getConfig().bands).toStrictEqual([
|
||||
{ series: [1, 2] },
|
||||
{ series: [2, 3] },
|
||||
]);
|
||||
});
|
||||
|
||||
it('emits no bands for a single series', () => {
|
||||
expect(builderFor(StackMode.Normal, 1).getConfig().bands).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps the panel unit on the axis for a normal stack', () => {
|
||||
expect(yAxisTicks(builderFor(StackMode.Normal), [1000])).toStrictEqual([
|
||||
'1 s',
|
||||
]);
|
||||
});
|
||||
|
||||
it('formats the axis as percentages for a percent stack', () => {
|
||||
expect(yAxisTicks(builderFor(StackMode.Percent), [0, 50, 100])).toStrictEqual(
|
||||
['0%', '50%', '100%'],
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves other axes on their own unit under a percent stack', () => {
|
||||
const builder = builderFor(StackMode.Percent);
|
||||
builder.addAxis({ scaleKey: 'x', show: true, side: 2 });
|
||||
|
||||
expect(builder.getConfig().axes?.map((a) => a.scale)).toStrictEqual([
|
||||
'y',
|
||||
'x',
|
||||
]);
|
||||
});
|
||||
|
||||
it('pins the y scale to the 0–100 band under a percent stack, dropping panel limits', () => {
|
||||
const builder = new UPlotConfigBuilder({ id: 'stack-scale' });
|
||||
builder.setStackMode(StackMode.Percent);
|
||||
builder.addScale({ scaleKey: 'y', softMin: 5, softMax: 500 });
|
||||
|
||||
// Soft, not hard: mixed-sign shares fall outside 0–100 and must stay visible.
|
||||
expect(builder.getConfig().scales?.y).toMatchObject({ auto: true });
|
||||
expect(scaleSoftLimits(builder, 'y')).toStrictEqual({ min: 0, max: 100 });
|
||||
});
|
||||
|
||||
it('leaves the panel limits alone when the stack is not percent', () => {
|
||||
const builder = new UPlotConfigBuilder({ id: 'stack-scale' });
|
||||
builder.setStackMode(StackMode.Normal);
|
||||
builder.addScale({ scaleKey: 'y', softMin: 5, softMax: 500 });
|
||||
|
||||
expect(scaleSoftLimits(builder, 'y')).toStrictEqual({ min: 5, max: 500 });
|
||||
});
|
||||
|
||||
it.each([StackMode.Normal, StackMode.Percent])(
|
||||
'draws thresholds under a %s stack',
|
||||
(stack) => {
|
||||
const builder = new UPlotConfigBuilder({ id: 'stack-thr' });
|
||||
builder.setStackMode(stack);
|
||||
builder.addThresholds({
|
||||
scaleKey: 'y',
|
||||
thresholds: [{ thresholdValue: 500, thresholdColor: 'red' }],
|
||||
yAxisUnit: 'ms',
|
||||
});
|
||||
|
||||
expect(builder.getConfig().hooks?.draw).toHaveLength(1);
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps a source-unit threshold from stretching the percent band', () => {
|
||||
const builder = new UPlotConfigBuilder({ id: 'stack-thr' });
|
||||
builder.setStackMode(StackMode.Percent);
|
||||
const thresholds = {
|
||||
scaleKey: 'y',
|
||||
thresholds: [{ thresholdValue: 500, thresholdColor: 'red' }],
|
||||
yAxisUnit: 'ms',
|
||||
};
|
||||
builder.addThresholds(thresholds);
|
||||
builder.addScale({ scaleKey: 'y', thresholds });
|
||||
|
||||
// Without this the 500ms threshold would widen a percentage axis to 0–500.
|
||||
expect(scaleSoftLimits(builder, 'y')).toStrictEqual({ min: 0, max: 100 });
|
||||
});
|
||||
|
||||
it('lets explicit bands win over the derived ones', () => {
|
||||
const builder = builderFor(StackMode.Normal);
|
||||
builder.setBands([{ series: [1, 3] }]);
|
||||
|
||||
expect(builder.getConfig().bands).toStrictEqual([{ series: [1, 3] }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,7 +44,7 @@ describe('UPlotScaleBuilder', () => {
|
||||
expect(adjustSpy).toHaveBeenCalledWith(null, null, undefined, undefined);
|
||||
});
|
||||
|
||||
it('handles time scales using explicit min/max and rounds max down to the previous minute', () => {
|
||||
it('handles time scales using explicit min/max', () => {
|
||||
const min = 1_700_000_000; // seconds
|
||||
const max = 1_700_000_600; // seconds
|
||||
|
||||
@@ -62,21 +62,25 @@ describe('UPlotScaleBuilder', () => {
|
||||
|
||||
expect(xScale.time).toBe(true);
|
||||
expect(xScale.auto).toBe(false);
|
||||
expect(Array.isArray(xScale.range)).toBe(true);
|
||||
expect(xScale.range).toStrictEqual([min, max]);
|
||||
});
|
||||
|
||||
const [resolvedMin, resolvedMax] = xScale.range as [number, number];
|
||||
it('keeps short time windows intact', () => {
|
||||
const min = 1_786_527_160;
|
||||
const max = 1_786_527_183;
|
||||
|
||||
// min is passed through
|
||||
expect(resolvedMin).toBe(min);
|
||||
const builder = new UPlotScaleBuilder(
|
||||
createScaleProps({
|
||||
scaleKey: 'x',
|
||||
time: true,
|
||||
min,
|
||||
max,
|
||||
}),
|
||||
);
|
||||
|
||||
// max is coerced to "endTime - 1 minute" and rounded down to minute precision
|
||||
const oneMinuteAgoTimestamp = (max - 60) * 1000;
|
||||
const currentDate = new Date(oneMinuteAgoTimestamp);
|
||||
currentDate.setSeconds(0);
|
||||
currentDate.setMilliseconds(0);
|
||||
const expectedMax = Math.floor(currentDate.getTime() / 1000);
|
||||
const config = builder.getConfig();
|
||||
|
||||
expect(resolvedMax).toBe(expectedMax);
|
||||
expect(config.x.range).toStrictEqual([min, max]);
|
||||
});
|
||||
|
||||
it('falls back to getFallbackMinMaxTimeStamp when time scale has no min/max', () => {
|
||||
@@ -99,9 +103,7 @@ describe('UPlotScaleBuilder', () => {
|
||||
|
||||
expect(getFallbackMinMaxSpy).toHaveBeenCalled();
|
||||
expect(resolvedMin).toBe(100);
|
||||
// max is aligned to "fallbackMax - 60 seconds" minute boundary
|
||||
expect(resolvedMax).toBeLessThanOrEqual(200);
|
||||
expect(resolvedMax).toBeGreaterThan(100);
|
||||
expect(resolvedMax).toBe(200);
|
||||
});
|
||||
|
||||
it('pipes limits through soft-limit adjustment and log-scale normalization before range config', () => {
|
||||
|
||||
@@ -33,6 +33,13 @@ export enum SelectionPreferencesSource {
|
||||
/**
|
||||
* Props for configuring the uPlot config builder
|
||||
*/
|
||||
/** `Percent` rescales each x-slice to its column total, so every column fills to 100. */
|
||||
export enum StackMode {
|
||||
None = 'none',
|
||||
Normal = 'normal',
|
||||
Percent = 'percent',
|
||||
}
|
||||
|
||||
export interface ConfigBuilderProps {
|
||||
id: string;
|
||||
onDragSelect?: (startTime: number, endTime: number) => void;
|
||||
|
||||
@@ -281,3 +281,20 @@ describe('dataUtils', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('insertLargeGapNullsIntoAlignedData index alignment', () => {
|
||||
// ChartWrapper gap-processes the pre-stack series to keep tooltip indices aligned;
|
||||
// that only holds because insertions are decided from the x axis, never from y.
|
||||
it('inserts at the same positions regardless of the y values', () => {
|
||||
const x = [0, 100, 200];
|
||||
const options = [{ spanGaps: 50 }];
|
||||
const raw = [x, [1, 2, 3]] as uPlot.AlignedData;
|
||||
const stacked = [x, [10, 20, 30]] as uPlot.AlignedData;
|
||||
|
||||
const fromRaw = insertLargeGapNullsIntoAlignedData(raw, options);
|
||||
const fromStacked = insertLargeGapNullsIntoAlignedData(stacked, options);
|
||||
|
||||
expect(fromRaw[0]).toStrictEqual(fromStacked[0]);
|
||||
expect(fromRaw[1]).toHaveLength((fromStacked[1] as unknown[]).length);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -116,7 +116,7 @@
|
||||
is hidden — the row stays a single crisp line and scrolls only when narrow. */
|
||||
.typeTabsScroll {
|
||||
justify-self: flex-end;
|
||||
--tab-list-wrapper-secondary-padding-left: 0;
|
||||
--tabs-list-wrapper-secondary-padding-left: 0;
|
||||
}
|
||||
|
||||
/* Connected segmented control, mirroring Overview's SegmentedControl: no outer
|
||||
|
||||
@@ -7,6 +7,7 @@ import { PanelMode } from 'container/DashboardContainer/visualization/panels/typ
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { IRenderTooltipFooterArgs } from 'lib/uPlotV2/components/types';
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
import {
|
||||
flattenTimeSeries,
|
||||
getExecStats,
|
||||
@@ -219,7 +220,9 @@ function BarPanelRenderer({
|
||||
height={containerDimensions.height}
|
||||
syncMode={dashboardPreference?.syncMode}
|
||||
syncFilterMode={dashboardPreference?.syncFilterMode}
|
||||
isStackedBarChart={spec.visualization?.stackedBarChart ?? false}
|
||||
stack={
|
||||
spec.visualization?.stackedBarChart ? StackMode.Normal : StackMode.None
|
||||
}
|
||||
renderTooltipFooter={renderTooltipFooter}
|
||||
onClick={enableDrillDown ? handleChartClick : undefined}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { DashboardtypesBarChartPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getInitialStackedBands } from 'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils';
|
||||
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
|
||||
import { buildBaseConfig } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/baseConfigBuilder';
|
||||
import { resolveSeriesLabelV5 } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/resolveSeriesLabel';
|
||||
@@ -101,12 +100,6 @@ function addSeries({
|
||||
}: AddSeriesArgs): void {
|
||||
const colorMapping = spec.legend?.customColors ?? {};
|
||||
|
||||
if (spec.visualization?.stackedBarChart) {
|
||||
// uPlot uses 1-based series indices (index 0 is the timestamp axis);
|
||||
// `+1` keeps the band targets aligned with the series we're about to add.
|
||||
builder.setBands(getInitialStackedBands(series.length + 1));
|
||||
}
|
||||
|
||||
series.forEach((s) => {
|
||||
const baseLabel = getLabelName(s.labels, s.queryName, s.legend);
|
||||
const label = resolveSeriesLabelV5(s, builderQueries, baseLabel);
|
||||
|
||||
@@ -40,8 +40,8 @@ func NewModule(
|
||||
providerSettings factory.ProviderSettings,
|
||||
cfg inframonitoring.Config,
|
||||
) inframonitoring.Module {
|
||||
fieldMapper := metricstelemetryschema.NewFieldMapper(fl)
|
||||
condBuilder := metricstelemetryschema.NewConditionBuilder(fieldMapper, fl)
|
||||
fieldMapper := metricstelemetryschema.NewFieldMapper()
|
||||
condBuilder := metricstelemetryschema.NewConditionBuilder(fieldMapper)
|
||||
return &module{
|
||||
telemetryStore: telemetryStore,
|
||||
telemetryMetadataStore: telemetryMetadataStore,
|
||||
|
||||
@@ -49,8 +49,8 @@ type module struct {
|
||||
|
||||
// NewModule constructs the metrics module with the provided dependencies.
|
||||
func NewModule(ts telemetrystore.TelemetryStore, telemetryMetadataStore telemetrytypes.MetadataStore, cache cache.Cache, ruleStore ruletypes.RuleStore, dashboardModule dashboard.Module, fl flagger.Flagger, providerSettings factory.ProviderSettings, cfg metricsexplorer.Config) metricsexplorer.Module {
|
||||
fieldMapper := metricstelemetryschema.NewFieldMapper(fl)
|
||||
condBuilder := metricstelemetryschema.NewConditionBuilder(fieldMapper, fl)
|
||||
fieldMapper := metricstelemetryschema.NewFieldMapper()
|
||||
condBuilder := metricstelemetryschema.NewConditionBuilder(fieldMapper)
|
||||
return &module{
|
||||
telemetryStore: ts,
|
||||
fieldMapper: fieldMapper,
|
||||
|
||||
@@ -42,7 +42,7 @@ func (c *conditionBuilder) ConditionFor(
|
||||
|
||||
// Rule state history fields have no family support, so every logical field
|
||||
// is single-member and flattens losslessly to its physical key.
|
||||
resolved, warning := querybuilder.ResolveLogicalFields(key, querybuilder.MatchingLogicalFields(ctx, orgID, nil, telemetrytypes.SignalUnspecified, nil, key, fieldKeys))
|
||||
resolved, warning := querybuilder.ResolveLogicalFields(key, querybuilder.MatchingLogicalFields(ctx, orgID, nil, key, fieldKeys))
|
||||
keys := querybuilder.SingleKeys(resolved)
|
||||
var warnings []string
|
||||
if warning != "" {
|
||||
|
||||
@@ -52,10 +52,10 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/query-service/constants"
|
||||
|
||||
chErrors "github.com/SigNoz/signoz/pkg/query-service/errors"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/metrics"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/model"
|
||||
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/utils"
|
||||
"github.com/SigNoz/signoz/pkg/semconv"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -3201,13 +3201,8 @@ func (r *ClickHouseReader) GetMetricAttributeValues(ctx context.Context, orgID v
|
||||
if req.Limit != 0 {
|
||||
query = query + fmt.Sprintf(" LIMIT %d;", req.Limit)
|
||||
}
|
||||
// Exact-name members keep the legacy union semantics: only the canonical
|
||||
// dotted spellings widen, exactly like the transition table this replaced.
|
||||
names := semconv.Members(semconv.KindMetric, telemetrytypes.FieldKeySelector{
|
||||
Name: req.AggregateAttribute,
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
FieldContext: telemetrytypes.FieldContextMetric,
|
||||
})
|
||||
names := []string{req.AggregateAttribute}
|
||||
names = append(names, metrics.GetTransitionedMetric(req.AggregateAttribute))
|
||||
|
||||
rows, err = r.db.Query(ctx, query, req.FilterAttributeKey, names, req.FilterAttributeKey, fmt.Sprintf("%%%s%%", req.SearchText), common.PastDayRoundOff())
|
||||
|
||||
|
||||
14
pkg/query-service/metrics/transition.go
Normal file
14
pkg/query-service/metrics/transition.go
Normal file
@@ -0,0 +1,14 @@
|
||||
package metrics
|
||||
|
||||
var MetricsUnderTransition = map[string]string{
|
||||
"k8s.pod.cpu.utilization": "k8s.pod.cpu.usage",
|
||||
"k8s.node.cpu.utilization": "k8s.node.cpu.usage",
|
||||
"container.cpu.utilization": "container.cpu.usage",
|
||||
}
|
||||
|
||||
func GetTransitionedMetric(metric string) string {
|
||||
if transitionedMetric, ok := MetricsUnderTransition[metric]; ok {
|
||||
return transitionedMetric
|
||||
}
|
||||
return metric
|
||||
}
|
||||
@@ -10,9 +10,8 @@ import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/query-service/constants"
|
||||
"github.com/SigNoz/signoz/pkg/query-service/metrics"
|
||||
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
|
||||
"github.com/SigNoz/signoz/pkg/semconv"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
)
|
||||
|
||||
// ValidateAndCastValue validates and casts the value of a key to the corresponding data type of the key
|
||||
@@ -233,17 +232,14 @@ func ClickHouseFormattedValue(v interface{}) string {
|
||||
}
|
||||
}
|
||||
|
||||
// The exact-name lookup keeps the legacy substitution semantics: only the
|
||||
// canonical dotted spellings redirect, exactly like the transition table this
|
||||
// replaced. Style-aware matching (MetricNames) is for the flagged v5 path.
|
||||
func ClickHouseFormattedMetricNames(v interface{}) string {
|
||||
if name, ok := v.(string); ok {
|
||||
current := semconv.Current(semconv.KindMetric, telemetrytypes.FieldKeySelector{
|
||||
Name: name,
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
FieldContext: telemetrytypes.FieldContextMetric,
|
||||
})
|
||||
return ClickHouseFormattedValue([]interface{}{current})
|
||||
transitionedMetrics := metrics.GetTransitionedMetric(name)
|
||||
if transitionedMetrics != name {
|
||||
return ClickHouseFormattedValue([]interface{}{transitionedMetrics})
|
||||
} else {
|
||||
return ClickHouseFormattedValue([]interface{}{name})
|
||||
}
|
||||
}
|
||||
|
||||
return ClickHouseFormattedValue(v)
|
||||
|
||||
@@ -483,22 +483,3 @@ func TestGetEpochNanoSecs(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Only the canonical dotted spelling of a metric-name family redirects on the
|
||||
// legacy path; a normalized spelling keeps reading its own series.
|
||||
func TestClickHouseFormattedMetricNames(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
expected string
|
||||
}{
|
||||
{name: "k8s.pod.cpu.utilization", expected: "['k8s.pod.cpu.usage']"},
|
||||
{name: "k8s.pod.cpu.usage", expected: "['k8s.pod.cpu.usage']"},
|
||||
{name: "k8s_pod_cpu_utilization", expected: "['k8s_pod_cpu_utilization']"},
|
||||
{name: "http.server.duration", expected: "['http.server.duration']"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := ClickHouseFormattedMetricNames(c.name); got != c.expected {
|
||||
t.Errorf("ClickHouseFormattedMetricNames(%q) = %q, want %q", c.name, got, c.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
package querybuilder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/huandu/go-sqlbuilder"
|
||||
)
|
||||
|
||||
// LogicalFamilyCondition compiles one condition for a family logical field
|
||||
// from the mapper's primitives. Family members are map-backed attribute or
|
||||
// resource keys by construction, so the compiler has no column-specific
|
||||
// branches; a signal keeps its own single-member paths and hands only
|
||||
// families here.
|
||||
func LogicalFamilyCondition(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
startNs, endNs uint64,
|
||||
fm qbtypes.FieldMapper,
|
||||
logical *telemetrytypes.LogicalField,
|
||||
operator qbtypes.FilterOperator,
|
||||
value any,
|
||||
sb *sqlbuilder.SelectBuilder,
|
||||
) (string, error) {
|
||||
if operator.IsStringSearchOperator() {
|
||||
value = FormatValueForContains(value)
|
||||
}
|
||||
|
||||
fieldExpression, err := LogicalValueExpr(ctx, orgID, startNs, endNs, fm, logical)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Coercion switches only on the data type, which every member shares, so
|
||||
// the first member stands in for the field.
|
||||
fieldExpression, value = DataTypeCollisionHandledFieldName(logical.Single(), value, fieldExpression, operator)
|
||||
|
||||
switch operator {
|
||||
case qbtypes.FilterOperatorEqual:
|
||||
return sb.E(fieldExpression, value), nil
|
||||
case qbtypes.FilterOperatorNotEqual:
|
||||
return sb.NE(fieldExpression, value), nil
|
||||
case qbtypes.FilterOperatorGreaterThan:
|
||||
return sb.G(fieldExpression, value), nil
|
||||
case qbtypes.FilterOperatorGreaterThanOrEq:
|
||||
return sb.GE(fieldExpression, value), nil
|
||||
case qbtypes.FilterOperatorLessThan:
|
||||
return sb.LT(fieldExpression, value), nil
|
||||
case qbtypes.FilterOperatorLessThanOrEq:
|
||||
return sb.LE(fieldExpression, value), nil
|
||||
|
||||
case qbtypes.FilterOperatorLike:
|
||||
return sb.Like(fieldExpression, value), nil
|
||||
case qbtypes.FilterOperatorNotLike:
|
||||
return sb.NotLike(fieldExpression, value), nil
|
||||
case qbtypes.FilterOperatorILike:
|
||||
return sb.ILike(fieldExpression, value), nil
|
||||
case qbtypes.FilterOperatorNotILike:
|
||||
return sb.NotILike(fieldExpression, value), nil
|
||||
|
||||
case qbtypes.FilterOperatorContains:
|
||||
return sb.ILike(fieldExpression, fmt.Sprintf("%%%s%%", value)), nil
|
||||
case qbtypes.FilterOperatorNotContains:
|
||||
return sb.NotILike(fieldExpression, fmt.Sprintf("%%%s%%", value)), nil
|
||||
|
||||
case qbtypes.FilterOperatorRegexp:
|
||||
return fmt.Sprintf(`match(%s, %s)`, sqlbuilder.Escape(fieldExpression), sb.Var(value)), nil
|
||||
case qbtypes.FilterOperatorNotRegexp:
|
||||
return fmt.Sprintf(`NOT match(%s, %s)`, sqlbuilder.Escape(fieldExpression), sb.Var(value)), nil
|
||||
|
||||
case qbtypes.FilterOperatorBetween:
|
||||
values, ok := value.([]any)
|
||||
if !ok || len(values) != 2 {
|
||||
return "", qbtypes.ErrBetweenValues
|
||||
}
|
||||
return sb.Between(fieldExpression, values[0], values[1]), nil
|
||||
case qbtypes.FilterOperatorNotBetween:
|
||||
values, ok := value.([]any)
|
||||
if !ok || len(values) != 2 {
|
||||
return "", qbtypes.ErrBetweenValues
|
||||
}
|
||||
return sb.NotBetween(fieldExpression, values[0], values[1]), nil
|
||||
|
||||
// `=`+OR / `!=`+AND instead of IN / NOT IN, to make use of the index
|
||||
case qbtypes.FilterOperatorIn:
|
||||
values, ok := value.([]any)
|
||||
if !ok {
|
||||
return "", qbtypes.ErrInValues
|
||||
}
|
||||
conditions := make([]string, 0, len(values))
|
||||
for _, item := range values {
|
||||
cond, err := LogicalFamilyCondition(ctx, orgID, startNs, endNs, fm, logical, qbtypes.FilterOperatorEqual, item, sb)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
conditions = append(conditions, cond)
|
||||
}
|
||||
return sb.Or(conditions...), nil
|
||||
case qbtypes.FilterOperatorNotIn:
|
||||
values, ok := value.([]any)
|
||||
if !ok {
|
||||
return "", qbtypes.ErrInValues
|
||||
}
|
||||
conditions := make([]string, 0, len(values))
|
||||
for _, item := range values {
|
||||
cond, err := LogicalFamilyCondition(ctx, orgID, startNs, endNs, fm, logical, qbtypes.FilterOperatorNotEqual, item, sb)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
conditions = append(conditions, cond)
|
||||
}
|
||||
return sb.And(conditions...), nil
|
||||
|
||||
case qbtypes.FilterOperatorExists, qbtypes.FilterOperatorNotExists:
|
||||
pred, err := LogicalExistsExpr(ctx, orgID, startNs, endNs, fm, logical, operator == qbtypes.FilterOperatorExists)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return sqlbuilder.Escape(pred), nil
|
||||
}
|
||||
return "", qbtypes.ErrUnsupportedOperator
|
||||
}
|
||||
@@ -10,26 +10,27 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
// SemconvFamiliesEnabled evaluates the resolve_semconv_families flag for the
|
||||
// semconvFamiliesEnabled evaluates the resolve_semconv_families flag for the
|
||||
// org. A nil flagger means off, so a caller without family support stays
|
||||
// literal by default.
|
||||
func SemconvFamiliesEnabled(ctx context.Context, orgID valuer.UUID, fl flagger.Flagger) bool {
|
||||
func semconvFamiliesEnabled(ctx context.Context, orgID valuer.UUID, fl flagger.Flagger) bool {
|
||||
if fl == nil {
|
||||
return false
|
||||
}
|
||||
return fl.BooleanOrEmpty(ctx, flagger.FeatureResolveSemconvFamilies, featuretypes.NewFlaggerEvaluationContext(orgID))
|
||||
}
|
||||
|
||||
// ExpandKeySelectorsForFamilies adds selectors for the other spellings of
|
||||
// each semantic-convention family that a selector names. The metadata fetched
|
||||
// for a query then contains each spelling that MatchingLogicalFields can
|
||||
// group. This function is the prefetch of the resolution layer: statement
|
||||
// builders call it after they derive the selectors, and the metadata store
|
||||
// stays family-blind (autocomplete responses keep the literal spelling that
|
||||
// the user typed). It does nothing when the resolve_semconv_families flag is
|
||||
// off for the org. Fuzzy (search-style) selectors never expand.
|
||||
// ExpandKeySelectorsForFamilies adds selectors for the other members of each
|
||||
// semantic-convention family that a selector names. The metadata fetched for
|
||||
// a query then contains each spelling that MatchingLogicalFields can group.
|
||||
// This function is the prefetch of the resolution layer: statement builders
|
||||
// call it after they derive the selectors, and the metadata store stays
|
||||
// family-blind (autocomplete responses keep the literal spelling that the
|
||||
// user typed). It does nothing when the resolve_semconv_families flag is off
|
||||
// for the org. Only trace selectors expand today, because that matches the
|
||||
// family support. Fuzzy (search-style) selectors never expand.
|
||||
func ExpandKeySelectorsForFamilies(ctx context.Context, orgID valuer.UUID, fl flagger.Flagger, selectors []*telemetrytypes.FieldKeySelector) []*telemetrytypes.FieldKeySelector {
|
||||
if !SemconvFamiliesEnabled(ctx, orgID, fl) {
|
||||
if !semconvFamiliesEnabled(ctx, orgID, fl) {
|
||||
return selectors
|
||||
}
|
||||
|
||||
@@ -40,14 +41,14 @@ func ExpandKeySelectorsForFamilies(ctx context.Context, orgID valuer.UUID, fl fl
|
||||
}
|
||||
|
||||
for _, selector := range selectors {
|
||||
if selector.SelectorMatchType == telemetrytypes.FieldSelectorMatchTypeFuzzy {
|
||||
if selector.Signal != telemetrytypes.SignalTraces ||
|
||||
selector.SelectorMatchType == telemetrytypes.FieldSelectorMatchTypeFuzzy {
|
||||
continue
|
||||
}
|
||||
members := semconv.AttributeMembers(telemetrytypes.FieldKeySelector{
|
||||
Name: selector.Name,
|
||||
Signal: selector.Signal,
|
||||
FieldContext: selector.FieldContext,
|
||||
MetricContext: selector.MetricContext,
|
||||
members := semconv.Members(semconv.KindAttribute, telemetrytypes.FieldKeySelector{
|
||||
Name: selector.Name,
|
||||
Signal: selector.Signal,
|
||||
FieldContext: selector.FieldContext,
|
||||
})
|
||||
for _, member := range members {
|
||||
if seen[member] {
|
||||
|
||||
@@ -52,13 +52,8 @@ func LogicalValueExpr(
|
||||
return "COALESCE(" + strings.Join(values, ", ") + ", '')", nil
|
||||
}
|
||||
|
||||
// Numeric and boolean maps read their zero value for an absent key, so the
|
||||
// tail keeps single-key semantics for rows without any member — the same
|
||||
// contract as the '' tail above.
|
||||
tail := "0"
|
||||
if logical.FieldDataType == telemetrytypes.FieldDataTypeBool {
|
||||
tail = "false"
|
||||
}
|
||||
// Numeric and boolean maps return zero for an absent key. If a family of
|
||||
// either type is enabled, this tail must become zero too.
|
||||
branches := make([]string, 0, len(logical.Members)*2)
|
||||
for i, member := range logical.Members {
|
||||
guard, err := fm.ExistsFor(ctx, orgID, tsStart, tsEnd, member, true)
|
||||
@@ -67,7 +62,7 @@ func LogicalValueExpr(
|
||||
}
|
||||
branches = append(branches, guard, memberExprs[i])
|
||||
}
|
||||
return "multiIf(" + strings.Join(branches, ", ") + ", " + tail + ")", nil
|
||||
return "multiIf(" + strings.Join(branches, ", ") + ", NULL)", nil
|
||||
}
|
||||
|
||||
// LogicalExistsExpr returns the existence predicate for a resolved logical
|
||||
|
||||
@@ -72,23 +72,7 @@ func TestLogicalValueExprNumericFamilyGuardsEveryMember(t *testing.T) {
|
||||
}
|
||||
expr, err := LogicalValueExpr(context.Background(), valuer.UUID{}, 0, 0, stubFieldMapper{}, logical)
|
||||
require.NoError(t, err)
|
||||
// The 0 tail mirrors the '' tail of the string branch: numeric maps read 0
|
||||
// for an absent key, so keyless rows keep single-key semantics.
|
||||
assert.Equal(t, "multiIf(has(current), value(current), has(old), value(old), 0)", expr)
|
||||
}
|
||||
|
||||
func TestLogicalValueExprBoolFamilyReadsFalseForKeylessRows(t *testing.T) {
|
||||
logical := &telemetrytypes.LogicalField{
|
||||
Name: "flag",
|
||||
FieldDataType: telemetrytypes.FieldDataTypeBool,
|
||||
Members: []*telemetrytypes.TelemetryFieldKey{
|
||||
{Name: "current", FieldDataType: telemetrytypes.FieldDataTypeBool},
|
||||
{Name: "old", FieldDataType: telemetrytypes.FieldDataTypeBool},
|
||||
},
|
||||
}
|
||||
expr, err := LogicalValueExpr(context.Background(), valuer.UUID{}, 0, 0, stubFieldMapper{}, logical)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "multiIf(has(current), value(current), has(old), value(old), false)", expr)
|
||||
assert.Equal(t, "multiIf(has(current), value(current), has(old), value(old), NULL)", expr)
|
||||
}
|
||||
|
||||
func TestLogicalExistsExprSingleMemberDelegatesToExistsFor(t *testing.T) {
|
||||
|
||||
@@ -48,7 +48,7 @@ func TestFamiliesOffByDefault(t *testing.T) {
|
||||
}},
|
||||
}
|
||||
|
||||
fields := MatchingLogicalFields(context.Background(), valuer.UUID{}, flaggertest.New(t), telemetrytypes.SignalUnspecified, nil, &telemetrytypes.TelemetryFieldKey{Name: "deployment.environment.name"}, fieldKeys)
|
||||
fields := MatchingLogicalFields(context.Background(), valuer.UUID{}, flaggertest.New(t), &telemetrytypes.TelemetryFieldKey{Name: "deployment.environment.name"}, fieldKeys)
|
||||
require.Len(t, fields, 1)
|
||||
assert.False(t, fields[0].IsFamily())
|
||||
assert.Equal(t, []string{"deployment.environment.name"}, memberNames(fields[0]))
|
||||
@@ -76,7 +76,7 @@ func TestMatchingLogicalFieldsGroupsFamilyMembers(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, requested := range []string{"deployment.environment.name", "deployment.environment"} {
|
||||
fields := MatchingLogicalFields(context.Background(), valuer.UUID{}, familiesOn(t), telemetrytypes.SignalUnspecified, nil, &telemetrytypes.TelemetryFieldKey{Name: requested}, fieldKeys)
|
||||
fields := MatchingLogicalFields(context.Background(), valuer.UUID{}, familiesOn(t), &telemetrytypes.TelemetryFieldKey{Name: requested}, fieldKeys)
|
||||
require.Len(t, fields, 1, "a family is one logical field, requested via %s", requested)
|
||||
logical := fields[0]
|
||||
assert.Equal(t, requested, logical.Name, "response identity is the requested spelling")
|
||||
@@ -106,7 +106,7 @@ func TestMatchingLogicalFieldsOrdersMembersByFamilyRank(t *testing.T) {
|
||||
}},
|
||||
}
|
||||
|
||||
fields := MatchingLogicalFields(context.Background(), valuer.UUID{}, familiesOn(t), telemetrytypes.SignalUnspecified, nil, &telemetrytypes.TelemetryFieldKey{
|
||||
fields := MatchingLogicalFields(context.Background(), valuer.UUID{}, familiesOn(t), &telemetrytypes.TelemetryFieldKey{
|
||||
Name: "deployment.environment.name",
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
}, fieldKeys)
|
||||
@@ -115,8 +115,9 @@ func TestMatchingLogicalFieldsOrdersMembersByFamilyRank(t *testing.T) {
|
||||
assert.Equal(t, []string{"resource.deployment.environment.name", "deployment.environment"}, memberNames(fields[0]))
|
||||
}
|
||||
|
||||
// Log entries group into families exactly like trace entries.
|
||||
func TestMatchingLogicalFieldsGroupsLogEntries(t *testing.T) {
|
||||
// Non-trace signals have no family support: the requested spelling stays
|
||||
// literal, and a family member name never pulls in its siblings.
|
||||
func TestMatchingLogicalFieldsKeepsLogsLiteral(t *testing.T) {
|
||||
logsKey := func(name string) *telemetrytypes.TelemetryFieldKey {
|
||||
return &telemetrytypes.TelemetryFieldKey{
|
||||
Name: name,
|
||||
@@ -130,60 +131,10 @@ func TestMatchingLogicalFieldsGroupsLogEntries(t *testing.T) {
|
||||
"deployment.environment": {logsKey("deployment.environment")},
|
||||
}
|
||||
|
||||
fields := MatchingLogicalFields(context.Background(), valuer.UUID{}, familiesOn(t), telemetrytypes.SignalLogs, nil, &telemetrytypes.TelemetryFieldKey{Name: "deployment.environment.name"}, fieldKeys)
|
||||
require.Len(t, fields, 1)
|
||||
assert.True(t, fields[0].IsFamily())
|
||||
assert.Equal(t, []string{"deployment.environment.name", "deployment.environment"}, memberNames(fields[0]))
|
||||
}
|
||||
|
||||
// A family gated away from a signal stays literal there: db.system carries
|
||||
// signals [traces, logs], so a metrics lookup never pulls in siblings.
|
||||
func TestMatchingLogicalFieldsHonorsTheFamilySignalGate(t *testing.T) {
|
||||
metricsKey := func(name string) *telemetrytypes.TelemetryFieldKey {
|
||||
return &telemetrytypes.TelemetryFieldKey{
|
||||
Name: name,
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
}
|
||||
}
|
||||
fieldKeys := map[string][]*telemetrytypes.TelemetryFieldKey{
|
||||
"db.system.name": {metricsKey("db.system.name")},
|
||||
"db.system": {metricsKey("db.system")},
|
||||
}
|
||||
|
||||
fields := MatchingLogicalFields(context.Background(), valuer.UUID{}, familiesOn(t), telemetrytypes.SignalMetrics, nil, &telemetrytypes.TelemetryFieldKey{Name: "db.system.name"}, fieldKeys)
|
||||
fields := MatchingLogicalFields(context.Background(), valuer.UUID{}, familiesOn(t), &telemetrytypes.TelemetryFieldKey{Name: "deployment.environment.name"}, fieldKeys)
|
||||
require.Len(t, fields, 1)
|
||||
assert.False(t, fields[0].IsFamily())
|
||||
assert.Equal(t, []string{"db.system.name"}, memberNames(fields[0]))
|
||||
}
|
||||
|
||||
// Metric entries group across the stored label spellings of the family, in
|
||||
// member-major order: every spelling of the current name precedes the first
|
||||
// spelling of the old one.
|
||||
func TestMatchingLogicalFieldsGroupsMetricSpellings(t *testing.T) {
|
||||
metricsKey := func(name string) *telemetrytypes.TelemetryFieldKey {
|
||||
return &telemetrytypes.TelemetryFieldKey{
|
||||
Name: name,
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
}
|
||||
}
|
||||
fieldKeys := map[string][]*telemetrytypes.TelemetryFieldKey{
|
||||
"deployment.environment.name": {metricsKey("deployment.environment.name")},
|
||||
"deployment_environment_name": {metricsKey("deployment_environment_name")},
|
||||
"deployment.environment": {metricsKey("deployment.environment")},
|
||||
"deployment_environment": {metricsKey("deployment_environment")},
|
||||
}
|
||||
|
||||
fields := MatchingLogicalFields(context.Background(), valuer.UUID{}, familiesOn(t), telemetrytypes.SignalMetrics, nil, &telemetrytypes.TelemetryFieldKey{Name: "deployment.environment"}, fieldKeys)
|
||||
require.Len(t, fields, 1)
|
||||
assert.True(t, fields[0].IsFamily())
|
||||
assert.Equal(t, []string{
|
||||
"deployment.environment.name", "deployment_environment_name",
|
||||
"deployment.environment", "deployment_environment",
|
||||
}, memberNames(fields[0]))
|
||||
assert.Equal(t, []string{"deployment.environment.name"}, memberNames(fields[0]))
|
||||
}
|
||||
|
||||
// A family and a genuine same-name collision stack cleanly: the family stays
|
||||
@@ -214,7 +165,7 @@ func TestResolveLogicalFieldsKeepsFamilyThroughAmbiguity(t *testing.T) {
|
||||
}
|
||||
|
||||
requested := &telemetrytypes.TelemetryFieldKey{Name: "deployment.environment.name"}
|
||||
fields := MatchingLogicalFields(context.Background(), valuer.UUID{}, familiesOn(t), telemetrytypes.SignalUnspecified, nil, requested, fieldKeys)
|
||||
fields := MatchingLogicalFields(context.Background(), valuer.UUID{}, familiesOn(t), requested, fieldKeys)
|
||||
require.Len(t, fields, 2, "resource family + attribute collision")
|
||||
|
||||
resolved, warning := ResolveLogicalFields(requested, fields)
|
||||
@@ -242,7 +193,7 @@ func TestMatchingLogicalFieldsNeverMergesAcrossDataTypes(t *testing.T) {
|
||||
}},
|
||||
}
|
||||
|
||||
fields := MatchingLogicalFields(context.Background(), valuer.UUID{}, familiesOn(t), telemetrytypes.SignalUnspecified, nil, &telemetrytypes.TelemetryFieldKey{Name: "deployment.environment.name"}, fieldKeys)
|
||||
fields := MatchingLogicalFields(context.Background(), valuer.UUID{}, familiesOn(t), &telemetrytypes.TelemetryFieldKey{Name: "deployment.environment.name"}, fieldKeys)
|
||||
require.Len(t, fields, 2)
|
||||
for _, logical := range fields {
|
||||
assert.False(t, logical.IsFamily())
|
||||
|
||||
@@ -42,8 +42,6 @@ type filterExpressionVisitor struct {
|
||||
skipResourceFilter bool
|
||||
skipFullTextFilter bool
|
||||
variables map[string]qbtypes.VariableItem
|
||||
signal telemetrytypes.Signal
|
||||
metricContext *telemetrytypes.MetricContext
|
||||
|
||||
keysWithWarnings map[string]bool
|
||||
startNs uint64
|
||||
@@ -69,11 +67,6 @@ type FilterExprVisitorOpts struct {
|
||||
Variables map[string]qbtypes.VariableItem
|
||||
StartNs uint64
|
||||
EndNs uint64
|
||||
// Signal is the signal the statement builder compiles for; family
|
||||
// resolution uses it for keys that do not carry their own. MetricContext
|
||||
// carries the queried metric name so metric-scoped families resolve.
|
||||
Signal telemetrytypes.Signal
|
||||
MetricContext *telemetrytypes.MetricContext
|
||||
}
|
||||
|
||||
// newFilterExpressionVisitor creates a new filterExpressionVisitor.
|
||||
@@ -90,8 +83,6 @@ func newFilterExpressionVisitor(opts FilterExprVisitorOpts) *filterExpressionVis
|
||||
skipResourceFilter: opts.SkipResourceFilter,
|
||||
skipFullTextFilter: opts.SkipFullTextFilter,
|
||||
variables: opts.Variables,
|
||||
signal: opts.Signal,
|
||||
metricContext: opts.MetricContext,
|
||||
keysWithWarnings: make(map[string]bool),
|
||||
startNs: opts.StartNs,
|
||||
endNs: opts.EndNs,
|
||||
@@ -395,7 +386,7 @@ func (v *filterExpressionVisitor) VisitPrimary(ctx *grammar.PrimaryContext) any
|
||||
// VisitComparison handles all comparison operators.
|
||||
func (v *filterExpressionVisitor) VisitComparison(ctx *grammar.ComparisonContext) any {
|
||||
key := v.Visit(ctx.Key()).(*telemetrytypes.TelemetryFieldKey)
|
||||
matching := MatchingLogicalFields(v.context, v.orgID, v.fl, v.signal, v.metricContext, key, v.fieldKeys)
|
||||
matching := MatchingLogicalFields(v.context, v.orgID, v.fl, key, v.fieldKeys)
|
||||
|
||||
// Handle EXISTS specially
|
||||
if ctx.EXISTS() != nil {
|
||||
@@ -746,7 +737,7 @@ func (v *filterExpressionVisitor) VisitFunctionCall(ctx *grammar.FunctionCallCon
|
||||
return ErrorConditionLiteral
|
||||
}
|
||||
|
||||
conds, ok := v.buildConditions(key, MatchingLogicalFields(v.context, v.orgID, v.fl, v.signal, v.metricContext, key, v.fieldKeys), operator, value)
|
||||
conds, ok := v.buildConditions(key, MatchingLogicalFields(v.context, v.orgID, v.fl, key, v.fieldKeys), operator, value)
|
||||
if !ok {
|
||||
return ErrorConditionLiteral
|
||||
}
|
||||
@@ -939,7 +930,7 @@ func (v *filterExpressionVisitor) VisitKey(ctx *grammar.KeyContext) any {
|
||||
// buildConditions invokes the condition builder for a filter term, folding its
|
||||
// warnings/errors into visitor state; returns false if an error was recorded.
|
||||
func (v *filterExpressionVisitor) buildConditions(key *telemetrytypes.TelemetryFieldKey, matching []*telemetrytypes.LogicalField, op qbtypes.FilterOperator, value any) ([]string, bool) {
|
||||
conds, warns, err := v.conditionBuilder.ConditionFor(v.context, v.orgID, v.startNs, v.endNs, key, v.fieldKeys, qbtypes.ConditionBuilderOptions{SkipResourceFilter: v.skipResourceFilter, MetricContext: v.metricContext}, op, value, v.builder)
|
||||
conds, warns, err := v.conditionBuilder.ConditionFor(v.context, v.orgID, v.startNs, v.endNs, key, v.fieldKeys, qbtypes.ConditionBuilderOptions{SkipResourceFilter: v.skipResourceFilter}, op, value, v.builder)
|
||||
if err != nil {
|
||||
_, _, _, _, errURL, _ := errors.Unwrapb(err)
|
||||
assignIfEmpty(&v.mainErrorURL, errURL)
|
||||
@@ -996,47 +987,44 @@ func assignIfEmpty(s *string, value string) {
|
||||
}
|
||||
|
||||
// familyMemberNames returns the physical spellings to look up for the
|
||||
// referenced key: the semantic-convention family spellings (current-first)
|
||||
// when the resolve_semconv_families flag is on for the org, else just the
|
||||
// requested name. The key's own signal wins over the caller's; metric lookups
|
||||
// additionally expand each member into its stored label spellings.
|
||||
func familyMemberNames(ctx context.Context, orgID valuer.UUID, fl flagger.Flagger, signal telemetrytypes.Signal, metricCtx *telemetrytypes.MetricContext, field *telemetrytypes.TelemetryFieldKey) []string {
|
||||
if !SemconvFamiliesEnabled(ctx, orgID, fl) {
|
||||
// referenced key: the semantic-convention family members (current-first) when
|
||||
// the resolve_semconv_families flag is on for the org and the key can resolve
|
||||
// to traces, else just the requested name. Only trace field mappers understand
|
||||
// families today; logs and metrics keep the requested spelling until theirs
|
||||
// land.
|
||||
func familyMemberNames(ctx context.Context, orgID valuer.UUID, fl flagger.Flagger, field *telemetrytypes.TelemetryFieldKey) []string {
|
||||
if !semconvFamiliesEnabled(ctx, orgID, fl) {
|
||||
return []string{field.Name}
|
||||
}
|
||||
if field.Signal != telemetrytypes.SignalUnspecified {
|
||||
signal = field.Signal
|
||||
if field.Signal != telemetrytypes.SignalUnspecified && field.Signal != telemetrytypes.SignalTraces {
|
||||
return []string{field.Name}
|
||||
}
|
||||
return semconv.AttributeMembers(telemetrytypes.FieldKeySelector{
|
||||
Name: field.Name,
|
||||
Signal: signal,
|
||||
FieldContext: field.FieldContext,
|
||||
MetricContext: metricCtx,
|
||||
return semconv.Members(semconv.KindAttribute, telemetrytypes.FieldKeySelector{
|
||||
Name: field.Name,
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: field.FieldContext,
|
||||
})
|
||||
}
|
||||
|
||||
// MatchingLogicalFields resolves the referenced key against the metadata map
|
||||
// into logical fields, honoring any context/data type the user specified.
|
||||
//
|
||||
// Physical keys that are members of one semantic-convention family group into
|
||||
// one logical field per (signal, context, data type) identity, members
|
||||
// ordered current-first. Every other matching key becomes its own
|
||||
// single-member logical field. Ambiguity is the length of the returned slice:
|
||||
// one family is one element and is never ambiguous with itself, but the slice
|
||||
// can hold several logical fields — including several family fields, one per
|
||||
// identity, when the family exists under more than one context or data type.
|
||||
// Members alias the metadata map entries; nothing is copied or mutated.
|
||||
//
|
||||
// signal is the signal the caller compiles for and metricCtx the queried
|
||||
// metric, both used only for family resolution; a key that carries its own
|
||||
// signal wins over the caller's.
|
||||
// Physical keys that are members of one semantic-convention family (traces
|
||||
// only today) group into one logical field per (signal, context, data type)
|
||||
// identity, members ordered current-first. Every other matching key becomes
|
||||
// its own single-member logical field. Ambiguity is the length of the
|
||||
// returned slice: one family is one element and is never ambiguous with
|
||||
// itself, but the slice can hold several logical fields — including several
|
||||
// family fields, one per identity, when the family exists under more than
|
||||
// one context or data type. Members alias the metadata map entries; nothing
|
||||
// is copied or mutated.
|
||||
//
|
||||
// Family grouping only happens when the resolve_semconv_families flag is on
|
||||
// for the org. A nil flagger means off: every match then stays a
|
||||
// single-member logical field.
|
||||
func MatchingLogicalFields(ctx context.Context, orgID valuer.UUID, fl flagger.Flagger, signal telemetrytypes.Signal, metricCtx *telemetrytypes.MetricContext, field *telemetrytypes.TelemetryFieldKey, fieldKeys map[string][]*telemetrytypes.TelemetryFieldKey) []*telemetrytypes.LogicalField {
|
||||
members := familyMemberNames(ctx, orgID, fl, signal, metricCtx, field)
|
||||
matches := collectMemberMatches(field, members, metricCtx, fieldKeys)
|
||||
func MatchingLogicalFields(ctx context.Context, orgID valuer.UUID, fl flagger.Flagger, field *telemetrytypes.TelemetryFieldKey, fieldKeys map[string][]*telemetrytypes.TelemetryFieldKey) []*telemetrytypes.LogicalField {
|
||||
members := familyMemberNames(ctx, orgID, fl, field)
|
||||
matches := collectMemberMatches(field, members, fieldKeys)
|
||||
return groupIntoLogicalFields(field.Name, len(members) > 1, matches)
|
||||
}
|
||||
|
||||
@@ -1062,29 +1050,32 @@ func matchesRequestedIdentity(field, item *telemetrytypes.TelemetryFieldKey, con
|
||||
}
|
||||
|
||||
// inFamilyScope reports whether a match found under a sibling member name is
|
||||
// legitimate: the member must be a family spelling of the requested name for
|
||||
// the entry's own signal and context. A member lookup can otherwise find a
|
||||
// same-named field in a scope where the family does not apply.
|
||||
func inFamilyScope(field, item *telemetrytypes.TelemetryFieldKey, memberName string, metricCtx *telemetrytypes.MetricContext) bool {
|
||||
return slices.Contains(semconv.AttributeMembers(telemetrytypes.FieldKeySelector{
|
||||
Name: field.Name,
|
||||
Signal: item.Signal,
|
||||
FieldContext: item.FieldContext,
|
||||
MetricContext: metricCtx,
|
||||
// legitimate: the entry must be trace metadata, and the member must be in the
|
||||
// family of the requested name for the entry's context. A member lookup can
|
||||
// otherwise find a same-named field in a scope where the family does not
|
||||
// apply.
|
||||
func inFamilyScope(field, item *telemetrytypes.TelemetryFieldKey, memberName string) bool {
|
||||
if item.Signal != telemetrytypes.SignalTraces {
|
||||
return false
|
||||
}
|
||||
return slices.Contains(semconv.Members(semconv.KindAttribute, telemetrytypes.FieldKeySelector{
|
||||
Name: field.Name,
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: item.FieldContext,
|
||||
}), memberName)
|
||||
}
|
||||
|
||||
// collectMemberMatches finds the metadata entries for every member spelling:
|
||||
// first under the member names, then under their context-prefixed spellings
|
||||
// (a context can be a legitimate part of a stored name, e.g. `attribute.key`).
|
||||
func collectMemberMatches(field *telemetrytypes.TelemetryFieldKey, members []string, metricCtx *telemetrytypes.MetricContext, fieldKeys map[string][]*telemetrytypes.TelemetryFieldKey) []memberMatch {
|
||||
func collectMemberMatches(field *telemetrytypes.TelemetryFieldKey, members []string, fieldKeys map[string][]*telemetrytypes.TelemetryFieldKey) []memberMatch {
|
||||
matches := make([]memberMatch, 0)
|
||||
collect := func(lookupName string, rank int, memberName string, contextMatched bool) {
|
||||
for _, item := range fieldKeys[lookupName] {
|
||||
if !matchesRequestedIdentity(field, item, contextMatched) {
|
||||
continue
|
||||
}
|
||||
if memberName != field.Name && !inFamilyScope(field, item, memberName, metricCtx) {
|
||||
if memberName != field.Name && !inFamilyScope(field, item, memberName) {
|
||||
continue
|
||||
}
|
||||
matches = append(matches, memberMatch{key: item, rank: rank})
|
||||
@@ -1102,18 +1093,18 @@ func collectMemberMatches(field *telemetrytypes.TelemetryFieldKey, members []str
|
||||
return matches
|
||||
}
|
||||
|
||||
// groupIntoLogicalFields turns matches into logical fields. Entries of a
|
||||
// family-capable signal in family mode group by their (signal, context, data
|
||||
// type) identity; every other entry becomes its own single-member field.
|
||||
// Members sort by family rank at the end: precedence is a property of the
|
||||
// family, not of the order in which the lookups found the members.
|
||||
// groupIntoLogicalFields turns matches into logical fields. Trace entries in
|
||||
// family mode group by their (signal, context, data type) identity; every
|
||||
// other entry becomes its own single-member field. Members sort by family
|
||||
// rank at the end: precedence is a property of the family, not of the order
|
||||
// in which the lookups found the members.
|
||||
func groupIntoLogicalFields(requestedName string, familyMode bool, matches []memberMatch) []*telemetrytypes.LogicalField {
|
||||
fields := make([]*telemetrytypes.LogicalField, 0, len(matches))
|
||||
groups := make(map[string]*telemetrytypes.LogicalField)
|
||||
ranks := make(map[*telemetrytypes.TelemetryFieldKey]int)
|
||||
|
||||
for _, match := range matches {
|
||||
if !familyMode || !familySignal(match.key.Signal) {
|
||||
if !familyMode || match.key.Signal != telemetrytypes.SignalTraces {
|
||||
fields = append(fields, telemetrytypes.SingleLogicalField(requestedName, match.key))
|
||||
continue
|
||||
}
|
||||
@@ -1153,11 +1144,3 @@ func groupHasMemberNamed(group *telemetrytypes.LogicalField, name string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func familySignal(signal telemetrytypes.Signal) bool {
|
||||
switch signal {
|
||||
case telemetrytypes.SignalTraces, telemetrytypes.SignalLogs, telemetrytypes.SignalMetrics:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -590,7 +590,7 @@ func TestVisitKey(t *testing.T) {
|
||||
// and decides not-found handling. Replay that here against the generic
|
||||
// builder behavior (error unless the key is ignored). The test maps carry
|
||||
// no signal, so every logical field is single-member and flattens losslessly.
|
||||
matching := MatchingLogicalFields(context.Background(), valuer.UUID{}, nil, telemetrytypes.SignalUnspecified, nil, key, tt.fieldKeys)
|
||||
matching := MatchingLogicalFields(context.Background(), valuer.UUID{}, nil, key, tt.fieldKeys)
|
||||
resolved, warning := ResolveLogicalFields(key, matching)
|
||||
keys := SingleKeys(resolved)
|
||||
|
||||
@@ -768,7 +768,7 @@ func (b *resourceConditionBuilder) ConditionFor(
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
resolved, warning := ResolveLogicalFields(key, MatchingLogicalFields(context.Background(), valuer.UUID{}, nil, telemetrytypes.SignalUnspecified, nil, key, fieldKeys))
|
||||
resolved, warning := ResolveLogicalFields(key, MatchingLogicalFields(context.Background(), valuer.UUID{}, nil, key, fieldKeys))
|
||||
keys := SingleKeys(resolved)
|
||||
var warnings []string
|
||||
if warning != "" {
|
||||
@@ -811,7 +811,7 @@ func (b *conditionBuilder) ConditionFor(
|
||||
return []string{fmt.Sprintf("%s_cond", key.Name)}, nil, nil
|
||||
}
|
||||
|
||||
resolved, warning := ResolveLogicalFields(key, MatchingLogicalFields(context.Background(), valuer.UUID{}, nil, telemetrytypes.SignalUnspecified, nil, key, fieldKeys))
|
||||
resolved, warning := ResolveLogicalFields(key, MatchingLogicalFields(context.Background(), valuer.UUID{}, nil, key, fieldKeys))
|
||||
keys := SingleKeys(resolved)
|
||||
var warnings []string
|
||||
if warning != "" {
|
||||
|
||||
@@ -2,44 +2,21 @@
|
||||
|
||||
package semconv
|
||||
|
||||
import "github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
|
||||
var families = []Family{
|
||||
{
|
||||
current: "container.cpu.usage",
|
||||
kind: KindMetric,
|
||||
members: []Member{
|
||||
{name: "container.cpu.utilization"},
|
||||
},
|
||||
Current: "db.system.name",
|
||||
Old: []string{"db.system"},
|
||||
Kind: KindAttribute,
|
||||
Contexts: nil,
|
||||
Signals: nil,
|
||||
ApplyToMetrics: nil,
|
||||
},
|
||||
{
|
||||
current: "db.system.name",
|
||||
kind: KindAttribute,
|
||||
members: []Member{
|
||||
{name: "db.system"},
|
||||
},
|
||||
signals: []telemetrytypes.Signal{telemetrytypes.SignalLogs, telemetrytypes.SignalTraces},
|
||||
},
|
||||
{
|
||||
current: "deployment.environment.name",
|
||||
kind: KindAttribute,
|
||||
members: []Member{
|
||||
{name: "deployment.environment"},
|
||||
},
|
||||
signals: []telemetrytypes.Signal{telemetrytypes.SignalLogs, telemetrytypes.SignalMetrics, telemetrytypes.SignalTraces},
|
||||
},
|
||||
{
|
||||
current: "k8s.node.cpu.usage",
|
||||
kind: KindMetric,
|
||||
members: []Member{
|
||||
{name: "k8s.node.cpu.utilization"},
|
||||
},
|
||||
},
|
||||
{
|
||||
current: "k8s.pod.cpu.usage",
|
||||
kind: KindMetric,
|
||||
members: []Member{
|
||||
{name: "k8s.pod.cpu.utilization"},
|
||||
},
|
||||
Current: "deployment.environment.name",
|
||||
Old: []string{"deployment.environment"},
|
||||
Kind: KindAttribute,
|
||||
Contexts: nil,
|
||||
Signals: nil,
|
||||
ApplyToMetrics: nil,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
package semconv
|
||||
|
||||
import (
|
||||
"iter"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
@@ -16,51 +14,16 @@ type Kind struct {
|
||||
valuer.String
|
||||
}
|
||||
|
||||
// Member is one historical spelling of a family, with the scope its rename
|
||||
// edges declared. A nil axis places no constraint on that axis.
|
||||
type Member struct {
|
||||
name string
|
||||
contexts []telemetrytypes.FieldContext
|
||||
signals []telemetrytypes.Signal
|
||||
applyToMetrics []string
|
||||
}
|
||||
|
||||
// Name returns the member spelling.
|
||||
func (m Member) Name() string {
|
||||
return m.name
|
||||
}
|
||||
|
||||
// Family is one logical telemetry field. Members are ordered from the most
|
||||
// recent predecessor to the oldest one and therefore also define fallback
|
||||
// order. The family-level contexts and signals come from the overlay and gate
|
||||
// where the family may resolve at all; member scopes come from the schema
|
||||
// edges and gate which members apply for a given selector.
|
||||
// Family is one logical telemetry field. Old is ordered from the most recent
|
||||
// predecessor to the oldest one and therefore also defines fallback order.
|
||||
type Family struct {
|
||||
current string
|
||||
kind Kind
|
||||
members []Member
|
||||
contexts []telemetrytypes.FieldContext
|
||||
signals []telemetrytypes.Signal
|
||||
valueMap map[string]string
|
||||
}
|
||||
|
||||
// Current returns the current name of the family.
|
||||
func (f Family) Current() string {
|
||||
return f.current
|
||||
}
|
||||
|
||||
// Kind returns the family kind.
|
||||
func (f Family) Kind() Kind {
|
||||
return f.kind
|
||||
}
|
||||
|
||||
// Old returns the historical spellings in fallback order.
|
||||
func (f Family) Old() []string {
|
||||
names := make([]string, len(f.members))
|
||||
for i, member := range f.members {
|
||||
names[i] = member.name
|
||||
}
|
||||
return names
|
||||
Current string
|
||||
Old []string
|
||||
Kind Kind
|
||||
Contexts []telemetrytypes.FieldContext
|
||||
Signals []telemetrytypes.Signal
|
||||
ApplyToMetrics []string
|
||||
ValueMap map[string]string
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -75,13 +38,8 @@ func (Kind) Enum() []any {
|
||||
return []any{KindAttribute, KindMetric}
|
||||
}
|
||||
|
||||
// Lookup returns the family that resolves selector.Name for kind.
|
||||
//
|
||||
// The missing-information policy is the same on every axis (signal, field
|
||||
// context, metric name): an axis the selector does not populate is a wildcard
|
||||
// and constrains nothing. When the wildcards leave more than one family
|
||||
// admitted, the name does not resolve — resolution never picks an arbitrary
|
||||
// winner, it asks for more information by staying literal.
|
||||
// Lookup returns the enabled family containing selector.Name for kind. The
|
||||
// returned family must not be modified.
|
||||
func Lookup(kind Kind, selector telemetrytypes.FieldKeySelector) (Family, bool) {
|
||||
idx, ok := lookupIndex(kind, selector)
|
||||
if !ok {
|
||||
@@ -90,284 +48,80 @@ func Lookup(kind Kind, selector telemetrytypes.FieldKeySelector) (Family, bool)
|
||||
return families[idx], true
|
||||
}
|
||||
|
||||
// Members returns the current name first, followed by the historical spellings
|
||||
// admitted for the selector, in fallback order. A name outside an enabled
|
||||
// family — or one the selector leaves ambiguous — is returned unchanged. The
|
||||
// Members returns the current name first, followed by historical names in
|
||||
// fallback order. A name outside an enabled family is returned unchanged. The
|
||||
// returned slice must not be modified.
|
||||
func Members(kind Kind, selector telemetrytypes.FieldKeySelector) []string {
|
||||
idx, ok := lookupIndex(kind, selector)
|
||||
if !ok {
|
||||
return []string{selector.Name}
|
||||
}
|
||||
return admittedMembers(idx, selector)
|
||||
}
|
||||
|
||||
func admittedMembers(idx int, selector telemetrytypes.FieldKeySelector) []string {
|
||||
admitted := 0
|
||||
for _, member := range families[idx].members {
|
||||
if memberAdmits(member, selector) {
|
||||
admitted++
|
||||
}
|
||||
}
|
||||
if admitted == len(families[idx].members) {
|
||||
return familyMembers[idx]
|
||||
}
|
||||
names := make([]string, 0, admitted+1)
|
||||
names = append(names, families[idx].current)
|
||||
for _, member := range families[idx].members {
|
||||
if memberAdmits(member, selector) {
|
||||
names = append(names, member.name)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// AttributeMembers returns the physical attribute spellings that may represent
|
||||
// selector.Name. Metrics have used both dotted and normalized label layouts,
|
||||
// and resource labels have additionally used a resource_ prefix, so metric
|
||||
// selectors expand every admitted member into those spellings; keeping that
|
||||
// storage detail here prevents metrics readers from maintaining local
|
||||
// transition tables. Every other signal gets the plain member list.
|
||||
func AttributeMembers(selector telemetrytypes.FieldKeySelector) []string {
|
||||
if selector.Signal != telemetrytypes.SignalMetrics {
|
||||
return Members(KindAttribute, selector)
|
||||
}
|
||||
|
||||
lookupSelector := selector
|
||||
lookupSelector.Name = strings.TrimPrefix(selector.Name, "resource_")
|
||||
idx, style, ok := lookupMetricSpelling(KindAttribute, lookupSelector)
|
||||
if !ok {
|
||||
return []string{selector.Name}
|
||||
}
|
||||
|
||||
logicalMembers := admittedMembers(idx, lookupSelector)
|
||||
result := make([]string, 0, len(logicalMembers)*4)
|
||||
for _, member := range logicalMembers {
|
||||
variants := []string{member, normalizeMetricSpelling(member)}
|
||||
if style == metricSpellingNormalized {
|
||||
variants[0], variants[1] = variants[1], variants[0]
|
||||
}
|
||||
|
||||
if selector.FieldContext == telemetrytypes.FieldContextResource ||
|
||||
selector.FieldContext == telemetrytypes.FieldContextUnspecified ||
|
||||
strings.HasPrefix(selector.Name, "resource_") {
|
||||
for _, variant := range variants {
|
||||
result = appendUniqueString(result, "resource_"+variant)
|
||||
}
|
||||
}
|
||||
for _, variant := range variants {
|
||||
result = appendUniqueString(result, variant)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// MetricNames returns the current and historical storage names for a metric.
|
||||
// The input's dotted or normalized style is preserved because both layouts are
|
||||
// valid metric identities and must not be mixed in one query.
|
||||
func MetricNames(name string) []string {
|
||||
selector := telemetrytypes.FieldKeySelector{
|
||||
Name: name,
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
FieldContext: telemetrytypes.FieldContextMetric,
|
||||
}
|
||||
idx, style, ok := lookupMetricSpelling(KindMetric, selector)
|
||||
if !ok {
|
||||
return []string{name}
|
||||
}
|
||||
|
||||
logicalMembers := admittedMembers(idx, selector)
|
||||
result := make([]string, 0, len(logicalMembers))
|
||||
for _, member := range logicalMembers {
|
||||
if style == metricSpellingNormalized {
|
||||
member = normalizeMetricSpelling(member)
|
||||
}
|
||||
result = appendUniqueString(result, member)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// CurrentAttribute returns the canonical dotted name for an attribute
|
||||
// spelling, or selector.Name if no enabled family matches.
|
||||
func CurrentAttribute(selector telemetrytypes.FieldKeySelector) string {
|
||||
if selector.Signal != telemetrytypes.SignalMetrics {
|
||||
return Current(KindAttribute, selector)
|
||||
}
|
||||
|
||||
lookupSelector := selector
|
||||
lookupSelector.Name = strings.TrimPrefix(selector.Name, "resource_")
|
||||
idx, _, ok := lookupMetricSpelling(KindAttribute, lookupSelector)
|
||||
if !ok {
|
||||
return selector.Name
|
||||
}
|
||||
return families[idx].current
|
||||
return familyMembers[idx]
|
||||
}
|
||||
|
||||
// Current returns the current name for selector.Name, or the input name when
|
||||
// it does not resolve to a family.
|
||||
// it does not belong to an enabled family.
|
||||
func Current(kind Kind, selector telemetrytypes.FieldKeySelector) string {
|
||||
idx, ok := lookupIndex(kind, selector)
|
||||
if !ok {
|
||||
return selector.Name
|
||||
}
|
||||
return families[idx].current
|
||||
return families[idx].Current
|
||||
}
|
||||
|
||||
// All iterates over every enabled family.
|
||||
func All() iter.Seq[Family] {
|
||||
return func(yield func(Family) bool) {
|
||||
for _, family := range families {
|
||||
if !yield(family) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
// All returns every enabled family. The returned slice and families must not be
|
||||
// modified.
|
||||
func All() []Family {
|
||||
return families
|
||||
}
|
||||
|
||||
func buildIndexes() (map[string][]int, [][]string) {
|
||||
index := make(map[string][]int)
|
||||
members := make([][]string, len(families))
|
||||
for i, family := range families {
|
||||
members[i] = make([]string, 0, len(family.members)+1)
|
||||
members[i] = append(members[i], family.current)
|
||||
index[family.current] = append(index[family.current], i)
|
||||
for _, member := range family.members {
|
||||
members[i] = append(members[i], member.name)
|
||||
if !slices.Contains(index[member.name], i) {
|
||||
index[member.name] = append(index[member.name], i)
|
||||
}
|
||||
members[i] = make([]string, 0, len(family.Old)+1)
|
||||
members[i] = append(members[i], family.Current)
|
||||
members[i] = append(members[i], family.Old...)
|
||||
index[family.Current] = append(index[family.Current], i)
|
||||
for _, old := range family.Old {
|
||||
index[old] = append(index[old], i)
|
||||
}
|
||||
}
|
||||
return index, members
|
||||
}
|
||||
|
||||
func lookupIndex(kind Kind, selector telemetrytypes.FieldKeySelector) (int, bool) {
|
||||
found, foundIdx := 0, 0
|
||||
for _, idx := range memberToFamilies[selector.Name] {
|
||||
if familyAdmits(families[idx], kind, selector) {
|
||||
found++
|
||||
foundIdx = idx
|
||||
if matchesSelector(families[idx], kind, selector) {
|
||||
return idx, true
|
||||
}
|
||||
}
|
||||
if found != 1 {
|
||||
return 0, false
|
||||
}
|
||||
return foundIdx, true
|
||||
return 0, false
|
||||
}
|
||||
|
||||
type metricSpelling int
|
||||
|
||||
const (
|
||||
metricSpellingDotted metricSpelling = iota
|
||||
metricSpellingNormalized
|
||||
)
|
||||
|
||||
// lookupMetricSpelling resolves a metric spelling to its family: first as the
|
||||
// canonical dotted name, then by comparing the normalized form of every
|
||||
// family spelling. The ambiguity rule of lookupIndex applies to both styles.
|
||||
func lookupMetricSpelling(kind Kind, selector telemetrytypes.FieldKeySelector) (int, metricSpelling, bool) {
|
||||
if idx, ok := lookupIndex(kind, selector); ok {
|
||||
return idx, metricSpellingDotted, true
|
||||
func matchesSelector(family Family, kind Kind, selector telemetrytypes.FieldKeySelector) bool {
|
||||
if family.Kind != kind {
|
||||
return false
|
||||
}
|
||||
|
||||
found, foundIdx := 0, 0
|
||||
for idx := range families {
|
||||
if normalizedFamilyAdmits(families[idx], kind, selector) {
|
||||
found++
|
||||
foundIdx = idx
|
||||
if selector.Signal != telemetrytypes.SignalUnspecified && len(family.Signals) > 0 {
|
||||
if !slices.Contains(family.Signals, selector.Signal) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if found != 1 {
|
||||
return 0, metricSpellingDotted, false
|
||||
}
|
||||
return foundIdx, metricSpellingNormalized, true
|
||||
}
|
||||
|
||||
func normalizedFamilyAdmits(family Family, kind Kind, selector telemetrytypes.FieldKeySelector) bool {
|
||||
if normalizeMetricSpelling(family.current) == selector.Name ||
|
||||
slices.ContainsFunc(family.members, func(member Member) bool {
|
||||
return normalizeMetricSpelling(member.name) == selector.Name
|
||||
}) {
|
||||
dotted := selector
|
||||
dotted.Name = denormalizeAgainst(family, selector.Name)
|
||||
return familyAdmits(family, kind, dotted)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func denormalizeAgainst(family Family, name string) string {
|
||||
if normalizeMetricSpelling(family.current) == name {
|
||||
return family.current
|
||||
}
|
||||
for _, member := range family.members {
|
||||
if normalizeMetricSpelling(member.name) == name {
|
||||
return member.name
|
||||
if selector.FieldContext != telemetrytypes.FieldContextUnspecified && len(family.Contexts) > 0 {
|
||||
if !slices.Contains(family.Contexts, selector.FieldContext) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
func normalizeMetricSpelling(name string) string {
|
||||
return strings.ReplaceAll(name, ".", "_")
|
||||
}
|
||||
|
||||
func appendUniqueString(values []string, value string) []string {
|
||||
if slices.Contains(values, value) {
|
||||
return values
|
||||
}
|
||||
return append(values, value)
|
||||
}
|
||||
|
||||
// familyAdmits reports whether the family resolves selector.Name: the
|
||||
// family-level gate must admit the selector, and the name must be the current
|
||||
// name or an admitted member.
|
||||
func familyAdmits(family Family, kind Kind, selector telemetrytypes.FieldKeySelector) bool {
|
||||
if family.kind != kind {
|
||||
return false
|
||||
}
|
||||
if !axisAdmits(family.signals, selector.Signal, telemetrytypes.SignalUnspecified) {
|
||||
return false
|
||||
}
|
||||
if !axisAdmits(family.contexts, selector.FieldContext, telemetrytypes.FieldContextUnspecified) {
|
||||
return false
|
||||
}
|
||||
if selector.Name == family.current {
|
||||
for _, member := range family.members {
|
||||
if memberAdmits(member, selector) {
|
||||
return true
|
||||
}
|
||||
if selector.Signal == telemetrytypes.SignalMetrics && len(family.ApplyToMetrics) > 0 {
|
||||
if selector.MetricContext == nil {
|
||||
return false
|
||||
}
|
||||
return false
|
||||
return slices.Contains(family.ApplyToMetrics, selector.MetricContext.MetricName)
|
||||
}
|
||||
for _, member := range family.members {
|
||||
if member.name == selector.Name && memberAdmits(member, selector) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// memberAdmits reports whether the member applies for the selector under the
|
||||
// wildcard policy: a selector axis without a value never constrains, and a
|
||||
// member axis without a value admits every selector value.
|
||||
func memberAdmits(member Member, selector telemetrytypes.FieldKeySelector) bool {
|
||||
if !axisAdmits(member.signals, selector.Signal, telemetrytypes.SignalUnspecified) {
|
||||
return false
|
||||
}
|
||||
if !axisAdmits(member.contexts, selector.FieldContext, telemetrytypes.FieldContextUnspecified) {
|
||||
return false
|
||||
}
|
||||
if len(member.applyToMetrics) > 0 &&
|
||||
selector.MetricContext != nil && selector.MetricContext.MetricName != "" &&
|
||||
!slices.Contains(member.applyToMetrics, selector.MetricContext.MetricName) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func axisAdmits[T comparable](scope []T, value T, unspecified T) bool {
|
||||
if len(scope) == 0 || value == unspecified {
|
||||
return true
|
||||
}
|
||||
return slices.Contains(scope, value)
|
||||
}
|
||||
|
||||
@@ -78,177 +78,3 @@ func TestMembersReturnsInputWhenKindDoesNotMatch(t *testing.T) {
|
||||
"an attribute family must not match a metric-name lookup",
|
||||
)
|
||||
}
|
||||
|
||||
func TestFamilySignalsGateResolution(t *testing.T) {
|
||||
metrics := telemetrytypes.FieldKeySelector{Name: "db.system", Signal: telemetrytypes.SignalMetrics}
|
||||
logs := telemetrytypes.FieldKeySelector{Name: "db.system", Signal: telemetrytypes.SignalLogs}
|
||||
|
||||
assert.Equal(t, []string{"db.system"}, Members(KindAttribute, metrics),
|
||||
"a family gated to traces and logs must stay literal for metrics")
|
||||
assert.Equal(t, []string{"db.system.name", "db.system"}, Members(KindAttribute, logs),
|
||||
"the gate admits the signals it lists")
|
||||
}
|
||||
|
||||
func TestMetricNameFamilyResolves(t *testing.T) {
|
||||
selector := telemetrytypes.FieldKeySelector{Name: "k8s.pod.cpu.utilization", Signal: telemetrytypes.SignalMetrics}
|
||||
|
||||
assert.Equal(t, []string{"k8s.pod.cpu.usage", "k8s.pod.cpu.utilization"}, Members(KindMetric, selector))
|
||||
assert.Equal(t, "k8s.pod.cpu.usage", Current(KindMetric, selector))
|
||||
assert.Equal(t, []string{"k8s.pod.cpu.utilization"}, Members(KindAttribute, selector),
|
||||
"a metric-name family must not match an attribute lookup")
|
||||
}
|
||||
|
||||
func TestMembersReturnsSharedSliceForUnscopedFamily(t *testing.T) {
|
||||
selector := telemetrytypes.FieldKeySelector{Name: "deployment.environment", Signal: telemetrytypes.SignalTraces}
|
||||
|
||||
first := Members(KindAttribute, selector)
|
||||
second := Members(KindAttribute, selector)
|
||||
assert.Equal(t, &first[0], &second[0],
|
||||
"a family whose members all admit must return the precomputed slice, not a copy")
|
||||
}
|
||||
|
||||
func TestAttributeMembersExpandsMetricSpellings(t *testing.T) {
|
||||
selector := telemetrytypes.FieldKeySelector{
|
||||
Name: "deployment.environment",
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
}
|
||||
|
||||
assert.Equal(t, []string{
|
||||
"resource_deployment.environment.name", "resource_deployment_environment_name",
|
||||
"deployment.environment.name", "deployment_environment_name",
|
||||
"resource_deployment.environment", "resource_deployment_environment",
|
||||
"deployment.environment", "deployment_environment",
|
||||
}, AttributeMembers(selector), "metric members expand into dotted, normalized, and resource_-prefixed spellings")
|
||||
}
|
||||
|
||||
func TestAttributeMembersPreservesRequestedSpellingStyle(t *testing.T) {
|
||||
selector := telemetrytypes.FieldKeySelector{
|
||||
Name: "resource_deployment_environment",
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
}
|
||||
|
||||
assert.Equal(t, []string{
|
||||
"resource_deployment_environment_name", "resource_deployment.environment.name",
|
||||
"deployment_environment_name", "deployment.environment.name",
|
||||
"resource_deployment_environment", "resource_deployment.environment",
|
||||
"deployment_environment", "deployment.environment",
|
||||
}, AttributeMembers(selector), "a normalized request lists normalized spellings first and keeps resource_ variants")
|
||||
}
|
||||
|
||||
func TestAttributeMembersRespectsTheFamilyGateForMetrics(t *testing.T) {
|
||||
selector := telemetrytypes.FieldKeySelector{
|
||||
Name: "db.system",
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
}
|
||||
|
||||
assert.Equal(t, []string{"db.system"}, AttributeMembers(selector),
|
||||
"a family gated away from metrics must not expand metric spellings")
|
||||
}
|
||||
|
||||
func TestAttributeMembersIsPlainForOtherSignals(t *testing.T) {
|
||||
selector := telemetrytypes.FieldKeySelector{
|
||||
Name: "deployment.environment",
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
}
|
||||
|
||||
assert.Equal(t, []string{"deployment.environment.name", "deployment.environment"}, AttributeMembers(selector))
|
||||
}
|
||||
|
||||
func TestMetricNamesPreservesStyle(t *testing.T) {
|
||||
assert.Equal(t, []string{"k8s.pod.cpu.usage", "k8s.pod.cpu.utilization"}, MetricNames("k8s.pod.cpu.utilization"))
|
||||
assert.Equal(t, []string{"k8s_pod_cpu_usage", "k8s_pod_cpu_utilization"}, MetricNames("k8s_pod_cpu_utilization"))
|
||||
assert.Equal(t, []string{"k8s.pod.cpu.usage", "k8s.pod.cpu.utilization"}, MetricNames("k8s.pod.cpu.usage"))
|
||||
assert.Equal(t, []string{"http.server.duration"}, MetricNames("http.server.duration"))
|
||||
}
|
||||
|
||||
func TestCurrentAttributeCanonicalizesMetricSpellings(t *testing.T) {
|
||||
assert.Equal(t, "deployment.environment.name", CurrentAttribute(telemetrytypes.FieldKeySelector{
|
||||
Name: "resource_deployment_environment",
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
}))
|
||||
assert.Equal(t, "unrelated_label", CurrentAttribute(telemetrytypes.FieldKeySelector{
|
||||
Name: "unrelated_label",
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
}))
|
||||
}
|
||||
|
||||
func TestAllIteratesEnabledFamilies(t *testing.T) {
|
||||
currents := []string{}
|
||||
for family := range All() {
|
||||
currents = append(currents, family.Current())
|
||||
}
|
||||
assert.Contains(t, currents, "deployment.environment.name")
|
||||
assert.Contains(t, currents, "k8s.pod.cpu.usage")
|
||||
}
|
||||
|
||||
// swapFamilies replaces the generated table for one test so scoped-member and
|
||||
// fan-out behavior can be pinned without enabling such families for real.
|
||||
func swapFamilies(t *testing.T, replacement []Family) {
|
||||
t.Helper()
|
||||
prevFamilies, prevIndex, prevMembers := families, memberToFamilies, familyMembers
|
||||
families = replacement
|
||||
memberToFamilies, familyMembers = buildIndexes()
|
||||
t.Cleanup(func() {
|
||||
families, memberToFamilies, familyMembers = prevFamilies, prevIndex, prevMembers
|
||||
})
|
||||
}
|
||||
|
||||
func TestFanOutResolvesOnlyWithEnoughInformation(t *testing.T) {
|
||||
swapFamilies(t, []Family{
|
||||
{
|
||||
current: "cpu.mode",
|
||||
kind: KindAttribute,
|
||||
members: []Member{{name: "state", applyToMetrics: []string{"system.cpu.time"}}},
|
||||
},
|
||||
{
|
||||
current: "db.client.connection.state",
|
||||
kind: KindAttribute,
|
||||
members: []Member{{name: "state", applyToMetrics: []string{"db.client.connections.usage"}}},
|
||||
},
|
||||
})
|
||||
|
||||
ambiguous := telemetrytypes.FieldKeySelector{Name: "state", Signal: telemetrytypes.SignalMetrics}
|
||||
assert.Equal(t, []string{"state"}, Members(KindAttribute, ambiguous),
|
||||
"without a metric name, a fanned-out member admits several families and must stay literal")
|
||||
|
||||
pinned := ambiguous
|
||||
pinned.MetricContext = &telemetrytypes.MetricContext{MetricName: "system.cpu.time"}
|
||||
assert.Equal(t, []string{"cpu.mode", "state"}, Members(KindAttribute, pinned),
|
||||
"the metric name disambiguates the fan-out")
|
||||
|
||||
outside := ambiguous
|
||||
outside.MetricContext = &telemetrytypes.MetricContext{MetricName: "http.server.duration"}
|
||||
assert.Equal(t, []string{"state"}, Members(KindAttribute, outside),
|
||||
"a metric outside every apply_to_metrics list resolves no family")
|
||||
}
|
||||
|
||||
func TestMemberScopesFilterMembers(t *testing.T) {
|
||||
swapFamilies(t, []Family{{
|
||||
current: "user_agent.original",
|
||||
kind: KindAttribute,
|
||||
members: []Member{
|
||||
{name: "http.user_agent", contexts: []telemetrytypes.FieldContext{telemetrytypes.FieldContextAttribute}, signals: []telemetrytypes.Signal{telemetrytypes.SignalTraces}},
|
||||
{name: "browser.user_agent", contexts: []telemetrytypes.FieldContext{telemetrytypes.FieldContextResource}},
|
||||
},
|
||||
}})
|
||||
|
||||
resource := telemetrytypes.FieldKeySelector{
|
||||
Name: "user_agent.original",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextResource,
|
||||
}
|
||||
assert.Equal(t, []string{"user_agent.original", "browser.user_agent"}, Members(KindAttribute, resource),
|
||||
"a strict resource lookup must not include the span-only member")
|
||||
|
||||
attribute := resource
|
||||
attribute.FieldContext = telemetrytypes.FieldContextAttribute
|
||||
assert.Equal(t, []string{"user_agent.original", "http.user_agent"}, Members(KindAttribute, attribute),
|
||||
"a strict attribute lookup must not include the resource-only member")
|
||||
|
||||
strictResourceOldSpan := resource
|
||||
strictResourceOldSpan.Name = "http.user_agent"
|
||||
assert.Equal(t, []string{"http.user_agent"}, Members(KindAttribute, strictResourceOldSpan),
|
||||
"an old spelling outside its own scope stays literal")
|
||||
}
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
package logsstatementbuilder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/flagger"
|
||||
"github.com/SigNoz/signoz/pkg/flagger/flaggertest"
|
||||
"github.com/SigNoz/signoz/pkg/instrumentation/instrumentationtest"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/statementbuilder"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/logstelemetryschema"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes/telemetrytypestest"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// A filter on either spelling of an enabled family compiles to one merged
|
||||
// condition over the log attribute maps; the flag default keeps it literal.
|
||||
func TestStatementBuilderResolvesLogFamilies(t *testing.T) {
|
||||
releaseTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)
|
||||
releaseTimeNano := uint64(releaseTime.UnixNano())
|
||||
|
||||
logsKey := func(name string) *telemetrytypes.TelemetryFieldKey {
|
||||
return &telemetrytypes.TelemetryFieldKey{
|
||||
Name: name,
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
FieldContext: telemetrytypes.FieldContextAttribute,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
}
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
familyOn bool
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "families on",
|
||||
familyOn: true,
|
||||
expected: "SELECT count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE COALESCE(NULLIF(attributes_string['deployment.environment.name'], ''), NULLIF(attributes_string['deployment.environment'], ''), '') = ? AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? ORDER BY __result_0 DESC",
|
||||
},
|
||||
{
|
||||
name: "families off",
|
||||
familyOn: false,
|
||||
expected: "SELECT count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE (attributes_string['deployment.environment'] = ? AND mapContains(attributes_string, 'deployment.environment')) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? ORDER BY __result_0 DESC",
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
fl := flaggertest.WithBooleanFlags(t, map[string]bool{
|
||||
flagger.FeatureResolveSemconvFamilies.String(): c.familyOn,
|
||||
})
|
||||
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
|
||||
keys := logstelemetryschema.BuildCompleteFieldKeyMap(releaseTime)
|
||||
keys["deployment.environment.name"] = []*telemetrytypes.TelemetryFieldKey{logsKey("deployment.environment.name")}
|
||||
keys["deployment.environment"] = []*telemetrytypes.TelemetryFieldKey{logsKey("deployment.environment")}
|
||||
mockMetadataStore.KeysMap = keys
|
||||
fm := logstelemetryschema.NewFieldMapper(fl)
|
||||
cb := logstelemetryschema.NewConditionBuilder(fm, fl)
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, fm, cb, fl)
|
||||
statementBuilder := NewLogQueryStatementBuilder(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
mockMetadataStore, fm, cb, aggExprRewriter,
|
||||
logstelemetryschema.DefaultFullTextColumn, fl, nil,
|
||||
statementbuilder.Config{SkipResourceFingerprint: statementbuilder.SkipResourceFingerprint{Enabled: false, Threshold: 100000}},
|
||||
)
|
||||
|
||||
query := qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]{
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
|
||||
Aggregations: []qbtypes.LogAggregation{{Expression: "count()"}},
|
||||
Filter: &qbtypes.Filter{
|
||||
Expression: "attribute.deployment.environment = 'production'",
|
||||
},
|
||||
}
|
||||
q, err := statementBuilder.Build(context.Background(), valuer.UUID{},
|
||||
releaseTimeNano+uint64(24*time.Hour.Nanoseconds()),
|
||||
releaseTimeNano+uint64(48*time.Hour.Nanoseconds()),
|
||||
qbtypes.RequestTypeScalar, query, nil)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, c.expected, q.Query)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -128,7 +128,6 @@ func (b *logQueryStatementBuilder) Build(
|
||||
bodyJSONEnabled := b.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(orgID))
|
||||
|
||||
keySelectors, warnings := getKeySelectors(query, bodyJSONEnabled)
|
||||
keySelectors = querybuilder.ExpandKeySelectorsForFamilies(ctx, orgID, b.fl, keySelectors)
|
||||
keys, _, err := b.metadataStore.GetKeysMulti(ctx, orgID, keySelectors)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -742,8 +741,6 @@ func (b *logQueryStatementBuilder) addFilterCondition(
|
||||
Variables: variables,
|
||||
StartNs: start,
|
||||
EndNs: end,
|
||||
Flagger: b.fl,
|
||||
Signal: telemetrytypes.SignalLogs,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
|
||||
@@ -44,8 +44,8 @@ func NewFactory(
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fm := metricstelemetryschema.NewFieldMapper(nil)
|
||||
cb := metricstelemetryschema.NewConditionBuilder(fm, nil)
|
||||
fm := metricstelemetryschema.NewFieldMapper()
|
||||
cb := metricstelemetryschema.NewConditionBuilder(fm)
|
||||
return NewMeterQueryStatementBuilder(settings, metadataStore, fm, cb, metricsStatementBuilder), nil
|
||||
},
|
||||
)
|
||||
|
||||
@@ -162,8 +162,8 @@ func TestStatementBuilder(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
fm := metricstelemetryschema.NewFieldMapper(nil)
|
||||
cb := metricstelemetryschema.NewConditionBuilder(fm, nil)
|
||||
fm := metricstelemetryschema.NewFieldMapper()
|
||||
cb := metricstelemetryschema.NewConditionBuilder(fm)
|
||||
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
|
||||
keys, err := telemetrytypestest.LoadFieldKeysFromJSON("testdata/keys_map.json")
|
||||
if err != nil {
|
||||
@@ -202,8 +202,8 @@ func TestStatementBuilder(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGroupByAliasAvoidsColumnCollision(t *testing.T) {
|
||||
fm := metricstelemetryschema.NewFieldMapper(nil)
|
||||
cb := metricstelemetryschema.NewConditionBuilder(fm, nil)
|
||||
fm := metricstelemetryschema.NewFieldMapper()
|
||||
cb := metricstelemetryschema.NewConditionBuilder(fm)
|
||||
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
|
||||
keys, err := telemetrytypestest.LoadFieldKeysFromJSON("testdata/keys_map.json")
|
||||
require.NoError(t, err)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user