Compare commits

..

20 Commits

Author SHA1 Message Date
Vinícius Lourenço
867b958318 test(storybook): cover classic alert form label states 2026-10-01 12:00:26 -03:00
Vinícius Lourenço
088fe04a20 test(storybook): add planned downtime edit story with specific alert rules 2026-10-01 12:00:26 -03:00
Vinícius Lourenço
0002d9c5f8 test(storybook): hold Noz click-opened state past the thread remount 2026-10-01 12:00:26 -03:00
Vinícius Lourenço
c8b6e83474 test(storybook): drop the fixed pause between filter keystrokes 2026-10-01 12:00:25 -03:00
Vinícius Lourenço
5607edb302 test(storybook): find dashboard settings and panel editor controls by test id 2026-10-01 12:00:25 -03:00
Vinícius Lourenço
680ac5e88f fix(tanstack-table): keep cells mounted when inline row callbacks change 2026-10-01 12:00:25 -03:00
Vinícius Lourenço
09f9d2f3c8 test(storybook): cover panel editor chart options, thresholds and links 2026-10-01 12:00:25 -03:00
Vinícius Lourenço
203d7a0753 test(storybook): cover dashboard settings, variables and authz states 2026-10-01 12:00:25 -03:00
Vinícius Lourenço
3ba8c9390c fix(storybook): attribute failed responses to the story that sent them 2026-10-01 11:59:59 -03:00
Vinícius Lourenço
6cc07a4d75 test(storybook): cover Noz voice recording states 2026-10-01 11:59:58 -03:00
Vinícius Lourenço
856cd91491 test(storybook): assert the in-flight create key button is disabled 2026-10-01 11:59:58 -03:00
Vinícius Lourenço
f22196a2c0 test(storybook): cover ingestion create key validation, expiry and submit states 2026-10-01 11:59:58 -03:00
Vinícius Lourenço
0438850c45 test(storybook): cover ingestion key tag adding and added states 2026-10-01 11:59:58 -03:00
Vinícius Lourenço
2cc9421350 test(storybook): click exceptions filter suggestions as they are found 2026-10-01 11:59:58 -03:00
Vinícius Lourenço
c692297ca6 test(storybook): hold the pod filter stories at their opening height 2026-10-01 11:59:58 -03:00
Vinícius Lourenço
cc6ddd5233 test(storybook): wait for the filter's scrollbars before focusing it 2026-10-01 11:59:57 -03:00
Vinícius Lourenço
d72424d9d0 test(storybook): keep filter focused while scrollbars initialise 2026-10-01 11:59:57 -03:00
Vinícius Lourenço
bbec5691e0 test(storybook): add query builder filter interaction stories 2026-10-01 11:59:57 -03:00
Vinícius Lourenço
cf61f7b31a test(storybook): fix interaction stories against current main 2026-10-01 11:59:57 -03:00
Vinícius Lourenço
02055934ba test(storybook): add interaction stories for menus, drawers and selects 2026-10-01 11:59:57 -03:00
109 changed files with 3636 additions and 3605 deletions

View File

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

View File

@@ -262,10 +262,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"

View File

@@ -84,9 +84,9 @@ A storage answers four questions and nothing else:
| WhenAbsent | Absent row reads | Positive filter | Raw select | Multi-candidate column | Field keys |
|---|---|---|---|---|---|
| `AlwaysPresent` | a real value | no guard | no guard | no branch, ends the candidate list | table columns |
| `AbsentIsSentinel` | `''`, 0, false, and that is not a value | exists guard | exists guard | presence branch | map attributes, cast JSON paths, string families of such members |
| `AbsentIsSentinel` | `''`, 0, false, and that is not a value | exists guard | exists guard | presence branch | map attributes, cast JSON paths, string families |
| `AbsentIsNull` | NULL | no guard | no guard | presence branch | multi-era folds, body JSON paths, numeric families |
| `AbsentIsValue` | `''`, and that is the keyless contract | no guard | no guard | no presence branch | metrics labels, rule state history labels, and families of such members |
| `AbsentIsValue` | `''`, and that is the keyless contract | no guard | no guard | no presence branch | metrics labels, rule state history labels |
### The generic layer
@@ -109,7 +109,7 @@ The functions, from the outside in:
| `RejectsBodyFunction(traits, operator)` | Runs before resolution. A storage without body functions (`has`, `hasAny`, `hasAll`, `hasToken`, `search`) errors. The fingerprint side of a split skips the term, because the main query evaluates it. After resolution, `Condition` errors when `has`, `hasAny`, `hasAll`, or `hasToken` lands on a map-backed key (resource, attribute, scope), before the split can drop it. |
| `SharedCondition(...)` | The `Compile` of every storage without its own condition language: `LogicalRead`, the shared data-type collision cast, `OperatorCondition`, then the guard rule. |
| `OperatorCondition(...)` | The operator switch over an already cast read. A storage with its own cast policy composes with it. |
| `LogicalRead(...)` | The only place family expressions are built. A single-member field reads through its member. A family merges the member reads, current member first: `COALESCE(NULLIF(m1, ''), NULLIF(m2, ''), '')` for strings, `multiIf` with a NULL tail for numbers. It ORs the member presence tests. A row without any member reads what the tail of the merge reads. When every member reads its sentinel as a value, so does the family. A member with a value map reads through `TransformRead`. `NOT EXISTS` is the read's `Absence`, the storage's own negated form. |
| `LogicalRead(...)` | The only place family expressions are built. A single-member field reads through its member. A family merges the member reads, current member first: `COALESCE(NULLIF(m1, ''), NULLIF(m2, ''), '')` for strings, `multiIf` with a NULL tail for numbers. It ORs the member presence tests. A row without any member reads what the tail of the merge reads. A member with a value map reads through `TransformRead`. `NOT EXISTS` is the read's `Absence`, the storage's own negated form. |
### A resolved key

View File

@@ -70,3 +70,49 @@
Object.defineProperty(window, 'Date', { value: FrozenDate, writable: true });
})();
</script>
<script>
// Storybook has no microphone. Chromium's recognizer asks for one and for
// Google's speech service, and the `not-allowed` or `network` it reports makes
// the assistant hide its mic for the rest of the tab. This one starts
// listening at once and hears only what a story passes to `hear`.
(() => {
class StorySpeechRecognition extends EventTarget {
static listening = null;
lang = 'en-US';
continuous = false;
interimResults = false;
onstart = null;
onresult = null;
onerror = null;
onend = null;
start() {
StorySpeechRecognition.listening = this;
setTimeout(() => this.onstart?.());
}
stop() {
if (StorySpeechRecognition.listening === this) {
StorySpeechRecognition.listening = null;
}
setTimeout(() => this.onend?.());
}
abort() {
this.stop();
}
/** An interim result: the words so far, still listening. */
hear(transcript) {
const result = Object.assign([{ transcript, confidence: 1 }], {
isFinal: false,
});
this.onresult?.({ resultIndex: 0, results: [result] });
}
}
window.SpeechRecognition = StorySpeechRecognition;
window.webkitSpeechRecognition = StorySpeechRecognition;
})();
</script>

View File

@@ -122,7 +122,21 @@ const preview: Preview = {
['List', 'New', 'Edit'],
],
'Dashboards',
['List', 'Detail', 'Panel Editor', 'Public'],
[
'List',
'Detail',
[
'Overview',
'Settings',
'Variables',
'New Variable',
'Edit Variable',
'Authz',
],
'Panel Editor',
['Overview', 'Chart Options', 'Thresholds and Links'],
'Public',
],
'Services',
['List', 'Detail', 'Top Level Operations', 'Service Map'],
'Logs',

View File

@@ -23,6 +23,13 @@ const IGNORED_MESSAGES = [
// (YouTube embeds, the docs pane) so they hit the real network instead of
// an unanswered msw request; the block is the point, not a bug.
/violates the following Content Security Policy directive/,
// The filter editor's ANTLR parser reports every syntax error through
// `console.error` (`line 1:14 missing ...`), so each partial expression
// typed into it logs one; the editor shows the same errors on screen.
/^line \d+:\d+ /,
// Chromium's echo of a 4xx/5xx response, which the `[response]` entry below
// already reports with its URL and the time its request left.
/^Failed to load resource: the server responded with a status of /,
];
interface CapturedMessage {
@@ -86,7 +93,9 @@ const config: TestRunnerConfig = {
page.on('response', (response) => {
if (response.status() >= 400) {
messages.push({
at: Date.now(),
// The browser's clock, when the request left: a busy runner receives
// the outgoing story's responses after the next story has started.
at: response.request().timing().startTime,
text: `[response] ${response.status()} ${response.url()}`,
});
}

View File

@@ -0,0 +1,229 @@
import { EditorView } from '@codemirror/view';
import { userEvent, waitFor, within } from 'storybook/test';
/** Suggestions wait on a 300ms debounce and a fetch, past the 1s default. */
const untilLoaded = { timeout: 15_000 };
/** Throws until `found` holds something, which is what `waitFor` retries on. */
const present = <TValue>(
found: TValue | null | undefined,
what: string,
): TValue => {
if (found === null || found === undefined) {
throw new Error(`${what} not found`);
}
return found;
};
const suggestionList = (canvasElement: HTMLElement): HTMLElement | null =>
canvasElement.querySelector<HTMLElement>('.cm-tooltip-autocomplete');
const suggestionRow = (
canvasElement: HTMLElement,
text: string,
): HTMLElement | undefined => {
const list = suggestionList(canvasElement);
return list
? within(list)
.queryAllByRole('option')
.find((option) => option.textContent?.includes(text))
: undefined;
};
/** Ctrl+Space, the editor's own shortcut for asking for suggestions. */
const requestSuggestions = (editor: HTMLElement): void => {
editor.dispatchEvent(
new KeyboardEvent('keydown', {
key: ' ',
code: 'Space',
ctrlKey: true,
bubbles: true,
}),
);
};
const currentView = (
canvasElement: HTMLElement,
): { editor: HTMLElement; view: EditorView } => {
// An explorer renders one editor per query; the first is the one on screen.
const editor = present(
canvasElement.querySelector<HTMLElement>(
'.code-mirror-where-clause .cm-content',
),
'filter editor',
);
return {
editor,
view: present(EditorView.findFromDOM(editor), 'editor view'),
};
};
/**
* The explorers wrap the filter in `OverlayScrollbar`, which initialises when
* the browser is idle. Initialising moves the content, the editor with it, and
* focuses the editor again through the DOM, which puts the caret back at the
* start and swaps the suggestions for the key list. Throws until every wrapper
* around the filter has initialised.
*/
const assertScrollbarsReady = (editor: HTMLElement): void => {
for (
let wrapper = editor.closest('.overlay-scrollbar');
wrapper;
wrapper = wrapper.parentElement?.closest('.overlay-scrollbar') ?? null
) {
if (!wrapper.hasAttribute('data-overlayscrollbars')) {
throw new Error('scrollbars around the filter still initialising');
}
}
};
/**
* Waits until `text` shows in the suggestion list, asking for suggestions
* whenever the list is shut. Focus and typing only open it once the keys have
* loaded, and moving the caret never does.
*/
const waitForSuggestion = (
canvasElement: HTMLElement,
text: string,
): Promise<HTMLElement> =>
waitFor(
() => {
const { editor } = currentView(canvasElement);
if (!suggestionList(canvasElement)) {
requestSuggestions(editor);
}
return present(suggestionRow(canvasElement, text), `suggestion "${text}"`);
},
{ ...untilLoaded, interval: 250 },
);
/**
* Focuses the filter once the scrollbars around it have initialised, and waits
* for its suggestion list.
*/
const focusFilter = async (canvasElement: HTMLElement): Promise<EditorView> => {
await waitFor(
() => {
const { editor, view } = currentView(canvasElement);
assertScrollbarsReady(editor);
if (!view.hasFocus) {
view.focus();
}
if (!suggestionList(canvasElement)) {
requestSuggestions(editor);
}
return present(suggestionList(canvasElement), 'suggestion list');
},
{ ...untilLoaded, interval: 250 },
);
return currentView(canvasElement).view;
};
/** Waits for a row of the suggestion list. */
export const findSuggestion = (
canvasElement: HTMLElement,
text: string,
): Promise<HTMLElement> => waitForSuggestion(canvasElement, text);
/** Focuses the empty filter: every key, with any recent filters above them. */
export const openKeySuggestions = async (
canvasElement: HTMLElement,
row: string,
): Promise<void> => {
await focusFilter(canvasElement);
await waitForSuggestion(canvasElement, row);
};
/**
* Focuses the filter and types onto the end of it one character at a time,
* each as the transaction a keystroke makes, leaving the caret at the end so
* the suggestion list follows what was typed. Quotes and brackets are not
* closed for it: type both.
*
* `userEvent.type` cannot be used: CodeMirror redraws the line as tokens are
* highlighted, which strands the caret `userEvent` tracks.
*/
export const typeFilter = async (
canvasElement: HTMLElement,
text: string,
): Promise<void> => {
const view = await focusFilter(canvasElement);
for (const character of text) {
const at = view.state.doc.length;
view.dispatch({
changes: { from: at, insert: character },
selection: { anchor: at + character.length },
userEvent: 'input.type',
});
}
};
/**
* Types an expression, then steps the caret back inside it, before a closing
* bracket or parenthesis, where the suggestions are about what goes in there.
*/
export const typeFilterWithCaretBack = async (
canvasElement: HTMLElement,
text: string,
stepsBack: number,
row: string,
): Promise<void> => {
await typeFilter(canvasElement, text);
const { view } = currentView(canvasElement);
view.dispatch({
selection: { anchor: view.state.doc.length - stepsBack },
userEvent: 'select',
});
await waitForSuggestion(canvasElement, row);
};
/**
* Moves focus off the filter, which is when the expression is validated and
* the error marker can show.
*/
export const blurFilter = async (canvasElement: HTMLElement): Promise<void> => {
await userEvent.keyboard('{Escape}');
await userEvent.click(canvasElement.ownerDocument.body);
};
/** Types an expression, leaves the filter and opens its validation errors. */
export const showFilterErrors = async (
canvasElement: HTMLElement,
text: string,
): Promise<void> => {
await typeFilter(canvasElement, text);
await blurFilter(canvasElement);
const marker = await waitFor(
() =>
present(
canvasElement.querySelector<HTMLElement>('.query-status-container button'),
'error marker',
),
untilLoaded,
);
await userEvent.hover(marker);
await waitFor(
() =>
present(
canvasElement.ownerDocument.querySelector('.query-validation-error'),
'validation error',
),
untilLoaded,
);
};

View File

@@ -233,12 +233,23 @@ function TanStackTableInner<TData, TItemKey = string>(
[effectiveColumns],
);
// A rebuilt column def is a new cell component to React, which remounts every
// cell it renders. Read the row callbacks through refs so inline ones do not.
const isRowActiveRef = useRef(isRowActive);
isRowActiveRef.current = isRowActive;
const getRowKeyDataRef = useRef(getRowKeyData);
getRowKeyDataRef.current = getRowKeyData;
const tanstackColumns = useMemo<ColumnDef<TData>[]>(
() =>
effectiveColumns.map((colDef) =>
buildTanstackColumnDef<TData, TItemKey>(colDef, isRowActive, getRowKeyData),
buildTanstackColumnDef<TData, TItemKey>(
colDef,
(row) => isRowActiveRef.current?.(row) ?? false,
(index) => getRowKeyDataRef.current(index),
),
),
[effectiveColumns, isRowActive, getRowKeyData],
[effectiveColumns],
);
const getRowId = useCallback(

View File

@@ -1,8 +1,16 @@
import { useEffect, useState } from 'react';
import { fireEvent, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { UrlUpdateEvent } from 'nuqs/adapters/testing';
import { renderTanStackTable } from './testUtils';
import TanStackTable from '../index';
import type { TableColumnDef } from '../types';
import {
defaultData,
renderTanStackTable,
renderWithProviders,
type TestRow,
} from './testUtils';
jest.mock('hooks/useDarkMode', () => ({
useIsDarkMode: (): boolean => false,
@@ -810,6 +818,58 @@ describe('TanStackTableView Integration', () => {
});
});
describe('cell identity', () => {
it('keeps cells mounted across re-renders that pass new inline row callbacks', async () => {
const user = userEvent.setup();
const onMount = jest.fn();
function MountTracker({ id }: { id: string }): JSX.Element {
useEffect(() => onMount(id), [id]);
return <span data-testid={`tracked-${id}`}>Row {id}</span>;
}
const columns: TableColumnDef<TestRow>[] = [
{
id: 'id',
header: 'ID',
accessorKey: 'id',
cell: ({ row }): JSX.Element => <MountTracker id={row.id} />,
},
];
function Harness(): JSX.Element {
const [, setRenders] = useState(0);
return (
<>
<button
type="button"
data-testid="rerender"
onClick={(): void => setRenders((count) => count + 1)}
>
Re-render
</button>
<TanStackTable<TestRow>
data={defaultData}
columns={columns}
getRowKey={(row): string => row.id}
isRowActive={(row): boolean => row.id === '1'}
/>
</>
);
}
renderWithProviders(<Harness />);
await screen.findByTestId('tracked-2');
onMount.mockClear();
await user.click(screen.getByTestId('rerender'));
await user.hover(screen.getByTestId('tracked-2'));
expect(onMount).not.toHaveBeenCalled();
});
});
describe('disableVirtualScroll', () => {
it('throws error when used with onEndReached', () => {
expect(() => {

View File

@@ -1,82 +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: [],
applyToMetrics: [],
valueMap: {},
},
{
current: 'deployment.environment.name',
old: ['deployment.environment'],
kind: 'attribute',
members: [
{
name: 'deployment.environment',
contexts: [],
signals: [],
applyToMetrics: [],
},
],
contexts: ['attribute', 'resource'],
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;

View File

@@ -1,53 +0,0 @@
import { renderHook } from '@testing-library/react';
import { useBottomStripStore } from 'container/BottomStrip/store/useBottomStripStore';
import { StripItemKind } from 'container/BottomStrip/types';
import { useExceptionsStripInfo } from '../useExceptionsStripInfo';
describe('useExceptionsStripInfo', () => {
beforeEach(() => {
useBottomStripStore.setState({ left: null, ownerId: null });
});
it('shows the rows on the page against the total', () => {
renderHook(() => useExceptionsStripInfo({ shownCount: 25, totalCount: 500 }));
expect(useBottomStripStore.getState().left).toMatchObject([
{ kind: StripItemKind.Text, text: '25 of 500 exceptions' },
]);
});
it('still says n of m when the whole list fits on one page', () => {
renderHook(() => useExceptionsStripInfo({ shownCount: 42, totalCount: 42 }));
expect(useBottomStripStore.getState().left?.[0]).toMatchObject({
text: '42 of 42 exceptions',
});
});
it('says exception, not exceptions, when there is one', () => {
renderHook(() => useExceptionsStripInfo({ shownCount: 1, totalCount: 1 }));
expect(useBottomStripStore.getState().left?.[0]).toMatchObject({
text: '1 of 1 exception',
});
});
it('shows zero before the counts land', () => {
renderHook(() => useExceptionsStripInfo({ shownCount: 0, totalCount: 0 }));
expect(useBottomStripStore.getState().left?.[0]).toMatchObject({
text: '0 of 0 exceptions',
});
});
it('clears the strip when the page unmounts', () => {
const { unmount } = renderHook(() =>
useExceptionsStripInfo({ shownCount: 25, totalCount: 500 }),
);
unmount();
expect(useBottomStripStore.getState().left).toBeNull();
});
});

View File

@@ -38,7 +38,6 @@ import { Exception, PayloadProps } from 'types/api/errors/getAll';
import { GlobalReducer } from 'types/reducer/globalTime';
import { FilterDropdownExtendsProps } from './types';
import { useExceptionsStripInfo } from './useExceptionsStripInfo';
import {
extractFilterValues,
getDefaultFilterValue,
@@ -161,11 +160,6 @@ function AllErrors(): JSX.Element {
},
]);
useExceptionsStripInfo({
shownCount: data?.payload?.length ?? 0,
totalCount: errorCountResponse.data?.payload ?? 0,
});
const isFetching = isErrorsFetching || errorCountResponse.isFetching;
useEffect(() => {
setIsFetching(isFetching);

View File

@@ -1,26 +0,0 @@
import { useMemo } from 'react';
import { useBottomStrip } from 'container/BottomStrip/useBottomStrip';
import { type StripItem, StripItemKind } from 'container/BottomStrip/types';
import { pluralize } from 'utils/pluralize';
interface UseExceptionsStripInfoArgs {
shownCount: number;
totalCount: number;
}
export function useExceptionsStripInfo({
shownCount,
totalCount,
}: UseExceptionsStripInfoArgs): void {
const items = useMemo<StripItem[]>(
() => [
{
kind: StripItemKind.Text,
text: `${shownCount} of ${pluralize(totalCount, 'exception')}`,
},
],
[shownCount, totalCount],
);
useBottomStrip(items);
}

View File

@@ -854,9 +854,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
<ChangelogModal changelog={changelog} onClose={toggleChangelogModal} />
)}
<Toaster
offset={{ bottom: 'calc(var(--bottom-strip-height, 0px) + 24px)' }}
/>
<Toaster />
</Layout>
</TooltipProvider>
);

View File

@@ -26,7 +26,6 @@ import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import ROUTES from 'constants/routes';
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
import { useHomeStripInfo } from 'container/Home/useHomeStripInfo';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
@@ -65,8 +64,6 @@ const homeInterval = 30 * 60 * 1000;
// eslint-disable-next-line sonarjs/cognitive-complexity
export default function Home(): JSX.Element {
useHomeStripInfo();
const { user } = useAppContext();
const { safeNavigate } = useSafeNavigate();
const isDarkMode = useIsDarkMode();

View File

@@ -1,58 +0,0 @@
import { renderHook } from '@testing-library/react';
import { useGetAlerts } from 'api/generated/services/alerts';
import { useBottomStripStore } from 'container/BottomStrip/store/useBottomStripStore';
import { StripItemKind } from 'container/BottomStrip/types';
import { useHomeStripInfo } from '../useHomeStripInfo';
jest.mock('api/generated/services/alerts', () => ({
useGetAlerts: jest.fn(),
}));
const mockUseGetAlerts = useGetAlerts as jest.Mock;
describe('useHomeStripInfo', () => {
beforeEach(() => {
useBottomStripStore.setState({ left: null, ownerId: null });
});
it('counts the firing alert instances', () => {
mockUseGetAlerts.mockReturnValue({ data: { data: [{}, {}, {}] } });
renderHook(() => useHomeStripInfo());
expect(useBottomStripStore.getState().left).toMatchObject([
{ kind: StripItemKind.Text, text: '3 alerts firing' },
]);
});
it('says alert, not alerts, when only one is firing', () => {
mockUseGetAlerts.mockReturnValue({ data: { data: [{}] } });
renderHook(() => useHomeStripInfo());
expect(useBottomStripStore.getState().left?.[0]).toMatchObject({
text: '1 alert firing',
});
});
it('shows zero before the response lands', () => {
mockUseGetAlerts.mockReturnValue({ data: undefined });
renderHook(() => useHomeStripInfo());
expect(useBottomStripStore.getState().left?.[0]).toMatchObject({
text: '0 alerts firing',
});
});
it('clears the strip when the page unmounts', () => {
mockUseGetAlerts.mockReturnValue({ data: { data: [{}, {}, {}] } });
const { unmount } = renderHook(() => useHomeStripInfo());
unmount();
expect(useBottomStripStore.getState().left).toBeNull();
});
});

View File

@@ -1,21 +0,0 @@
import { useMemo } from 'react';
import { useGetAlerts } from 'api/generated/services/alerts';
import { useBottomStrip } from 'container/BottomStrip/useBottomStrip';
import { type StripItem, StripItemKind } from 'container/BottomStrip/types';
import { pluralize } from 'utils/pluralize';
export function useHomeStripInfo(): void {
// Firing instances, not rules, matching the triggered alerts page.
const { data } = useGetAlerts();
const count = data?.data?.length ?? 0;
const items = useMemo<StripItem[]>(
() => [
{ kind: StripItemKind.Text, text: `${pluralize(count, 'alert')} firing` },
],
[count],
);
useBottomStrip(items);
}

View File

@@ -1,53 +0,0 @@
import { renderHook } from '@testing-library/react';
import { useBottomStripStore } from 'container/BottomStrip/store/useBottomStripStore';
import { StripItemKind } from 'container/BottomStrip/types';
import { useAlertRulesStripInfo } from '../useAlertRulesStripInfo';
describe('useAlertRulesStripInfo', () => {
beforeEach(() => {
useBottomStripStore.setState({ left: null, ownerId: null });
});
it('shows the rows on the page against the total', () => {
renderHook(() => useAlertRulesStripInfo({ shownCount: 15, totalCount: 17 }));
expect(useBottomStripStore.getState().left).toMatchObject([
{ kind: StripItemKind.Text, text: '15 of 17 rules' },
]);
});
it('still says n of m when the whole list fits on one page', () => {
renderHook(() => useAlertRulesStripInfo({ shownCount: 17, totalCount: 17 }));
expect(useBottomStripStore.getState().left?.[0]).toMatchObject({
text: '17 of 17 rules',
});
});
it('says rule, not rules, when there is only one', () => {
renderHook(() => useAlertRulesStripInfo({ shownCount: 1, totalCount: 1 }));
expect(useBottomStripStore.getState().left?.[0]).toMatchObject({
text: '1 of 1 rule',
});
});
it('shows zero when nothing matched', () => {
renderHook(() => useAlertRulesStripInfo({ shownCount: 0, totalCount: 12 }));
expect(useBottomStripStore.getState().left?.[0]).toMatchObject({
text: '0 of 12 rules',
});
});
it('clears the strip when the page unmounts', () => {
const { unmount } = renderHook(() =>
useAlertRulesStripInfo({ shownCount: 15, totalCount: 17 }),
);
unmount();
expect(useBottomStripStore.getState().left).toBeNull();
});
});

View File

@@ -20,7 +20,6 @@ import { ALERT_RULES_PARAMS, useAlertRulesFilters } from './hooks';
import styles from './ListAlertRules.module.scss';
import { getAlertRuleColumns } from './table.config';
import type { AlertRule } from './types';
import { useAlertRulesStripInfo } from './useAlertRulesStripInfo';
import { useAlertRulesData } from './useAlertRulesData';
import { useAlertRulesHandlers } from './useAlertRulesHandlers';
@@ -88,11 +87,6 @@ function ListAlertRules(): JSX.Element {
return filteredRules.slice(start, start + limit);
}, [filteredRules, page, limit]);
useAlertRulesStripInfo({
shownCount: paginatedRules.length,
totalCount: filteredRules.length,
});
const columnsWithActions = useMemo(() => {
if (!action) {
return columns;

View File

@@ -1,27 +0,0 @@
import { useMemo } from 'react';
import { useBottomStrip } from 'container/BottomStrip/useBottomStrip';
import { type StripItem, StripItemKind } from 'container/BottomStrip/types';
import { pluralize } from 'utils/pluralize';
interface UseAlertRulesStripInfoArgs {
/** Rows on the current page, matching the table's own footer. */
shownCount: number;
totalCount: number;
}
export function useAlertRulesStripInfo({
shownCount,
totalCount,
}: UseAlertRulesStripInfoArgs): void {
const items = useMemo<StripItem[]>(
() => [
{
kind: StripItemKind.Text,
text: `${shownCount} of ${pluralize(totalCount, 'rule')}`,
},
],
[shownCount, totalCount],
);
useBottomStrip(items);
}

View File

@@ -21,7 +21,6 @@ import { getTotalRPS } from 'utils/services';
import { getColumns } from '../Columns/ServiceColumn';
import { ServiceMetricsTableProps } from '../types';
import { useServicesStripInfo } from '../useServicesStripInfo';
import { getServiceListFromQuery } from '../utils';
function ServiceMetricTable({
@@ -68,8 +67,6 @@ function ServiceMetricTable({
[isLoading, queries, topLevelOperations],
);
useServicesStripInfo(services.length);
const { search } = useLocation();
const tableColumns = useMemo(() => getColumns(search, true), [search]);
const [RPS, setRPS] = useState(0);

View File

@@ -18,7 +18,6 @@ import { GlobalReducer } from 'types/reducer/globalTime';
import { Tags } from 'hooks/useResourceAttribute/types';
import SkipOnBoardingModal from '../SkipOnBoardModal';
import { useServicesStripInfo } from '../useServicesStripInfo';
import ServiceTraceTable from './ServiceTracesTable';
function ServiceTraces(): JSX.Element {
@@ -43,8 +42,6 @@ function ServiceTraces(): JSX.Element {
const services = data || [];
useServicesStripInfo(services.length);
const [skipOnboarding, setSkipOnboarding] = useState(
localStorageGet(SKIP_ONBOARDING) === 'true',
);

View File

@@ -1,43 +0,0 @@
import { renderHook } from '@testing-library/react';
import { useBottomStripStore } from 'container/BottomStrip/store/useBottomStripStore';
import { StripItemKind } from 'container/BottomStrip/types';
import { useServicesStripInfo } from '../useServicesStripInfo';
describe('useServicesStripInfo', () => {
beforeEach(() => {
useBottomStripStore.setState({ left: null, ownerId: null });
});
it('shows how many services are listed', () => {
renderHook(() => useServicesStripInfo(18));
expect(useBottomStripStore.getState().left).toMatchObject([
{ kind: StripItemKind.Text, text: '18 services' },
]);
});
it('says service, not services, when there is one', () => {
renderHook(() => useServicesStripInfo(1));
expect(useBottomStripStore.getState().left?.[0]).toMatchObject({
text: '1 service',
});
});
it('shows zero when there are none', () => {
renderHook(() => useServicesStripInfo(0));
expect(useBottomStripStore.getState().left?.[0]).toMatchObject({
text: '0 services',
});
});
it('clears the strip when the page unmounts', () => {
const { unmount } = renderHook(() => useServicesStripInfo(18));
unmount();
expect(useBottomStripStore.getState().left).toBeNull();
});
});

View File

@@ -1,13 +0,0 @@
import { useMemo } from 'react';
import { useBottomStrip } from 'container/BottomStrip/useBottomStrip';
import { type StripItem, StripItemKind } from 'container/BottomStrip/types';
import { pluralize } from 'utils/pluralize';
export function useServicesStripInfo(count: number): void {
const items = useMemo<StripItem[]>(
() => [{ kind: StripItemKind.Text, text: pluralize(count, 'service') }],
[count],
);
useBottomStrip(items);
}

View File

@@ -8,6 +8,7 @@ import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { encode } from 'js-base64';
import type { Tags } from 'hooks/useResourceAttribute/types';
import { fireEvent, userEvent, waitFor, within } from 'storybook/test';
import {
choiceControl,
@@ -31,6 +32,9 @@ import {
type ServiceHealth,
} from './__story_mockdata__/serviceMap';
/** The keys are only fetched once the select opens, past the 1s default. */
const untilLoaded = { timeout: 15_000 };
const GRAPH = 'Service map · graph';
const FILTERS = 'Service map · filters';
@@ -129,3 +133,80 @@ export const serviceMapMocks = defineStoryMocks({
],
config: (values) => ({ route: serviceMapRoute(values.filters) }),
});
/** Opens the select under a test id; it closes again after every pick. */
export const openSelect = async (
canvasElement: HTMLElement,
testId: string,
): Promise<void> => {
const select = await within(canvasElement).findByTestId(
testId,
undefined,
untilLoaded,
);
await userEvent.click(within(select).getByRole('combobox'));
};
export const OPEN_DROPDOWN =
'.ant-select-dropdown:not(.ant-select-dropdown-hidden)';
/** antd keeps a hidden copy of each label for screen readers; the title skips it. */
const visibleOption = (title: string): HTMLElement | null =>
document.querySelector<HTMLElement>(
`${OPEN_DROPDOWN} .ant-select-item-option[title="${title}"]`,
);
/**
* Picks the option titled `title` in the open dropdown. `userEvent.click`
* moves focus off the select on the way, which closes it before the option
* takes the click.
*/
export const pickOption = async (title: string): Promise<void> => {
const option = await waitFor(() => {
const match = visibleOption(title);
if (!match) {
throw new Error(`option "${title}" not found`);
}
return match;
}, untilLoaded);
await fireEvent.click(option);
};
/**
* Opens the attribute filter on its next step. Each single-choice pick closes
* the dropdown and swaps the select for the next step's, and a click that lands
* before the swap opens nothing, so it opens again until `title` shows.
*/
export const openAttributeFilterOn = (
canvasElement: HTMLElement,
title: string,
): Promise<void> =>
waitFor(
async () => {
if (visibleOption(title)) {
return;
}
if (!document.querySelector(OPEN_DROPDOWN)) {
await openSelect(canvasElement, 'resource-attributes-filter');
}
throw new Error(`option "${title}" not shown`);
},
{ ...untilLoaded, interval: 500 },
);
/** Stages `k8s.cluster.name IN` and leaves the filter open on its values. */
export const stageClusterIn = async (
canvasElement: HTMLElement,
): Promise<void> => {
await openAttributeFilterOn(canvasElement, 'k8s.cluster.name');
await pickOption('k8s.cluster.name');
await openAttributeFilterOn(canvasElement, 'IN');
await pickOption('IN');
await openAttributeFilterOn(canvasElement, 'staging-eu');
};

View File

@@ -1,10 +1,17 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { screen, userEvent, within } from 'storybook/test';
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { serviceMapMocks } from './ServiceMap.stories.mocks';
import {
OPEN_DROPDOWN,
openAttributeFilterOn,
openSelect,
pickOption,
serviceMapMocks,
stageClusterIn,
} from './ServiceMap.stories.mocks';
import ServiceMapContainer from '../index';
@@ -97,3 +104,49 @@ export const FilterAttributes: Story = {
await screen.findByText('k8s.cluster.name', undefined, untilLoaded);
},
};
/** A key staged as a chip, the filter open again on how to match it. */
export const FilterOperators: Story = {
play: async ({ canvasElement }): Promise<void> => {
await openAttributeFilterOn(canvasElement, 'k8s.cluster.name');
await pickOption('k8s.cluster.name');
await openAttributeFilterOn(canvasElement, 'Not IN');
},
};
/** A key and `IN` staged, the filter open on the values the key holds. */
export const FilterValues: Story = {
play: async ({ canvasElement }): Promise<void> => {
await stageClusterIn(canvasElement);
},
};
/** Two values ticked before the filter is left, which is what applies it. */
export const FilterValuesSelected: Story = {
play: async ({ canvasElement }): Promise<void> => {
await stageClusterIn(canvasElement);
await pickOption('prod-us-east');
await pickOption('prod-eu-west');
await waitFor(
() =>
expect(
document.querySelectorAll('.ant-select-item-option-selected'),
).toHaveLength(2),
untilLoaded,
);
},
};
/** The environment selector open on the environments the calls came from. */
export const EnvironmentOptions: Story = {
play: async ({ canvasElement }): Promise<void> => {
await openSelect(canvasElement, 'resource-environment-filter');
await waitFor(
() =>
expect(
document.querySelector(`${OPEN_DROPDOWN} .ant-select-item-option`),
).not.toBeNull(),
untilLoaded,
);
},
};

View File

@@ -11,8 +11,6 @@ import { useAIAssistantStore } from 'container/AIAssistant/store/useAIAssistantS
import { VariantContext } from 'container/AIAssistant/VariantContext';
import Noz from 'components/Noz/Noz';
import { useAIAssistantStripInfo } from './useAIAssistantStripInfo';
import styles from './AIAssistantPage.module.scss';
import ConversationsList from 'container/AIAssistant/components/ConversationsList';
@@ -43,8 +41,6 @@ export default function AIAssistantPage(): JSX.Element {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useAIAssistantStripInfo();
const conversations = useAIAssistantStore((s) => s.conversations);
const activeConversationId = useAIAssistantStore(
(s) => s.activeConversationId,

View File

@@ -1,60 +0,0 @@
import { renderHook } from '@testing-library/react';
import { useAIAssistantStore } from 'container/AIAssistant/store/useAIAssistantStore';
import { useBottomStripStore } from 'container/BottomStrip/store/useBottomStripStore';
import { StripItemKind } from 'container/BottomStrip/types';
import { useAIAssistantStripInfo } from '../useAIAssistantStripInfo';
function seed(conversations: Record<string, unknown>): void {
useAIAssistantStore.setState({ conversations } as never);
}
describe('useAIAssistantStripInfo', () => {
beforeEach(() => {
useBottomStripStore.setState({ left: null, ownerId: null });
});
it('counts only the conversations that are not archived', () => {
seed({
a: { id: 'a' },
b: { id: 'b' },
c: { id: 'c', archived: true },
});
renderHook(() => useAIAssistantStripInfo());
expect(useBottomStripStore.getState().left).toMatchObject([
{ kind: StripItemKind.Text, text: '2 conversations' },
]);
});
it('says one conversation, not 1 conversations', () => {
seed({ a: { id: 'a' } });
renderHook(() => useAIAssistantStripInfo());
expect(useBottomStripStore.getState().left?.[0]).toMatchObject({
text: '1 conversation',
});
});
it('shows zero when there are none', () => {
seed({});
renderHook(() => useAIAssistantStripInfo());
expect(useBottomStripStore.getState().left?.[0]).toMatchObject({
text: '0 conversations',
});
});
it('clears the strip when the page unmounts', () => {
seed({ a: { id: 'a' } });
const { unmount } = renderHook(() => useAIAssistantStripInfo());
unmount();
expect(useBottomStripStore.getState().left).toBeNull();
});
});

View File

@@ -7,6 +7,7 @@ import { AI_API_PATH, setAIBackendUrl } from 'api/AIAPIInstance';
import ROUTES from 'constants/routes';
import { useAIAssistantStore } from 'container/AIAssistant/store/useAIAssistantStore';
import { rest, type RequestHandler } from 'msw';
import { expect, userEvent, waitFor, within } from 'storybook/test';
import {
choiceControl,
@@ -270,3 +271,40 @@ export const longActionTooltipHandlers: RequestHandler[] = onBothBases(
);
},
);
/** The recognizer `.storybook/preview-head.html` installs in place of the browser's. */
interface StorySpeechRecognition {
listening: { hear: (transcript: string) => void } | null;
}
/**
* Clicks the composer's mic and waits for the recording to start. The composer
* stays disabled until the thread has loaded.
*/
export const startVoiceInput = async (
canvasElement: HTMLElement,
): Promise<void> => {
const canvas = within(canvasElement);
const mic = await waitFor(
async () => {
const button = canvas.getByRole('button', { name: /start voice input/i });
// Depending on its version, Button disables through `disabled` or `aria-disabled`.
await expect(button).toBeEnabled();
await expect(button).not.toHaveAttribute('aria-disabled', 'true');
return button;
},
{ timeout: 15_000 },
);
await userEvent.click(mic);
await canvas.findByRole('status', { name: /recording voice input/i });
};
/** Speaks into the open recording: the words so far, still listening. */
export const speak = (transcript: string): void => {
(
window as unknown as { webkitSpeechRecognition: StorySpeechRecognition }
).webkitSpeechRecognition.listening?.hear(transcript);
};

View File

@@ -10,6 +10,8 @@ import AIAssistantPage from '../AIAssistantPage';
import {
aiAssistantMocks,
longActionTooltipHandlers,
speak,
startVoiceInput,
} from './AIAssistantPage.stories.mocks';
import type { ThreadPart } from './__story_mockdata__/aiAssistant';
@@ -47,11 +49,15 @@ type Story = StoryObj<AIAssistantArgs>;
/** The thread list resolves before the thread does, which outlasts the 1s default. */
const untilLoaded = { timeout: 15_000 };
/** How long what a click opened has to stay on screen to count as open. */
const HOLD_MS = 1_000;
/**
* Click something, and keep clicking until what it opens is on screen. The
* message list remounts its items while it measures a freshly loaded thread, so
* a single click can land on a row that is about to be replaced, taking the
* state it just set with it.
* state it just set with it. The replacement can land after that state has
* rendered, so it has to still be there `HOLD_MS` later.
*/
const clickUntil = async (
find: () => Promise<HTMLElement>,
@@ -60,6 +66,10 @@ const clickUntil = async (
await waitFor(async () => {
await userEvent.click(await find());
await screen.findByText(opens, undefined, { timeout: 1_000 });
await new Promise((resolve) => {
setTimeout(resolve, HOLD_MS);
});
screen.getByText(opens);
}, untilLoaded);
};
@@ -173,6 +183,18 @@ export const ApprovalDiff: Story = {
play: openApprovalDiff,
};
/**
* Dictating a question: the mic listening, the words heard so far in the
* composer, and the controls to discard them or stop and send.
*/
export const VoiceRecording: Story = {
play: async ({ canvasElement }): Promise<void> => {
await startVoiceInput(canvasElement);
speak('why did checkout p99 jump after the 14:00 deploy');
await within(canvasElement).findByDisplayValue(/checkout p99 jump/);
},
};
/** The comment box a thumbs down opens, which a thumbs up does not. */
export const NegativeFeedback: Story = {
play: async ({ canvasElement }): Promise<void> => {
@@ -245,9 +267,11 @@ export const TooltipsInApprovalDiff: Story = {
};
/**
* The conversation count in the bottom strip, in place of the build version.
* Archived threads are left out of it.
* The recording controls' tooltips, held open: discard, stop and send, and the
* send button beside them, which waits for words.
*/
export const BottomStrip: Story = {
args: { bottomStrip: true },
export const TooltipsInVoiceRecording: Story = {
args: { tooltipsOpen: true, contents: BRIEF },
play: async ({ canvasElement }): Promise<void> =>
startVoiceInput(canvasElement),
};

View File

@@ -1,20 +0,0 @@
import { useMemo } from 'react';
import { useAIAssistantStore } from 'container/AIAssistant/store/useAIAssistantStore';
import { useBottomStrip } from 'container/BottomStrip/useBottomStrip';
import { type StripItem, StripItemKind } from 'container/BottomStrip/types';
import { pluralize } from 'utils/pluralize';
export function useAIAssistantStripInfo(): void {
const conversations = useAIAssistantStore((state) => state.conversations);
const count = Object.values(conversations).filter(
(conversation) => !conversation.archived,
).length;
const items = useMemo<StripItem[]>(
() => [{ kind: StripItemKind.Text, text: pluralize(count, 'conversation') }],
[count],
);
useBottomStrip(items);
}

View File

@@ -133,19 +133,3 @@ export const ColumnPicker: Story = {
export const Tooltips: Story = {
args: { tooltipsOpen: true },
};
/**
* The rule count in the bottom strip, in place of the build version: the rows on
* the page against the total, the same pair the table's own footer prints.
*/
export const BottomStrip: Story = {
args: { bottomStrip: true },
};
/** The same count on a second page, where the two numbers come apart. */
export const BottomStripPaginated: Story = {
args: { bottomStrip: true, rules: RULE_MAX },
parameters: {
signoz: { route: '/alerts?tab=AlertRules&page=2&limit=10' },
},
};

View File

@@ -79,30 +79,61 @@ export const NewDowntime: Story = {
},
};
const clickFirstRowAction = async (
canvasElement: HTMLElement,
action: 'edit' | 'delete',
): Promise<void> => {
const icon = (
await within(canvasElement).findByText(
FIRST_DOWNTIME_NAME,
undefined,
untilLoaded,
)
)
.closest('.header-content')
// The row action holds edit then delete, neither of them labelled.
?.querySelectorAll('.action-btn svg')[action === 'edit' ? 0 : 1];
if (!icon) {
throw new Error(`Downtime ${action} action did not render`);
}
await userEvent.click(icon);
};
/** The deletion confirmation opened from the first schedule's real row action. */
export const DeleteDowntimeConfirm: Story = {
play: async ({ canvasElement }): Promise<void> => {
const action = (
await within(canvasElement).findByText(
FIRST_DOWNTIME_NAME,
undefined,
untilLoaded,
)
)
.closest('.header-content')
// The row action holds edit then delete, neither of them labelled.
?.querySelectorAll('.action-btn svg')[1];
if (!action) {
throw new Error('Downtime delete action did not render');
}
await userEvent.click(action);
await clickFirstRowAction(canvasElement, 'delete');
// The modal titles itself and its confirm button the same.
await screen.findByRole('button', { name: 'Delete Schedule' });
},
};
/**
* The first schedule opened for editing: the specific rules it silences sit
* above the rule picker, each with its own remove button.
*/
export const EditDowntime: Story = {
play: async ({ canvasElement }): Promise<void> => {
await clickFirstRowAction(canvasElement, 'edit');
await screen.findByText(/edit planned downtime/i);
await screen.findByText(/^Postgres connections/);
},
};
/** The new-downtime form set to repeat weekly, which adds the days and duration. */
export const NewDowntimeRecurring: Story = {
play: async ({ canvasElement }): Promise<void> => {
await NewDowntime.play?.({ canvasElement } as never);
await userEvent.click(
await screen.findByRole('combobox', { name: 'Repeats every' }),
);
await userEvent.click(await screen.findByText('Weekly'));
await screen.findByText('Duration');
},
};
/** A client-side search with no matching downtime schedule. */
export const SearchNoResults: Story = {
play: async ({ canvasElement }): Promise<void> => {

View File

@@ -6,9 +6,19 @@
import { rest } from 'msw';
import set from 'api/browser/localstorage/set';
import { LOCALSTORAGE } from 'constants/localStorage';
import { screen, userEvent, waitFor, within } from 'storybook/test';
import { countControl, toggleControl } from '@/storybook/controls/controls';
import {
choiceControl,
countControl,
toggleControl,
} from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
import {
RESPONSE_STATES,
type ResponseState,
respondWith,
} from '@/storybook/runtime/responseState';
import { fieldValuesResponse } from '@/storybook/msw/__story_mockdata__/fields';
import {
@@ -23,6 +33,9 @@ import {
type ListErrorsBody,
} from './__story_mockdata__/exceptions';
/** The page fetches before it renders a row, which outlasts the 1s default. */
const untilLoaded = { timeout: 15_000 };
const LIST = 'Exceptions · list';
const FILTERS = 'Exceptions · filters';
@@ -42,6 +55,13 @@ export const exceptionsMocks = defineStoryMocks({
value: 6,
max: EXCEPTION_QUICK_FILTER_CAP,
}),
filterKeys: choiceControl<ResponseState>('Filter keys', {
group: FILTERS,
description:
'How `/autocomplete/attribute_keys` answers when the resource filter opens, apart from the page-wide Data control.',
options: RESPONSE_STATES,
value: 'loaded',
}),
filterPanel: toggleControl('Quick filters panel', {
group: FILTERS,
description:
@@ -92,7 +112,7 @@ export const exceptionsMocks = defineStoryMocks({
rest.get(
'http://localhost/api/v3/autocomplete/attribute_keys',
response.json((req) =>
respondWith(values.filterKeys, (req) =>
exceptionAttributeKeysResponse(req.url.searchParams.get('searchText')),
),
),
@@ -111,3 +131,61 @@ export const exceptionsMocks = defineStoryMocks({
set(LOCALSTORAGE.SHOW_EXCEPTIONS_QUICK_FILTERS, String(values.filterPanel));
},
});
export const openResourceFilter = async (
canvasElement: HTMLElement,
): Promise<HTMLElement> => {
const filter = await within(canvasElement).findByTestId(
'qb-search-select',
undefined,
untilLoaded,
);
await userEvent.click(within(filter).getByRole('combobox'));
return filter;
};
/**
* Clicks the visible row whose label is `text`. The dropdown renders in the
* body, a key row carries its type beside the label, and antd keeps a hidden
* copy of each label for screen readers that takes no clicks.
*
* The key list renders twice after the filter opens, since a second key
* request empties it until it answers. The click happens in the same task as
* the lookup: `fireEvent` from `storybook/test` dispatches a tick later, which
* can land on a row already removed and never reach React.
*/
export const pickSuggestion = async (text: string): Promise<void> => {
await waitFor(() => {
const match = Array.from(
document.querySelectorAll<HTMLElement>(
'.query-builder-search.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option',
),
).find((option) =>
Array.from(option.querySelectorAll('*')).some(
(node) => node.children.length === 0 && node.textContent === text,
),
);
if (!match) {
throw new Error(`suggestion "${text}" not found`);
}
// `userEvent.click` moves focus off the search input on the way, which closes
// the dropdown before the row takes the click.
match.click();
}, untilLoaded);
};
export const commitFilter = async (
key: string,
operator: string,
value: string,
): Promise<void> => {
await pickSuggestion(key);
await screen.findByText('Operator for', { exact: false }, untilLoaded);
await pickSuggestion(operator);
await screen.findByText('Value(s) for', { exact: false }, untilLoaded);
await pickSuggestion(value);
};

View File

@@ -5,7 +5,12 @@ import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { exceptionsMocks } from './AllErrors.stories.mocks';
import {
commitFilter,
exceptionsMocks,
openResourceFilter,
pickSuggestion,
} from './AllErrors.stories.mocks';
import AllErrors from '../index';
type AllErrorsArgs = PageStoryArgs<typeof exceptionsMocks>;
@@ -126,10 +131,107 @@ export const QuickFiltersSettingsWithBanner: Story = {
play: dirtyQuickFiltersSettings,
};
/**
* The exception count in the bottom strip, in place of the build version: the
* rows on the page against the total the count query returns.
*/
export const BottomStrip: Story = {
args: { bottomStrip: true },
/** The resource filter opened: every key the exceptions can be narrowed by. */
export const FilterKeySuggestions: Story = {
play: async ({ canvasElement }): Promise<void> => {
await openResourceFilter(canvasElement);
await screen.findByText('Suggested Filters', undefined, untilLoaded);
},
};
/** The key list grown past its first rows with the Show all shortcut. */
export const FilterAllKeys: Story = {
play: async ({ canvasElement }): Promise<void> => {
await openResourceFilter(canvasElement);
await screen.findByText('Show all filter items', undefined, untilLoaded);
await userEvent.keyboard('{Control>}/{/Control}');
await screen.findByText('cloud.region', undefined, untilLoaded);
},
};
/** A partial key: the typed text as a free search, then the keys that match. */
export const FilterPartialKey: Story = {
play: async ({ canvasElement }): Promise<void> => {
const filter = await openResourceFilter(canvasElement);
await userEvent.type(within(filter).getByRole('combobox'), 'serv');
await screen.findByText('service.namespace', undefined, untilLoaded);
},
};
/** A key picked: the operators the exceptions page allows for it. */
export const FilterOperatorSuggestions: Story = {
play: async ({ canvasElement }): Promise<void> => {
await openResourceFilter(canvasElement);
await pickSuggestion('service.name');
await screen.findByText('Operator for', { exact: false }, untilLoaded);
},
};
/** A key and an operator picked: the values the key holds. */
export const FilterValueSuggestions: Story = {
play: async ({ canvasElement }): Promise<void> => {
await openResourceFilter(canvasElement);
await pickSuggestion('service.name');
await pickSuggestion('=');
await screen.findByText('Value(s) for', { exact: false }, untilLoaded);
},
};
/** Two conditions committed as chips, with the dropdown closed again. */
export const FilterChips: Story = {
play: async ({ canvasElement }): Promise<void> => {
await openResourceFilter(canvasElement);
await commitFilter('service.name', '=', 'checkout');
await commitFilter('deployment.environment', '!=', 'staging');
await userEvent.click(canvasElement.ownerDocument.body);
await within(canvasElement).findByText(
'deployment.environment != staging',
undefined,
untilLoaded,
);
},
};
/**
* A committed chip clicked to change it: its text goes back into the input,
* with the dropdown shut until the input is typed into.
*/
export const FilterEditChip: Story = {
play: async ({ canvasElement }): Promise<void> => {
await openResourceFilter(canvasElement);
await commitFilter('service.name', '=', 'checkout');
await userEvent.click(
await within(canvasElement).findByText(
'service.name = checkout',
undefined,
untilLoaded,
),
);
// The select remounts whenever its chips change, so it is looked up again.
await waitFor(
() =>
expect(
within(within(canvasElement).getByTestId('qb-search-select')).getByRole(
'combobox',
),
).toHaveValue('service.name = checkout'),
untilLoaded,
);
},
};
/** The key list while its request is still in flight. */
export const FilterKeysLoading: Story = {
args: { filterKeys: 'loading' },
play: async ({ canvasElement }): Promise<void> => {
await openResourceFilter(canvasElement);
await waitFor(
() =>
expect(
document.querySelector('.query-builder-search .ant-spin'),
).not.toBeNull(),
untilLoaded,
);
},
};

View File

@@ -21,7 +21,8 @@ const pageStory = storyMocks(panelEditorMocks, { layout: 'app' });
* Route: `/dashboard/:dashboardId/panel/:panelId`.
*/
const meta = {
title: 'Pages/Dashboards/Panel Editor',
title: 'Pages/Dashboards/Panel Editor/Overview',
tags: ['authz'],
// The page is wrapped in `withAuthZPage`, which types its props as an index
// signature; the story's args are what the controls resolve to.
component: PanelEditorPage as ComponentType<PanelEditorArgs>,
@@ -49,6 +50,36 @@ export const NewPanel: Story = {
args: { panel: 'new' },
};
/** A number panel: one value, formatted, with comparison thresholds. */
export const NumberPanel: Story = {
args: { panel: 'p99-latency' },
};
/** A bar chart, whose Visualization section offers stacking. */
export const BarChartPanel: Story = {
args: { panel: 'errors-by-status' },
};
/** A pie chart, with a legend per slice and no axes. */
export const PieChartPanel: Story = {
args: { panel: 'traffic-share' },
};
/** A new area chart: the time series options with the area filled. */
export const AreaChartPanel: Story = {
args: { panel: 'new', newPanelKind: 'area-chart' },
};
/** A new histogram, where the display options are its buckets. */
export const HistogramPanel: Story = {
args: { panel: 'new', newPanelKind: 'histogram' },
};
/** A new text panel: a markdown body in place of the query builder. */
export const TextPanel: Story = {
args: { panel: 'new', newPanelKind: 'text' },
};
/** A list panel, where the config pane is the column editor. */
export const ListPanel: Story = {
args: { panel: 'recent-logs' },

View File

@@ -22,11 +22,13 @@ import type { PanelKind } from '../../../DashboardContainer/Panels/types/panelKi
export const NEW_PANEL_KINDS = [
'time-series',
'bar-chart',
'area-chart',
'number',
'pie-chart',
'table',
'histogram',
'list',
'text',
] as const;
export type NewPanelKind = (typeof NEW_PANEL_KINDS)[number];
@@ -34,11 +36,13 @@ export type NewPanelKind = (typeof NEW_PANEL_KINDS)[number];
const KIND_BY_OPTION: Record<NewPanelKind, PanelKind> = {
'time-series': 'signoz/TimeSeriesPanel',
'bar-chart': 'signoz/BarChartPanel',
'area-chart': 'signoz/AreaChartPanel',
number: 'signoz/NumberPanel',
'pie-chart': 'signoz/PieChartPanel',
table: 'signoz/TablePanel',
histogram: 'signoz/HistogramPanel',
list: 'signoz/ListPanel',
text: 'signoz/TextPanel',
};
export const newPanelKindOf = (option: NewPanelKind): PanelKind =>

View File

@@ -0,0 +1,190 @@
import type { ComponentType } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Route } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { expect, screen, userEvent, waitFor } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { panelEditorMocks } from '../PanelEditorPage.stories.mocks';
import {
expandSection,
openConfigSelect,
pickSegment,
} from './panelEditorPlay';
import PanelEditorPage from '../../PanelEditorPage';
type PanelEditorArgs = PageStoryArgs<typeof panelEditorMocks>;
const pageStory = storyMocks(panelEditorMocks, { layout: 'app' });
/**
* The panel editor's display options, one section open at a time: what each
* panel kind lets a user tune about how its data is drawn, and the preview
* following along.
*
* Route: `/dashboard/:dashboardId/panel/:panelId`.
*/
const meta = {
title: 'Pages/Dashboards/Panel Editor/Chart Options',
tags: ['authz', 'play'],
component: PanelEditorPage as ComponentType<PanelEditorArgs>,
render: (): JSX.Element => (
<Route path={ROUTES.DASHBOARD_PANEL_EDITOR} component={PanelEditorPage} />
),
...pageStory,
parameters: { ...pageStory.parameters },
} satisfies Meta<PanelEditorArgs>;
export default meta;
type Story = StoryObj<PanelEditorArgs>;
/** The panel type picker, open on the kinds this query can be drawn as. */
export const PanelTypeOpen: Story = {
play: async () => {
await openConfigSelect('panel-editor-v2-type-switcher');
await screen.findByRole('listbox');
},
};
/** The time range the panel reads, open on its choices. */
export const TimePreferenceOpen: Story = {
play: async () => {
await openConfigSelect('panel-editor-v2-time-preference');
await screen.findByRole('listbox');
},
};
/** Formatting & Units: the unit and decimals the values are shown with. */
export const Formatting: Story = {
play: async () => {
await expandSection('Formatting & Units');
await screen.findByTestId('panel-editor-v2-decimals');
},
};
/** The unit picker, open on its categories. */
export const UnitPickerOpen: Story = {
play: async (context) => {
await Formatting.play?.(context);
await openConfigSelect('panel-editor-v2-unit');
await screen.findByRole('listbox');
},
};
/** Axes: soft bounds for the y axis and its scale. */
export const Axes: Story = {
play: async () => {
await expandSection('Axes');
await screen.findByTestId('panel-editor-v2-log-scale');
},
};
/** The y axis on a log scale, which the preview redraws with. */
export const AxesLogScale: Story = {
play: async (context) => {
await Axes.play?.(context);
await pickSegment('panel-editor-v2-log-scale', 'Log');
},
};
/** Legend: its position, and a color per series the query returned. */
export const Legend: Story = {
play: async () => {
await expandSection('Legend');
await screen.findByTestId('panel-editor-v2-legend-position');
},
};
/** The legend moved to the right of the preview. */
export const LegendRight: Story = {
play: async (context) => {
await Legend.play?.(context);
await pickSegment('panel-editor-v2-legend-position', 'Right');
},
};
/** Chart appearance: line style, interpolation, fill and points. */
export const ChartAppearance: Story = {
play: async () => {
await expandSection('Chart appearance');
await screen.findByTestId('panel-editor-v2-line-style');
},
};
/** Dashed lines under a gradient fill, with the points drawn. */
export const ChartAppearanceDashedGradient: Story = {
play: async (context) => {
await ChartAppearance.play?.(context);
await pickSegment('panel-editor-v2-line-style', 'Dashed');
await pickSegment('panel-editor-v2-fill-mode', 'Gradient');
const points = screen.getByTestId('panel-editor-v2-show-points');
await userEvent.click(points);
await waitFor(() => expect(points).toHaveAttribute('aria-checked', 'true'));
},
};
/** An area chart's appearance, which adds the fill opacity. */
export const AreaChartAppearance: Story = {
args: { panel: 'new', newPanelKind: 'area-chart' },
play: async () => {
await expandSection('Chart appearance');
await screen.findByTestId('panel-editor-v2-fill-opacity');
},
};
/** A bar chart with its series stacked on each other. */
export const BarStacked: Story = {
args: { panel: 'errors-by-status' },
play: async () => {
await expandSection('Visualization');
const stack = await screen.findByTestId(
'panel-editor-v2-stacked-bar-chart',
{},
{ timeout: 10000 },
);
await userEvent.click(stack);
await waitFor(() => expect(stack).toHaveAttribute('aria-checked', 'true'));
},
};
/** A table's formatting: a unit per value column. */
export const TableColumnUnits: Story = {
args: { panel: 'top-endpoints' },
play: async () => {
await expandSection('Formatting & Units');
await screen.findByTestId('panel-editor-v2-decimals');
},
};
/** A pie chart's legend, colored per slice. */
export const PieLegend: Story = {
args: { panel: 'traffic-share' },
play: async () => {
await expandSection('Legend');
await screen.findByTestId('panel-editor-v2-legend-position');
},
};
/** A histogram's buckets: how many, how wide, and whether queries merge. */
export const HistogramBuckets: Story = {
args: { panel: 'new', newPanelKind: 'histogram' },
play: async () => {
await expandSection('Histogram / Buckets');
await screen.findByTestId('panel-editor-v2-bucket-count');
},
};
/** A text panel's appearance: alignment and background. */
export const TextAppearance: Story = {
args: { panel: 'new', newPanelKind: 'text' },
play: async () => {
await expandSection('Panel appearance');
await screen.findByTestId('text-layout-horizontal-align');
},
};

View File

@@ -0,0 +1,131 @@
import type { ComponentType } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Route } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { expect, screen, userEvent, waitFor } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { panelEditorMocks } from '../PanelEditorPage.stories.mocks';
import { addThreshold, openLinkDialog } from './panelEditorPlay';
import PanelEditorPage from '../../PanelEditorPage';
type PanelEditorArgs = PageStoryArgs<typeof panelEditorMocks>;
const pageStory = storyMocks(panelEditorMocks, { layout: 'app' });
/**
* The panel editor's Thresholds and Context Links sections. A threshold row
* takes the shape of its panel kind: a labelled line on a chart, a comparison
* on a number, a column rule on a table. A context link opens a dialog whose
* URL can carry the dashboard's variables.
*
* Route: `/dashboard/:dashboardId/panel/:panelId`.
*/
const meta = {
title: 'Pages/Dashboards/Panel Editor/Thresholds and Links',
tags: ['authz', 'play'],
component: PanelEditorPage as ComponentType<PanelEditorArgs>,
render: (): JSX.Element => (
<Route path={ROUTES.DASHBOARD_PANEL_EDITOR} component={PanelEditorPage} />
),
...pageStory,
parameters: { ...pageStory.parameters },
} satisfies Meta<PanelEditorArgs>;
export default meta;
type Story = StoryObj<PanelEditorArgs>;
/** A time series threshold: a value, a color and a label drawn on the chart. */
export const Threshold: Story = {
play: async () => {
await addThreshold();
await screen.findByTestId('threshold-value-0');
},
};
/** The threshold's color picker, open on the palette presets. */
export const ThresholdColorOpen: Story = {
play: async (context) => {
await Threshold.play?.(context);
await userEvent.click(screen.getByTestId('threshold-color-0'));
await screen.findByText('Defaults');
},
};
/** A number panel's threshold, which compares the value and recolors it. */
export const NumberThreshold: Story = {
args: { panel: 'p99-latency' },
play: async () => {
await addThreshold('panel-editor-v2-add-comparison-threshold');
await screen.findByTestId('comparison-threshold-operator-0');
},
};
/** A table's threshold, which picks the column it colors. */
export const TableThreshold: Story = {
args: { panel: 'top-endpoints' },
play: async () => {
await addThreshold('panel-editor-v2-add-table-threshold');
await screen.findByTestId('table-threshold-column-0');
},
};
/** The add-link dialog, empty, with Save waiting for a URL. */
export const ContextLinkDialog: Story = {
play: async () => {
await openLinkDialog();
},
};
/** The URL field focused, offering the variables it can interpolate. */
export const ContextLinkVariables: Story = {
play: async () => {
await openLinkDialog();
await userEvent.click(screen.getByTestId('context-link-url'));
await screen.findByTestId('context-link-variable-service');
},
};
/** A URL with query parameters, split into rows that can be edited one by one. */
export const ContextLinkParams: Story = {
play: async () => {
await openLinkDialog();
await userEvent.type(
screen.getByTestId('context-link-label'),
'Open service traces',
);
await userEvent.click(screen.getByTestId('context-link-url'));
await userEvent.paste(
'/traces-explorer?service={{service}}&env={{environment}}',
);
// Leaving the URL closes the variables list that covers the rows.
await userEvent.click(screen.getByTestId('context-link-label'));
await screen.findByTestId('context-link-param-key-1');
},
};
/** A URL the link cannot open, which the field flags and Save refuses. */
export const ContextLinkInvalidUrl: Story = {
play: async () => {
await openLinkDialog();
await userEvent.click(screen.getByTestId('context-link-url'));
await userEvent.paste('traces for {{service}}');
await screen.findByTestId('context-link-url-error');
},
};
/** A saved link, listed in the section with its edit and remove actions. */
export const ContextLinkSaved: Story = {
play: async (context) => {
await ContextLinkParams.play?.(context);
await userEvent.click(screen.getByTestId('context-link-save'));
await waitFor(() =>
expect(screen.queryByTestId('context-link-dialog')).toBeNull(),
);
await screen.findByTestId('context-link-item-0');
},
};

View File

@@ -0,0 +1,62 @@
import { screen, userEvent, within } from 'storybook/test';
// The editor resolves its panel from the dashboard document before it renders.
const PAGE_LOAD = { timeout: 10000 };
/**
* Opens a display-options section and scrolls it to the top of the pane, where
* a screenshot of the viewport can see it. Returns the section to query in.
*/
export const expandSection = async (title: string): Promise<HTMLElement> => {
// The toggle's test id is the title, lowercased, with whitespace as dashes.
const toggle = await screen.findByTestId(
`config-section-${title.toLowerCase().replace(/\s+/g, '-')}`,
{},
PAGE_LOAD,
);
if (toggle.getAttribute('aria-expanded') !== 'true') {
await userEvent.click(toggle);
}
toggle.scrollIntoView({ block: 'start' });
return toggle.closest('section') ?? document.body;
};
/** Picks one option of a segmented control by the label it shows. */
export const pickSegment = async (
testId: string,
label: string,
): Promise<void> => {
await userEvent.click(within(screen.getByTestId(testId)).getByText(label));
};
/**
* The pane's pickers are antd Selects: the element carrying the test id does
* nothing on click, the combobox inside it is what opens the list.
*/
export const openConfigSelect = async (testId: string): Promise<void> => {
const select = await screen.findByTestId(testId, {}, PAGE_LOAD);
await userEvent.click(within(select).getByRole('combobox'));
};
/**
* Opens Thresholds and adds one row through the section's own button, whose
* test id depends on the panel kind.
*/
export const addThreshold = async (
testId = 'panel-editor-v2-add-threshold',
): Promise<void> => {
const section = await expandSection('Thresholds');
await userEvent.click(within(section).getByTestId(testId));
};
export const openLinkDialog = async (): Promise<void> => {
const section = await expandSection('Context Links');
// The header's quick add carries the same name, so the body button goes by id.
await userEvent.click(within(section).getByTestId('panel-editor-v2-add-link'));
await screen.findByTestId('context-link-dialog');
};

View File

@@ -7,6 +7,7 @@ import { rest } from 'msw';
import { generatePath } from 'react-router-dom';
import ROUTES from 'constants/routes';
import type { GetPublicDashboard200 } from 'api/generated/services/sigNoz.schemas';
import { useDashboardPreferencesStore } from 'hooks/dashboard/useDashboardPreference';
import type { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
import {
@@ -15,11 +16,15 @@ import {
toggleControl,
} from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
import { fieldValuesResponse } from '@/storybook/msw/__story_mockdata__/fields';
import {
fieldKeysResponse,
fieldValuesResponse,
} from '@/storybook/msw/__story_mockdata__/fields';
import { queryRangeV5ScalarResponse } from '@/storybook/msw/__story_mockdata__/queryRange';
import {
currentDashboardDocument,
cyclicVariablesDashboardResponse,
patchDashboardDocument,
PANEL_IDS,
seedDashboardDocument,
@@ -29,8 +34,9 @@ import {
type VariableKind,
} from './__story_mockdata__/dashboard';
import {
attributeValues,
emptyPanelResponse,
NAMESPACE_VALUES,
VARIABLE_ATTRIBUTES,
panelResponse,
serviceVariableValues,
} from './__story_mockdata__/panelData';
@@ -65,6 +71,14 @@ const NOT_FOUND = {
const ok = { status: 'success', data: null };
// The editor rewrites any `Syntax error:` into its own hint, so the ClickHouse
// wording only shows through the variable bar.
const VARIABLE_QUERY_ERROR = {
status: 'error',
error:
"Code: 62. DB::Exception: Syntax error: failed at position 58 ('$environment'). (SYNTAX_ERROR)",
};
const publicMeta = (): GetPublicDashboard200 => ({
status: 'success',
data: {
@@ -137,6 +151,12 @@ export const dashboardMocks = defineStoryMocks({
value: 4,
max: 12,
}),
variableQueryFails: toggleControl('Variable query fails', {
group: DATA,
description:
"The query-backed `service` variable answers with a ClickHouse syntax error, which the variable bar and the editor's Test Run both report.",
value: false,
}),
noData: toggleControl('Panels return nothing', {
group: DATA,
description:
@@ -228,18 +248,35 @@ export const dashboardMocks = defineStoryMocks({
// The variable bar resolves before the panels and stays laid out while
// they load or fail, so its two endpoints answer on their own rather
// than through the Data control.
rest.post('http://localhost/api/v2/variables/query', (_req, res, ctx) =>
values.variableQueryFails
? rest.post('http://localhost/api/v2/variables/query', (_req, res, ctx) =>
res(ctx.status(400), ctx.json(VARIABLE_QUERY_ERROR)),
)
: rest.post('http://localhost/api/v2/variables/query', (_req, res, ctx) =>
res(
ctx.status(200),
ctx.json({
status: 'success',
data: {
variableValues: serviceVariableValues(values.variableValues),
},
}),
),
),
rest.get('http://localhost/api/v1/fields/values', (req, res, ctx) =>
res(
ctx.status(200),
ctx.json({
status: 'success',
data: { variableValues: serviceVariableValues(values.variableValues) },
}),
ctx.json(
fieldValuesResponse(attributeValues(req.url.searchParams.get('name'))),
),
),
),
rest.get('http://localhost/api/v1/fields/values', (_req, res, ctx) =>
res(ctx.status(200), ctx.json(fieldValuesResponse(NAMESPACE_VALUES))),
// The dynamic variable editor lists the attributes a variable can read.
rest.get(
'http://localhost/api/v1/fields/keys',
response.json(() => fieldKeysResponse(VARIABLE_ATTRIBUTES)),
),
// The header reads the public link on every load, so it answers even while
@@ -273,6 +310,9 @@ export const dashboardMocks = defineStoryMocks({
variables: values.variables,
locked: values.locked,
});
// The sync mode persists per dashboard, so one story's pick would open the
// next one on it.
useDashboardPreferencesStore.setState({ preferences: {} });
},
});
@@ -331,3 +371,9 @@ export const metricsListHandler = rest.get(
(_req, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: { metrics: [] } })),
);
export const cyclicVariablesDashboardHandler = rest.get(
'http://localhost/api/v2/dashboards/:id',
(_req, res, ctx) =>
res(ctx.status(200), ctx.json(cyclicVariablesDashboardResponse())),
);

View File

@@ -2,7 +2,7 @@ import type { ComponentType } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Route } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { screen, userEvent, within } from 'storybook/test';
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
@@ -30,8 +30,8 @@ const pageStory = storyMocks(dashboardMocks, { layout: 'app' });
* Route: `/dashboard/:dashboardId`.
*/
const meta = {
title: 'Pages/Dashboards/Detail',
tags: ['role-gated', 'play'],
title: 'Pages/Dashboards/Detail/Overview',
tags: ['authz', 'play'],
// The page is wrapped in `withAuthZPage`, which types its props as an index
// signature; the story's args are what the controls resolve to.
component: DashboardPage as ComponentType<DashboardArgs>,
@@ -159,53 +159,6 @@ export const TooltipsInJsonDrawer: Story = {
},
};
/**
* The Overview tab of dashboard settings, where Cross-Panel Sync explains what
* syncing the crosshair does and links out to the docs.
*/
export const TooltipsInSettings: Story = {
args: { tooltipsOpen: true },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
await canvas.findByTestId('show-drawer', {}, { timeout: 10000 }),
);
await screen.findByText('Sync Mode');
},
};
/**
* The Variables tab of dashboard settings, where a dynamic variable's Apply to
* all says whether it is already a filter on every panel. The row keeps its
* actions invisible until it is hovered, which the story does first.
*/
export const TooltipsInVariableSettings: Story = {
args: { tooltipsOpen: true },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(
await canvas.findByTestId('show-drawer', {}, { timeout: 10000 }),
);
await userEvent.click(await screen.findByRole('tab', { name: 'Variables' }));
// The tooltip trigger's Slot merge drops the button's own test id.
await userEvent.hover(
await screen.findByRole(
'button',
{ name: 'Apply to all' },
{ timeout: 10000 },
),
);
await screen.findByText(
'Add this variable as a filter to every panel',
undefined,
{ timeout: 10000 },
);
},
};
/**
* A panel expanded into view mode, whose header carries the full panel name its
* title truncates, over the dashboard's own tooltips behind the dialog.
@@ -285,6 +238,18 @@ export const SectionActionsMenu: Story = {
},
};
/** The panel menu's move-to-section submenu, open on the sections it can go to. */
export const PanelMoveToSectionSubmenu: Story = {
play: async (context) => {
await PanelActionsMenu.play?.(context);
await userEvent.hover(await screen.findByText('Move to section'));
// The submenu lists the sections the panel is not already in.
await waitFor(() => expect(screen.getAllByRole('menu')).toHaveLength(2), {
timeout: 10000,
});
},
};
/**
* A dashboard id nobody has, which is what a deleted or mistyped link opens on.
*

View File

@@ -336,6 +336,28 @@ const VARIABLES: Record<VariableKind, DashboardtypesVariableDTO> = {
},
};
/**
* `service` already filters on `$environment`, so an `environment` that reads
* `$service` back closes the loop. An imported dashboard can arrive like this;
* the editor only refuses it on save.
*/
const CYCLIC_ENVIRONMENT: DashboardtypesVariableDTO = {
kind: ListVariableKind.ListVariable,
spec: {
name: 'environment',
display: { name: 'environment' },
allowMultiple: false,
allowAllValue: false,
plugin: {
kind: QueryVariableKind['signoz/QueryVariable'],
spec: {
queryValue:
'SELECT DISTINCT env FROM signoz_metrics WHERE service_name IN $service',
},
},
},
};
export interface PanelQueryShape {
requestType: string;
metricName?: string;
@@ -451,3 +473,18 @@ export const patchDashboardDocument = (
return envelope(document);
};
export const cyclicVariablesDashboardResponse = (): GetDashboardV2200 => {
const response = dashboardResponse({
panels: PANEL_IDS.length,
sectioned: true,
variables: VARIABLE_KINDS,
locked: false,
});
response.data.spec.variables = response.data.spec.variables.map((variable) =>
variable === VARIABLES.custom ? CYCLIC_ENVIRONMENT : variable,
);
return response;
};

View File

@@ -173,3 +173,20 @@ export const NAMESPACE_VALUES = [
'payments-prod',
'platform-prod',
] as const;
/**
* The attributes the dynamic variable editor offers a variable to read, with
* the values each one resolves to.
*/
const ATTRIBUTE_VALUES: Record<string, readonly string[]> = {
'k8s.namespace.name': NAMESPACE_VALUES,
'k8s.cluster.name': ['prod-us-east-1', 'prod-eu-west-1'],
'service.name': SERVICES,
'deployment.environment': ['production', 'staging', 'development'],
'host.name': ['ip-10-0-1-12', 'ip-10-0-1-37', 'ip-10-0-2-4'],
};
export const VARIABLE_ATTRIBUTES = Object.keys(ATTRIBUTE_VALUES);
export const attributeValues = (name: string | null): readonly string[] =>
ATTRIBUTE_VALUES[name ?? ''] ?? NAMESPACE_VALUES;

View File

@@ -0,0 +1,206 @@
import type { ComponentType } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Route } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { dashboardMocks } from '../DashboardPage.stories.mocks';
import {
expectDisabled,
menuItem,
openActionsMenu,
toolbarButton,
} from './authzPlay';
import DashboardPage from '../../DashboardPage';
type DashboardArgs = PageStoryArgs<typeof dashboardMocks>;
const pageStory = storyMocks(dashboardMocks, { layout: 'app' });
/**
* The dashboard with one permission missing at a time. Every story here is an
* admin denied exactly what its name says. `read` decides whether the page
* renders at all; `update` is every edit, the settings drawer included, so
* Configure is as far as a user without it gets.
*
* Route: `/dashboard/:dashboardId`.
*/
const meta = {
title: 'Pages/Dashboards/Detail/Authz',
tags: ['authz', 'play'],
component: DashboardPage as ComponentType<DashboardArgs>,
render: (): JSX.Element => (
<Route path={ROUTES.DASHBOARD} component={DashboardPage} />
),
...pageStory,
parameters: { ...pageStory.parameters },
} satisfies Meta<DashboardArgs>;
export default meta;
type Story = StoryObj<DashboardArgs>;
const READ = 'read:dashboard';
const UPDATE = 'update:dashboard';
const DELETE = 'delete:dashboard';
const CREATE = 'create:dashboard';
/** The page refuses to render: `read` is the one check it mounts on. */
export const NoRead: Story = {
args: { revoked: [READ] },
play: async () => {
await screen.findByText('Uh-oh! You are not authorized', undefined, {
timeout: 10000,
});
},
};
/**
* The panels still render, and Configure and New Panel stay in the toolbar,
* disabled.
*/
export const NoUpdate: Story = {
args: { revoked: [UPDATE] },
play: async ({ canvasElement }) => {
await expectDisabled(await toolbarButton(canvasElement, 'Configure'));
await expectDisabled(await toolbarButton(canvasElement, 'New Panel'));
},
};
/** The same toolbar with its tooltips open, each naming what is missing. */
export const TooltipsWithoutUpdate: Story = {
args: { revoked: [UPDATE], tooltipsOpen: true },
play: NoUpdate.play,
};
/**
* A locked dashboard the user could not have edited anyway: the tooltips name
* the permission rather than the lock.
*/
export const TooltipsWithoutUpdateWhenLocked: Story = {
args: { revoked: [UPDATE], locked: true, tooltipsOpen: true },
play: NoUpdate.play,
};
/** The Actions menu: rename, lock and new section locked; clone and delete open. */
export const NoUpdateActionsMenu: Story = {
args: { revoked: [UPDATE] },
play: async ({ canvasElement }) => {
await openActionsMenu(canvasElement);
await expectDisabled(menuItem('Rename'));
await expectDisabled(menuItem('Lock dashboard'));
await expectDisabled(menuItem('New section'));
await expectDisabled(menuItem('Clone dashboard'), false);
await expectDisabled(menuItem('Delete dashboard'), false);
},
};
/** A panel's menu: view and download stay, every edit is locked. */
export const NoUpdatePanelMenu: Story = {
args: { revoked: [UPDATE] },
play: async ({ canvasElement }) => {
await userEvent.click(
await within(canvasElement).findByTestId(
'panel-actions-p99-latency',
{},
{ timeout: 10000 },
),
);
const menu = await screen.findByRole('menu');
await expectDisabled(
within(menu).getByRole('menuitem', { name: /Edit panel/ }),
);
await expectDisabled(
within(menu).getByRole('menuitem', { name: /Delete panel/ }),
);
},
};
/** The JSON editor opens read-only, and Apply says why it is locked. */
export const NoUpdateJsonEditor: Story = {
args: { revoked: [UPDATE], tooltipsOpen: true },
play: async ({ canvasElement }) => {
await userEvent.click(
await within(canvasElement).findByTestId(
'edit-json',
{},
{ timeout: 10000 },
),
);
await expectDisabled(await screen.findByTestId('json-editor-apply'));
},
};
/** A blank dashboard whose add-panel prompts are locked. */
export const NoUpdateEmpty: Story = {
args: { revoked: [UPDATE], panels: 0, sectioned: false },
play: async () => {
await expectDisabled(
await screen.findByTestId('add-panel', {}, { timeout: 10000 }),
);
},
};
/** Delete dashboard locked in the Actions menu; every edit still works. */
export const NoDelete: Story = {
args: { revoked: [DELETE] },
play: async ({ canvasElement }) => {
await openActionsMenu(canvasElement);
await expectDisabled(menuItem('Delete dashboard'));
await expectDisabled(menuItem('Rename'), false);
},
};
/** Cloning is a create, so the copy is locked while the original stays editable. */
export const NoCreate: Story = {
args: { revoked: [CREATE] },
play: async ({ canvasElement }) => {
await openActionsMenu(canvasElement);
await expectDisabled(menuItem('Clone dashboard'));
await expectDisabled(menuItem('Rename'), false);
},
};
/** Everything but reading: the Actions menu with only full screen left. */
export const ReadOnly: Story = {
args: { revoked: [UPDATE, DELETE, CREATE] },
play: async ({ canvasElement }) => {
await openActionsMenu(canvasElement);
await expectDisabled(menuItem('Clone dashboard'));
await expectDisabled(menuItem('Delete dashboard'));
await expectDisabled(menuItem('Full screen'), false);
},
};
/**
* The page holds on its spinner until the permission check answers. The
* spinner's `Loading dashboard...` tip never shows: antd only renders a tip
* around nested content.
*/
export const CheckLoading: Story = {
args: { authzState: 'loading' },
play: async ({ canvasElement }) => {
await waitFor(() =>
expect(canvasElement.querySelector('.ant-spin')).not.toBeNull(),
);
},
};
/**
* The permission check failing rather than denying: the page renders, and
* every edit stays locked as if `update` were denied.
*/
export const CheckFailed: Story = {
args: { authzState: 'error' },
// The mocked check intentionally fails; the resulting console error is the
// point of the story, not a regression.
parameters: { allowConsoleErrors: true },
play: async ({ canvasElement }) => {
await expectDisabled(await toolbarButton(canvasElement, 'Configure'));
},
};

View File

@@ -0,0 +1,38 @@
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
// The permission check resolves after the dashboard document loads.
const PAGE_LOAD = { timeout: 10000 };
/**
* A denied control stays on screen, disabled. Buttons say so through
* `disabled` or `aria-disabled` depending on the Button, menu rows through
* `data-disabled`.
*/
const isDisabled = (element: HTMLElement): boolean =>
element.hasAttribute('disabled') ||
element.getAttribute('aria-disabled') === 'true' ||
element.hasAttribute('data-disabled');
export const expectDisabled = (
element: HTMLElement,
disabled = true,
): Promise<void> =>
waitFor(() => expect(isDisabled(element)).toBe(disabled), PAGE_LOAD);
export const toolbarButton = (
canvasElement: HTMLElement,
name: string,
): Promise<HTMLElement> =>
within(canvasElement).findByRole('button', { name }, PAGE_LOAD);
export const openActionsMenu = async (
canvasElement: HTMLElement,
): Promise<void> => {
// The dropdown trigger's Slot merge drops the button's own test id.
await userEvent.click(await toolbarButton(canvasElement, 'Actions'));
await screen.findByText('Clone dashboard');
};
// Not by test id: the Actions menu only carries them on some Dropdown versions.
export const menuItem = (name: string): HTMLElement =>
screen.getByRole('menuitem', { name });

View File

@@ -0,0 +1,131 @@
import type { ComponentType } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Route } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { expect, screen, userEvent, waitFor } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import {
cyclicVariablesDashboardHandler,
dashboardMocks,
} from '../DashboardPage.stories.mocks';
import {
findPreviewValue,
openVariableEditor,
openVariableSelect,
} from './settingsPlay';
import DashboardPage from '../../DashboardPage';
type DashboardArgs = PageStoryArgs<typeof dashboardMocks>;
const pageStory = storyMocks(dashboardMocks, { layout: 'app' });
/**
* The variable editor on each of the dashboard's variables, reached from a
* row's edit action in the Variables tab: what every type saved, the values it
* resolves to, and the query failures and dependency loops it reports.
*
* Route: `/dashboard/:dashboardId`.
*/
const meta = {
title: 'Pages/Dashboards/Detail/Edit Variable',
tags: ['authz', 'play'],
component: DashboardPage as ComponentType<DashboardArgs>,
render: (): JSX.Element => (
<Route path={ROUTES.DASHBOARD} component={DashboardPage} />
),
...pageStory,
parameters: { ...pageStory.parameters },
} satisfies Meta<DashboardArgs>;
export default meta;
type Story = StoryObj<DashboardArgs>;
/**
* `$environment`: a comma-separated list with one value by default. The preview
* stays empty until the options are edited.
*/
export const Custom: Story = {
play: async ({ canvasElement }) => {
await openVariableEditor(canvasElement, 'environment');
await screen.findByDisplayValue('production,staging,development');
},
};
/**
* `$service`: a ClickHouse query that reads `$environment`, run once on open so
* the preview and default value have its values.
*/
export const Query: Story = {
play: async ({ canvasElement }) => {
await openVariableEditor(canvasElement, 'service');
await findPreviewValue('inventory');
},
};
/** `$service` with its query failing, which the preview reports in its place. */
export const QueryFailed: Story = {
args: { variableQueryFails: true },
// The failed variable query is the state under test.
parameters: { allowConsoleErrors: true },
play: async ({ canvasElement }) => {
await openVariableEditor(canvasElement, 'service');
await screen.findByText(
'Please make sure query is valid and dependent variables are selected',
undefined,
{ timeout: 10000 },
);
},
};
/** The sort picker, open over the previewed values it reorders. */
export const SortOpen: Story = {
play: async (context) => {
await Query.play?.(context);
await openVariableSelect('variable-sort-select');
await screen.findByTitle('Alphabetical (descending)');
},
};
/**
* `$namespace`: a dynamic variable reading `k8s.namespace.name` from metrics,
* with the panels it could be applied to.
*/
export const Dynamic: Story = {
play: async ({ canvasElement }) => {
await openVariableEditor(canvasElement, 'namespace');
await findPreviewValue('payments-prod');
},
};
/** `$owner`: a Textbox variable with its default value. */
export const Textbox: Story = {
play: async ({ canvasElement }) => {
await openVariableEditor(canvasElement, 'owner');
await waitFor(() =>
expect(screen.getByTestId('variable-text-input')).toHaveValue(
'platform-team',
),
);
},
};
/**
* `$environment` and `$service` reading each other, as an imported dashboard
* can arrive: saving either one is refused and names the loop.
*
* The document is answered by the story, so the Panels, Sections, Variables and
* Locked controls do not reach it.
*/
export const CircularDependency: Story = {
parameters: { msw: { handlers: [cyclicVariablesDashboardHandler] } },
play: async ({ canvasElement }) => {
await openVariableEditor(canvasElement, 'service');
await userEvent.click(screen.getByTestId('variable-save'));
await screen.findByText(/circular dependency detected/);
},
};

View File

@@ -0,0 +1,184 @@
import type { ComponentType } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Route } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { screen, userEvent } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { dashboardMocks } from '../DashboardPage.stories.mocks';
import {
findPreviewValue,
openNewVariable,
openVariableSelect,
pickVariableType,
typeVariableName,
} from './settingsPlay';
import DashboardPage from '../../DashboardPage';
type DashboardArgs = PageStoryArgs<typeof dashboardMocks>;
const pageStory = storyMocks(dashboardMocks, { layout: 'app' });
/**
* The variable editor on a new variable, reached from Add variable in the
* Variables tab: each type's own fields, the pickers they open, and the name
* and attribute checks that keep Save disabled.
*
* Route: `/dashboard/:dashboardId`.
*/
const meta = {
title: 'Pages/Dashboards/Detail/New Variable',
tags: ['authz', 'play'],
component: DashboardPage as ComponentType<DashboardArgs>,
render: (): JSX.Element => (
<Route path={ROUTES.DASHBOARD} component={DashboardPage} />
),
...pageStory,
parameters: { ...pageStory.parameters },
} satisfies Meta<DashboardArgs>;
export default meta;
type Story = StoryObj<DashboardArgs>;
/** The editor as it opens, on the Dynamic type with no field picked. */
export const Default: Story = {
play: async ({ canvasElement }) => {
await openNewVariable(canvasElement);
},
};
/** The dynamic variable's field picker, open on the attributes it can read. */
export const FieldPickerOpen: Story = {
play: async ({ canvasElement }) => {
await openNewVariable(canvasElement);
await openVariableSelect('variable-field-select');
await screen.findByRole(
'option',
{ name: 'k8s.cluster.name' },
{ timeout: 10000 },
);
},
};
/** The telemetry source the field is read from, open. */
export const SignalPickerOpen: Story = {
play: async ({ canvasElement }) => {
await openNewVariable(canvasElement);
await openVariableSelect('variable-signal-select');
await screen.findByTitle('Traces');
},
};
/**
* A field picked: the name follows it until typed over, and the values it
* resolves to fill the preview and the default value.
*/
export const DynamicFieldPicked: Story = {
play: async (context) => {
await FieldPickerOpen.play?.(context);
await userEvent.click(
screen.getByRole('option', { name: 'k8s.cluster.name' }),
);
await findPreviewValue('prod-us-east-1');
},
};
/** A field another dynamic variable already reads, which Save refuses. */
export const AttributeTaken: Story = {
play: async (context) => {
await FieldPickerOpen.play?.(context);
await userEvent.click(
screen.getByRole('option', { name: 'k8s.namespace.name' }),
);
await screen.findByText('A variable with this attribute key already exists');
},
};
/** The panels the new filter can be added to, open. */
export const ApplyToPanelsOpen: Story = {
play: async ({ canvasElement }) => {
await openNewVariable(canvasElement);
await openVariableSelect('variable-apply-panels');
await screen.findByRole('listbox');
},
};
/** A name typed and cleared again. */
export const NameRequired: Story = {
play: async ({ canvasElement }) => {
await openNewVariable(canvasElement);
await userEvent.type(screen.getByTestId('variable-name'), 'x{Backspace}');
await screen.findByText('Variable name is required');
},
};
/** A name another variable on the dashboard has. */
export const NameTaken: Story = {
play: async ({ canvasElement }) => {
await openNewVariable(canvasElement);
await typeVariableName('environment');
await screen.findByText('Variable name already exists');
},
};
/** A name with a space in it, which a `$name` reference could not spell. */
export const NameWithSpaces: Story = {
play: async ({ canvasElement }) => {
await openNewVariable(canvasElement);
await typeVariableName('build id');
await screen.findByText('Variable name cannot contain whitespaces');
},
};
/** A Textbox variable: a free-text value with an optional default. */
export const Textbox: Story = {
play: async ({ canvasElement }) => {
await openNewVariable(canvasElement);
await typeVariableName('region');
await pickVariableType('textbox');
await userEvent.type(
await screen.findByTestId('variable-text-input'),
'us-east-1',
);
},
};
/**
* A Custom variable with its options typed in, previewed as they are parsed,
* and multiple values on, which adds the ALL option toggle.
*/
export const Custom: Story = {
play: async ({ canvasElement }) => {
await openNewVariable(canvasElement);
await typeVariableName('region');
await pickVariableType('custom');
await userEvent.type(
await screen.findByTestId('variable-custom-input'),
'us-east-1,eu-west-1,ap-south-1',
);
await userEvent.click(screen.getByTestId('variable-multi-switch'));
await screen.findByText('Include an option for ALL values');
},
};
/** The Custom variable's default value picker, open on the parsed options. */
export const CustomDefaultOpen: Story = {
play: async (context) => {
await Custom.play?.(context);
await openVariableSelect('variable-default-select');
await screen.findByRole('option', { name: 'eu-west-1' });
},
};
/** A Query variable before its query is written: Test Run waits for one. */
export const Query: Story = {
play: async ({ canvasElement }) => {
await openNewVariable(canvasElement);
await pickVariableType('query');
await screen.findByText('Test Run Query');
},
};

View File

@@ -0,0 +1,121 @@
import type { ComponentType } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Route } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { screen, userEvent, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { dashboardMocks } from '../DashboardPage.stories.mocks';
import { openSettings } from './settingsPlay';
import DashboardPage from '../../DashboardPage';
type DashboardArgs = PageStoryArgs<typeof dashboardMocks>;
const pageStory = storyMocks(dashboardMocks, { layout: 'app' });
/**
* The dashboard's settings drawer on its Overview and Publish tabs: the name,
* icon, description and tags, the cross-panel sync mode, and the public link.
* The Publish tab only exists on cloud and enterprise licenses.
*
* Route: `/dashboard/:dashboardId`.
*/
const meta = {
title: 'Pages/Dashboards/Detail/Settings',
tags: ['authz', 'play'],
component: DashboardPage as ComponentType<DashboardArgs>,
render: (): JSX.Element => (
<Route path={ROUTES.DASHBOARD} component={DashboardPage} />
),
...pageStory,
parameters: { ...pageStory.parameters },
} satisfies Meta<DashboardArgs>;
export default meta;
type Story = StoryObj<DashboardArgs>;
/** The drawer as Configure opens it, on the Overview tab. */
export const Overview: Story = {
play: async ({ canvasElement }) => {
await openSettings(canvasElement);
await screen.findByText('Sync Mode');
},
};
/** Two fields edited, so the footer counts them and offers to save or discard. */
export const OverviewUnsavedChanges: Story = {
play: async ({ canvasElement }) => {
await openSettings(canvasElement);
await userEvent.type(await screen.findByTestId('dashboard-name'), ' (v2)');
await userEvent.type(
screen.getByTestId('dashboard-desc'),
' Owned by the platform team.',
);
await screen.findByText('2 unsaved changes');
},
};
/** The icon picker beside the name, open on the system icons. */
export const OverviewIconPicker: Story = {
play: async ({ canvasElement }) => {
const panel = await openSettings(canvasElement);
await userEvent.click(within(panel).getAllByRole('combobox')[0]);
await screen.findByRole('listbox');
},
};
/**
* Sync set to Tooltip, which adds the choice between every series and only the
* ones that share the group-by.
*/
export const OverviewTooltipSync: Story = {
play: async ({ canvasElement }) => {
await openSettings(canvasElement);
await userEvent.click(await screen.findByRole('radio', { name: 'Tooltip' }));
await screen.findByText('Synced Tooltip Series');
},
};
/**
* The Overview tab's own tooltip: Cross-Panel Sync explains what syncing does
* and links out to the docs.
*/
export const TooltipsInOverview: Story = {
args: { tooltipsOpen: true },
play: Overview.play,
};
/** The Publish tab of a dashboard with a public link. */
export const Publish: Story = {
play: async ({ canvasElement }) => {
await openSettings(canvasElement, 'Publish');
await screen.findByText('Default time range');
},
};
/** The Publish tab with its default time range select open. */
export const PublishTimeRangeOpen: Story = {
play: async (context) => {
await Publish.play?.(context);
await userEvent.click(
within(screen.getByRole('tabpanel')).getByRole('combobox'),
);
await screen.findByRole('listbox');
},
};
/** The Publish tab of a dashboard nobody published: no link, one Publish button. */
export const PublishPrivate: Story = {
args: { published: false },
// The 404 an unpublished dashboard answers with is the state under test.
parameters: { allowConsoleErrors: true },
play: async ({ canvasElement }) => {
await openSettings(canvasElement, 'Publish');
await screen.findByText('This dashboard is private');
},
};

View File

@@ -0,0 +1,132 @@
import type { ComponentType } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { Route } from 'react-router-dom';
import ROUTES from 'constants/routes';
import { screen, userEvent } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { dashboardMocks } from '../DashboardPage.stories.mocks';
import {
clickRowAction,
openSettings,
openVariableEditor,
typeVariableName,
variableRow,
} from './settingsPlay';
import DashboardPage from '../../DashboardPage';
type DashboardArgs = PageStoryArgs<typeof dashboardMocks>;
const pageStory = storyMocks(dashboardMocks, { layout: 'app' });
/**
* The Variables tab of dashboard settings: the variables in the order the bar
* shows them, and what deleting, renaming or applying one does to the panels
* that read it. Every panel filters on `$environment`, and `$service` reads it
* too; nothing reads `$namespace` or `$owner`.
*
* Route: `/dashboard/:dashboardId`.
*/
const meta = {
title: 'Pages/Dashboards/Detail/Variables',
tags: ['authz', 'play'],
component: DashboardPage as ComponentType<DashboardArgs>,
render: (): JSX.Element => (
<Route path={ROUTES.DASHBOARD} component={DashboardPage} />
),
...pageStory,
parameters: { ...pageStory.parameters },
} satisfies Meta<DashboardArgs>;
export default meta;
type Story = StoryObj<DashboardArgs>;
/** One variable of each type, listed by name and description. */
export const Default: Story = {
play: async ({ canvasElement }) => {
await openSettings(canvasElement, 'Variables');
await variableRow('owner');
},
};
/** A dashboard without variables, where the tab is its add-variable prompt. */
export const Empty: Story = {
args: { variables: [] },
play: async ({ canvasElement }) => {
await openSettings(canvasElement, 'Variables');
await screen.findByText('No variables yet');
},
};
/**
* A dynamic variable's Apply to all says whether it is already a filter on
* every panel. The row keeps its actions invisible until it is hovered, which
* the story does first.
*/
export const TooltipsInVariables: Story = {
args: { tooltipsOpen: true },
play: async ({ canvasElement }) => {
await openSettings(canvasElement, 'Variables');
// The tooltip trigger's Slot merge drops the button's own test id.
await userEvent.hover(
await screen.findByRole(
'button',
{ name: 'Apply to all' },
{ timeout: 10000 },
),
);
await screen.findByText(
'Add this variable as a filter to every panel',
undefined,
{ timeout: 10000 },
);
},
};
/** Deleting a variable nothing reads, which the row confirms inline. */
export const DeleteConfirm: Story = {
play: async ({ canvasElement }) => {
await openSettings(canvasElement, 'Variables');
await clickRowAction('owner', 'delete');
await screen.findByText('Delete?');
},
};
/**
* Deleting `$environment`, which the panels and `$service` read, so each usage
* is listed for review before anything is removed.
*/
export const DeleteReferenced: Story = {
play: async ({ canvasElement }) => {
await openSettings(canvasElement, 'Variables');
await clickRowAction('environment', 'delete');
await screen.findByText('Delete $environment');
},
};
/** Apply to all on `$namespace`: the filter it would add to every panel query. */
export const ApplyToAll: Story = {
play: async ({ canvasElement }) => {
await openSettings(canvasElement, 'Variables');
await clickRowAction('namespace', 'apply-all');
await screen.findByText('Apply $namespace to panels');
},
};
/**
* Renaming `$environment` to `$env`: saving the editor holds the rename until
* the rewritten queries are reviewed.
*/
export const RenameReferenced: Story = {
play: async ({ canvasElement }) => {
await openVariableEditor(canvasElement, 'environment');
await typeVariableName('env');
await userEvent.click(screen.getByTestId('variable-save'));
await screen.findByText('Rename $environment');
},
};

View File

@@ -0,0 +1,110 @@
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
// The dashboard document loads before the toolbar renders.
const PAGE_LOAD = { timeout: 10000 };
export type SettingsTab = 'Overview' | 'Variables' | 'Publish';
export type VariableType = 'dynamic' | 'textbox' | 'custom' | 'query';
type RowAction = 'edit' | 'delete' | 'apply-all';
/** Opens the settings drawer from the toolbar and returns the tab's panel. */
export const openSettings = async (
canvasElement: HTMLElement,
tab: SettingsTab = 'Overview',
): Promise<HTMLElement> => {
await userEvent.click(
await within(canvasElement).findByTestId('show-drawer', {}, PAGE_LOAD),
);
if (tab !== 'Overview') {
await userEvent.click(await screen.findByRole('tab', { name: tab }));
}
return screen.findByRole('tabpanel');
};
export const variableRow = (name: string): Promise<HTMLElement> =>
screen.findByTestId(`variable-row-${name}`, {}, PAGE_LOAD);
/** Row actions render once the edit permission resolves. */
export const clickRowAction = async (
name: string,
action: RowAction,
): Promise<void> => {
const row = await variableRow(name);
await userEvent.click(
action === 'apply-all'
? // The tooltip trigger's Slot merge drops this button's test id.
await within(row).findByRole('button', { name: 'Apply to all' }, PAGE_LOAD)
: await within(row).findByTestId(
`variable-${action}-${name}`,
{},
PAGE_LOAD,
),
);
};
/** A new variable's editor, which opens on the Dynamic type. */
export const openNewVariable = async (
canvasElement: HTMLElement,
): Promise<void> => {
const panel = await openSettings(canvasElement, 'Variables');
const add = await within(panel).findByTestId('add-variable', {}, PAGE_LOAD);
// Disabled until its permission check resolves: through `disabled` or
// `aria-disabled`, depending on the Button.
await waitFor(() => expect(add).toBeEnabled(), PAGE_LOAD);
await waitFor(
() => expect(add).not.toHaveAttribute('aria-disabled', 'true'),
PAGE_LOAD,
);
await userEvent.click(add);
await screen.findByText('Variable Type');
};
export const openVariableEditor = async (
canvasElement: HTMLElement,
name: string,
): Promise<void> => {
await openSettings(canvasElement, 'Variables');
await clickRowAction(name, 'edit');
await screen.findByText('Variable Type');
};
/**
* A value in the editor's preview. The toolbar's selector for the same variable
* resolves to its first value too, whenever its own query returns.
*/
export const findPreviewValue = async (value: string): Promise<HTMLElement> => {
const label = await screen.findByText('Preview of Values');
return within(label.parentElement as HTMLElement).findByText(
value,
undefined,
PAGE_LOAD,
);
};
export const pickVariableType = async (type: VariableType): Promise<void> => {
await userEvent.click(await screen.findByTestId(`variable-type-${type}`));
};
export const typeVariableName = async (name: string): Promise<void> => {
const input = await screen.findByTestId('variable-name');
await userEvent.clear(input);
await userEvent.type(input, name);
};
/**
* The editor's pickers are antd Selects: the element carrying the test id does
* nothing on click, the combobox inside it is what opens the list.
*/
export const openVariableSelect = async (testId: string): Promise<void> => {
const select = await screen.findByTestId(testId);
await userEvent.click(within(select).getByRole('combobox'));
};

View File

@@ -6,6 +6,7 @@
import { rest } from 'msw';
import type { GetDashboardV2200 } from 'api/generated/services/sigNoz.schemas';
import ROUTES from 'constants/routes';
import { screen, userEvent, waitFor, within } from 'storybook/test';
import {
choiceControl,
@@ -342,3 +343,30 @@ export const dashboardsListMocks = defineStoryMocks({
});
},
});
/** Opens the actions menu of the row at `index`. */
export const openRowActions = async (
canvasElement: HTMLElement,
index: number,
): Promise<void> => {
// The icon-only trigger carries no accessible name.
const triggers = await within(canvasElement).findAllByTestId(
'dashboard-action-icon',
{},
{ timeout: 10000 },
);
await userEvent.click(triggers[index]);
await screen.findByText('Rename');
};
/** Picks a row action, retrying while its permission check still disables it. */
export const pickRowAction = async (label: string | RegExp): Promise<void> => {
await waitFor(
async () => {
await userEvent.click(screen.getByText(label));
await screen.findByRole('dialog', {}, { timeout: 500 });
},
{ timeout: 10000 },
);
};

View File

@@ -6,7 +6,9 @@ import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import {
dashboardsListMocks,
openRowActions,
overflowingRows,
pickRowAction,
} from './DashboardsListPage.stories.mocks';
import { BuiltinViewId } from '../types';
@@ -114,6 +116,45 @@ export const Tooltips: Story = {
parameters: { msw: { handlers: [overflowingRows] } },
};
/** The first row's actions menu, open over the list. */
export const RowActionsMenu: Story = {
play: async ({ canvasElement }) => {
await openRowActions(canvasElement, 0);
},
};
/** The rename dialog, opened from the menu of the second row (the first is locked). */
export const RenameDashboardDialog: Story = {
play: async ({ canvasElement }) => {
await openRowActions(canvasElement, 1);
await pickRowAction('Rename');
await screen.findByRole('dialog', { name: 'Rename dashboard' });
},
};
/** The tags dialog, opened from the menu of the second row (the first is locked). */
export const EditTagsDialog: Story = {
play: async ({ canvasElement }) => {
await openRowActions(canvasElement, 1);
await pickRowAction(/^(Edit|Add) Tags$/);
await screen.findByRole('dialog', { name: /^(Edit|Add) tags$/ });
},
};
/** The popover that names the current filters as a new saved view. */
export const SaveViewPopover: Story = {
play: async ({ canvasElement }) => {
await userEvent.click(
await within(canvasElement).findByRole(
'button',
{ name: 'Save current filters as a view' },
{ timeout: 10000 },
),
);
await screen.findByText('Save as view');
},
};
/**
* The query the backend refused: the parse error it returned replaces the
* generic failure copy, and there is nothing to retry.

View File

@@ -1,4 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
@@ -19,6 +20,7 @@ const pageStory = storyMocks(editRulesMocks, { layout: 'app' });
*/
const meta = {
title: 'Pages/Alerts/Edit',
tags: ['play'],
component: EditRules,
...pageStory,
parameters: { ...pageStory.parameters },
@@ -53,3 +55,112 @@ export const RuleNotFound: Story = {
export const Tooltips: Story = {
args: { tooltipsOpen: true, previewSeries: 6 },
};
/** The page fetches the rule before it renders the form, which outlasts the 1s default. */
const untilLoaded = { timeout: 15_000 };
const LABEL_INPUT = 'alert-labels-input-v1';
/** The labels field, scrolled into view once the rule has loaded. */
const findLabelInput = async (
canvasElement: HTMLElement,
): Promise<HTMLElement> => {
const input = await within(canvasElement).findByTestId(
LABEL_INPUT,
undefined,
untilLoaded,
);
input.scrollIntoView({ block: 'center' });
return input;
};
const removeLabel = async (
canvasElement: HTMLElement,
label: string,
): Promise<void> => {
const chip = within(canvasElement)
.getByText(label)
.closest<HTMLElement>('[data-slot="badge"]');
if (!chip) {
throw new Error(`Label ${label} did not render`);
}
await userEvent.click(within(chip).getByRole('button'));
};
/** The rule's own labels, each with its remove button, above the empty input. */
export const Labels: Story = {
play: async ({ canvasElement }): Promise<void> => {
await findLabelInput(canvasElement);
const canvas = within(canvasElement);
await canvas.findByText('team: platform');
await canvas.findByText('env: prod');
},
};
/** A key typed and confirmed: it waits as its own chip while the input asks for its value. */
export const LabelKeyEntered: Story = {
play: async ({ canvasElement }): Promise<void> => {
const input = await findLabelInput(canvasElement);
await userEvent.type(input, 'region{enter}');
await within(canvasElement).findByPlaceholderText(
'Enter a value for label key(region) then press ENTER.',
);
},
};
/** A key and its value confirmed, added after the rule's own labels. */
export const LabelAdded: Story = {
play: async ({ canvasElement }): Promise<void> => {
const input = await findLabelInput(canvasElement);
await userEvent.type(input, 'region{enter}');
await userEvent.type(input, 'us-east-1{enter}');
await within(canvasElement).findByText('region: us-east-1');
},
};
/** One of the rule's labels removed, the other left in place. */
export const LabelRemoved: Story = {
play: async ({ canvasElement }): Promise<void> => {
await findLabelInput(canvasElement);
await within(canvasElement).findByText('team: platform');
await removeLabel(canvasElement, 'team: platform');
await waitFor(() =>
expect(within(canvasElement).queryByText('team: platform')).toBeNull(),
);
await expect(within(canvasElement).getByText('env: prod')).toBeVisible();
},
};
/** Every label removed: the input is back to its prompt and the clear-all button is gone. */
export const NoLabels: Story = {
play: async ({ canvasElement }): Promise<void> => {
const input = await findLabelInput(canvasElement);
await within(canvasElement).findByText('team: platform');
await removeLabel(canvasElement, 'team: platform');
await removeLabel(canvasElement, 'env: prod');
await within(canvasElement).findByPlaceholderText(
'Click here to enter a label (key value pairs)',
);
await expect(input.parentElement?.querySelector('button')).toBeNull();
},
};
/** The confirmation the clear-all button opens before it drops every label. */
export const ClearLabelsConfirm: Story = {
play: async ({ canvasElement }): Promise<void> => {
const input = await findLabelInput(canvasElement);
await within(canvasElement).findByText('team: platform');
const clearAll = input.parentElement?.querySelector('button');
if (!clearAll) {
throw new Error('Clear-all labels button did not render');
}
await userEvent.click(clearAll);
await screen.findByText(
'This action will remove all the labels. Do you want to proceed?',
);
},
};

View File

@@ -97,11 +97,3 @@ export const NavSettingsMenu: Story = {
await screen.findByRole('menu');
},
};
/**
* The firing alert count in the bottom strip, in place of the build version. It
* counts firing instances, so it does not match the alert rules widget above.
*/
export const BottomStrip: Story = {
args: { bottomStrip: true },
};

View File

@@ -1,4 +1,9 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import {
findSuggestion,
openKeySuggestions,
typeFilter,
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/stories/__story_mockdata__/querySearch.play';
import { screen, userEvent, within } from 'storybook/test';
import { VIEWS } from 'container/InfraMonitoringK8sV2/constants';
@@ -41,6 +46,21 @@ export const PodDetailsEvents: StoryObj<PodsArgs> = {
args: { drawer: true, drawerTab: VIEWS.EVENTS },
};
/** The selected pod's details drawer, switched to its logs tab. */
export const DetailsDrawerLogsTab: StoryObj<PodsArgs> = {
args: { drawer: true },
play: async () => {
const drawer = within(
await screen.findByRole('dialog', {}, { timeout: 10000 }),
);
await userEvent.click(
await drawer.findByText('Logs', {}, { timeout: 10000 }),
);
await drawer.findAllByText(/handled request in/, {}, { timeout: 10000 });
},
};
/**
* Every tooltip the pod list carries, held open: Collapse Filters beside the
* quick filters, Options above the table, the Pod Name, Status, Age and Restarts
@@ -86,3 +106,27 @@ export const TooltipsInOptionsPanel: StoryObj<PodsArgs> = {
await screen.findByText('Columns');
},
};
/**
* The pod list re-renders the filter when the viewport grows, and each render
* reconfigures the editor, which closes its suggestions. Shot at the height
* the page opened at.
*/
const heldViewport = { sbshot: { viewport: { height: 1200 } } };
/** The pod filter focused: the Kubernetes keys pods can be narrowed by. */
export const FilterKeySuggestions: StoryObj<PodsArgs> = {
parameters: heldViewport,
play: async ({ canvasElement }): Promise<void> => {
await openKeySuggestions(canvasElement, 'k8s.node.name');
},
};
/** The pod filter on a namespace: the namespaces the pods run in. */
export const FilterValueSuggestions: StoryObj<PodsArgs> = {
parameters: heldViewport,
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(canvasElement, 'k8s.namespace.name = ');
await findSuggestion(canvasElement, 'kube-system');
},
};

View File

@@ -69,3 +69,12 @@ export const GroupActionsMenu: Story = {
await screen.findByRole('menu');
},
};
/** The first mapping group's edit drawer, opened from its menu. */
export const GroupFormDrawer: Story = {
play: async (context): Promise<void> => {
await GroupActionsMenu.play?.(context);
await userEvent.click(await screen.findByText('Edit'));
await screen.findByText('Edit group');
},
};

View File

@@ -54,3 +54,24 @@ export const ModelCostActionsMenu: Story = {
await screen.findByRole('menu');
},
};
/** The first pricing rule's drawer, opened from its row menu. */
export const ModelCostDrawer: Story = {
play: async (context): Promise<void> => {
await ModelCostActionsMenu.play?.(context);
await userEvent.click(await screen.findByText('Edit'));
await screen.findByText('Edit model cost');
},
};
/** The drawer with its cache mode select open. */
export const ModelCostDrawerCacheModeOpen: Story = {
play: async (context): Promise<void> => {
await ModelCostDrawer.play?.(context);
await userEvent.click(
await screen.findByRole('combobox', { name: 'Cache mode' }),
);
await screen.findByRole('listbox');
},
};

View File

@@ -25,6 +25,11 @@ import {
toggleControl,
} from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
import {
RESPONSE_STATES,
type ResponseState,
respondWith,
} from '@/storybook/runtime/responseState';
import {
logsSavedViewsResponse,
@@ -44,6 +49,8 @@ import {
logRowsResponse,
QUICK_FILTER_MAX,
logsQuickFiltersResponse,
RECENT_FILTER_MAX,
recentFiltersStorage,
RELATIVE_TIME,
timeRangeState,
} from './__story_mockdata__/logs';
@@ -158,6 +165,20 @@ export const logsMocks = defineStoryMocks({
value: QUICK_FILTER_MAX,
max: QUICK_FILTER_MAX,
}),
filterValues: choiceControl<ResponseState>('Filter values', {
group: FILTERS,
description:
'How `/fields/values` answers once a key and an operator are typed in the filter, apart from the page-wide Data control.',
options: RESPONSE_STATES,
value: 'loaded',
}),
recentFilters: countControl('Recent filters', {
group: FILTERS,
description:
'Filters run before in this browser, which the filter offers above its key suggestions.',
value: 0,
max: RECENT_FILTER_MAX,
}),
savedViews: countControl('Saved views', {
group: FILTERS,
description: 'The views the view picker above the query builder lists.',
@@ -217,7 +238,7 @@ export const logsMocks = defineStoryMocks({
rest.get(
'http://localhost/api/v1/fields/values',
response.json((req) =>
respondWith(values.filterValues, (req) =>
logFieldValuesResponse(
req.url.searchParams.get('name') ?? '',
req.url.searchParams.get('searchText') ?? '',
@@ -264,8 +285,16 @@ export const logsMocks = defineStoryMocks({
}
: {},
}),
effect: ({ frequencyChart, filtersPanel, format, maxLines, fontSize }) => {
effect: ({
frequencyChart,
filtersPanel,
format,
maxLines,
fontSize,
recentFilters,
}) => {
setLocalStorage(LOCALSTORAGE.SHOW_FREQUENCY_CHART, String(frequencyChart));
setLocalStorage(...recentFiltersStorage(recentFilters));
setLocalStorage(LOCALSTORAGE.SHOW_LOGS_QUICK_FILTERS, String(filtersPanel));
// The preferences loader reads localStorage ahead of the URL, so this is

View File

@@ -1,4 +1,12 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import {
blurFilter,
openKeySuggestions,
showFilterErrors,
typeFilter,
typeFilterWithCaretBack,
findSuggestion,
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/stories/__story_mockdata__/querySearch.play';
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
@@ -209,3 +217,138 @@ export const Tooltips: Story = {
);
},
};
/** The filter focused before anything is typed: every key the logs carry. */
export const FilterKeySuggestions: Story = {
play: async ({ canvasElement }): Promise<void> => {
await openKeySuggestions(canvasElement, 'severity_text');
},
};
/** A partial key, with the keys that still match and the typed part marked. */
export const FilterPartialKey: Story = {
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(canvasElement, 'serv');
await findSuggestion(canvasElement, 'service.name');
},
};
/** A string key followed by a space: the operators a string compares with. */
export const FilterOperatorSuggestions: Story = {
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(canvasElement, 'service.name ');
await findSuggestion(canvasElement, 'CONTAINS');
},
};
/** A number key puts the range comparisons first. */
export const FilterNumberOperatorSuggestions: Story = {
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(canvasElement, 'http.status_code ');
await findSuggestion(canvasElement, 'BETWEEN');
},
};
/** `NOT` after a key narrows the list to the operators it can negate. */
export const FilterNegatedOperatorSuggestions: Story = {
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(canvasElement, 'service.name NOT ');
await findSuggestion(canvasElement, 'IN');
},
};
/** A key and an operator: the values the key holds, fetched for it. */
export const FilterValueSuggestions: Story = {
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(canvasElement, 'service.name = ');
await findSuggestion(canvasElement, 'checkout');
},
};
/** Values still being fetched for the key. */
export const FilterValuesLoading: Story = {
args: { filterValues: 'loading' },
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(canvasElement, 'service.name = ');
await findSuggestion(canvasElement, 'Loading suggestions');
},
};
/** A key the backend holds no values for, such as the free-text body. */
export const FilterNoValueSuggestions: Story = {
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(canvasElement, 'body = ');
await findSuggestion(canvasElement, 'No suggestions available');
},
};
/** The values request failed. */
export const FilterValuesError: Story = {
args: { filterValues: 'error' },
// The values request deliberately fails.
parameters: { allowConsoleErrors: true },
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(canvasElement, 'service.name = ');
await findSuggestion(canvasElement, 'Error loading suggestions');
},
};
/** Inside an `IN` list, after the first value: the rest of the values. */
export const FilterInList: Story = {
// The editor logs a TypeError while the list is open, which it survives.
parameters: { allowConsoleErrors: true },
play: async ({ canvasElement }): Promise<void> => {
await typeFilterWithCaretBack(
canvasElement,
"service.name IN ['auth', ]",
1,
'checkout',
);
},
};
/** A complete condition: the conjunctions that start the next one. */
export const FilterConjunctionSuggestions: Story = {
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(canvasElement, "service.name = 'checkout' ");
await findSuggestion(canvasElement, 'OR');
},
};
/** Inside an opened group: keys, another group and `NOT`. */
export const FilterNestedGroup: Story = {
play: async ({ canvasElement }): Promise<void> => {
await typeFilterWithCaretBack(canvasElement, '()', 1, 'NOT');
},
};
/** A long valid expression mixing operators, left for the next run. */
export const FilterComplete: Story = {
// The editor logs a TypeError while the `IN` list is typed, which it survives.
parameters: { allowConsoleErrors: true },
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(
canvasElement,
"service.name IN ['checkout', 'payments'] AND severity_text = 'ERROR' AND http.status_code >= 500 AND body CONTAINS 'timeout'",
);
await blurFilter(canvasElement);
},
};
/** An incomplete expression after focus left: the marker and its errors. */
export const FilterSyntaxError: Story = {
play: async ({ canvasElement }): Promise<void> => {
await showFilterErrors(canvasElement, 'service.name = ');
},
};
/** Filters run before, offered above the key suggestions. */
export const FilterRecentSearches: Story = {
args: { recentFilters: 3 },
play: async ({ canvasElement }): Promise<void> => {
await openKeySuggestions(
canvasElement,
"k8s.namespace.name = 'observability'",
);
},
};

View File

@@ -15,6 +15,9 @@ import {
} from 'api/generated/services/sigNoz.schemas';
import { defaultLogsSelectedColumns } from 'container/OptionsMenu/constants';
import type { OptionsQuery } from 'container/OptionsMenu/types';
import { STORAGE_VERSION } from 'lib/recentQueries/constants';
import type { RecentQueriesStoreShape } from 'lib/recentQueries/types';
import { makeId, storageKeyFor } from 'lib/recentQueries/utils';
import type { Time } from 'container/TopNav/DateTimeSelectionV2/types';
import { quickFiltersListResponse } from 'mocks-server/__mockdata__/customQuickFilters';
import type { AppState } from 'store/reducers';
@@ -470,3 +473,34 @@ const DASHBOARD_NAMES = [
export const dashboardsResponse = (): ListDashboardsForUserV2200 =>
dashboardsForUserResponse(DASHBOARD_NAMES);
const RECENT_FILTERS = [
"service.name = 'checkout' AND severity_text = 'ERROR'",
"k8s.namespace.name = 'observability'",
"body CONTAINS 'timeout'",
'http.status_code >= 500',
"deployment.environment IN ['production', 'staging']",
];
/** The filter lists this many recent entries at most. */
export const RECENT_FILTER_MAX = RECENT_FILTERS.length;
/**
* The explorer's recent filters as the store keeps them in localStorage,
* newest first, five minutes apart. Dated off `new Date()`, which the story
* clock freezes, so the "5 minutes ago" labels hold still.
*/
export const recentFiltersStorage = (count: number): [string, string] => {
const store: RecentQueriesStoreShape = {
version: STORAGE_VERSION,
entries: RECENT_FILTERS.slice(0, count).map((expression, index) => ({
id: makeId('logs', '', expression),
signal: 'logs',
source: '',
filter: { expression },
lastUsedAt: new Date().getTime() - (index + 1) * 5 * 60_000,
})),
};
return [storageKeyFor('logs', ''), JSON.stringify(store)];
};

View File

@@ -1,4 +1,9 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import {
findSuggestion,
openKeySuggestions,
typeFilter,
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/stories/__story_mockdata__/querySearch.play';
import { screen, userEvent } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
@@ -114,3 +119,18 @@ export const MetricDetailsDashboardsMenu: Story = {
await screen.findByRole('menu');
},
};
/** The summary's metric search focused: the attributes metrics can be found by. */
export const FilterKeySuggestions: Story = {
play: async ({ canvasElement }): Promise<void> => {
await openKeySuggestions(canvasElement, 'k8s.cluster.name');
},
};
/** The metric search on an attribute and an operator: the values it holds. */
export const FilterValueSuggestions: Story = {
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(canvasElement, 'service.name = ');
await findSuggestion(canvasElement, 'checkout');
},
};

View File

@@ -61,13 +61,3 @@ export const Pagination: Story = {
export const OverTrialLimit: Story = {
args: { traffic: 'over-trial-limit', banner: 'trial-expiry' },
};
/** The service count in the bottom strip, in place of the build version. */
export const BottomStrip: Story = {
args: { bottomStrip: true },
};
/** The same count on the span metrics table, which is the page's other path. */
export const BottomStripSpanMetrics: Story = {
args: { bottomStrip: true, mode: 'span-metrics' },
};

View File

@@ -15,10 +15,14 @@ import {
toggleControl,
} from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
import type { MockResolver } from '@/storybook/msw/types';
import {
CREATE_OUTCOMES,
type CreateOutcome,
EXPIRIES,
type Expiry,
ingestionKeyCreateError,
ingestionKeysResponse,
KEYS_PER_PAGE,
legacyIngestionResponse,
@@ -29,6 +33,11 @@ import {
const KEYS = 'Ingestion · keys';
const LIMITS = 'Ingestion · limits';
const rejectCreate: MockResolver = (_req, res, ctx) =>
res(ctx.status(409), ctx.json(ingestionKeyCreateError()));
const holdCreate: MockResolver = (_req, res, ctx) => res(ctx.delay('infinite'));
export const ingestionMocks = defineStoryMocks({
controls: {
gateway: toggleControl('Gateway', {
@@ -50,6 +59,13 @@ export const ingestionMocks = defineStoryMocks({
options: EXPIRIES,
value: 'none',
}),
create: choiceControl<CreateOutcome>('Creating a key', {
group: KEYS,
description:
'What the create behind the new key form answers. `hangs` holds the submit button in its loading state.',
options: CREATE_OUTCOMES,
value: 'succeeds',
}),
limits: multiChoiceControl<LimitSignal>('Signals with a limit', {
group: LIMITS,
description:
@@ -85,10 +101,14 @@ export const ingestionMocks = defineStoryMocks({
rest.post(
'http://localhost/api/v2/gateway/ingestion_keys',
response.json(() => ({
status: 'success',
data: { id: 'ingestion-key-new', value: 'sk_new' },
})),
{
succeeds: response.json(() => ({
status: 'success',
data: { id: 'ingestion-key-new', value: 'sk_new' },
})),
fails: rejectCreate,
hangs: holdCreate,
}[values.create],
),
rest.patch(

View File

@@ -1,5 +1,6 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { screen, userEvent, within } from 'storybook/test';
import dayjs from 'dayjs';
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
@@ -76,16 +77,149 @@ export const KeyLimits: Story = {
},
};
async function openCreateKey(
canvasElement: HTMLElement,
): Promise<ReturnType<typeof within>> {
await userEvent.click(
await within(canvasElement).findByText(
'New Ingestion key',
undefined,
untilLoaded,
),
);
return within(
await screen.findByRole(
'dialog',
{ name: 'Create new ingestion key' },
untilLoaded,
),
);
}
async function addTag(
dialog: ReturnType<typeof within>,
tag: string,
): Promise<void> {
await userEvent.click(await dialog.findByRole('button', { name: /New Tag/ }));
await userEvent.keyboard(`${tag}{Enter}`);
}
async function submitCreateKey(
dialog: ReturnType<typeof within>,
): Promise<void> {
await userEvent.type(dialog.getByLabelText('Name'), 'otel-collectors');
await userEvent.click(dialog.getByLabelText('Expiration'));
await userEvent.click(
await screen.findByTitle(dayjs().add(1, 'day').format('YYYY-MM-DD')),
);
await userEvent.click(
dialog.getByRole('button', { name: 'Create new Ingestion key' }),
);
}
/** The form a new key is named and dated in. */
export const CreateKey: Story = {
play: async ({ canvasElement }): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByText(
'New Ingestion key',
undefined,
untilLoaded,
),
);
await screen.findByText('Create new ingestion key', undefined, untilLoaded);
await openCreateKey(canvasElement);
},
};
/** A tag typed on the new key and not confirmed yet. */
export const CreateKeyAddingTag: Story = {
play: async ({ canvasElement }): Promise<void> => {
const dialog = await openCreateKey(canvasElement);
await userEvent.click(dialog.getByRole('button', { name: /New Tag/ }));
await userEvent.keyboard('team-payments');
},
};
/** The new key with its tags confirmed, each one removable. */
export const CreateKeyTagsAdded: Story = {
play: async ({ canvasElement }): Promise<void> => {
const dialog = await openCreateKey(canvasElement);
for (const tag of ['team-payments', 'env:production']) {
await addTag(dialog, tag);
await dialog.findByText(tag);
}
},
};
/**
* A tag typed again while the key already has it: Enter keeps the input open
* and adds nothing, with no message saying why.
*/
export const CreateKeyDuplicateTag: Story = {
play: async ({ canvasElement }): Promise<void> => {
const dialog = await openCreateKey(canvasElement);
await addTag(dialog, 'team-payments');
await addTag(dialog, 'team-payments');
await dialog.findByDisplayValue('team-payments');
},
};
/** A tag longer than its pill, cut short with an ellipsis. */
export const CreateKeyLongTag: Story = {
play: async ({ canvasElement }): Promise<void> => {
const dialog = await openCreateKey(canvasElement);
const tag =
'team-payments-platform-observability-production-us-east-1-canary-collectors';
await addTag(dialog, tag);
await dialog.findByText(tag);
},
};
/** A name with a space and no expiration, submitted: each field names its rule. */
export const CreateKeyInvalid: Story = {
// The page logs the rejected validation through `console.error`.
parameters: { allowConsoleErrors: true },
play: async ({ canvasElement }): Promise<void> => {
const dialog = await openCreateKey(canvasElement);
await userEvent.type(dialog.getByLabelText('Name'), 'otel collectors');
await userEvent.click(
dialog.getByRole('button', { name: 'Create new Ingestion key' }),
);
await dialog.findByText(/should only contain letters/);
},
};
/** The expiration calendar, where today and every day before it are disabled. */
export const CreateKeyExpirationOpen: Story = {
play: async ({ canvasElement }): Promise<void> => {
const dialog = await openCreateKey(canvasElement);
await userEvent.click(dialog.getByLabelText('Expiration'));
await screen.findByTitle(dayjs().format('YYYY-MM-DD'));
},
};
/** The new key sent, with the create still in flight. */
export const CreateKeySubmitting: Story = {
args: { create: 'hangs' },
play: async ({ canvasElement }): Promise<void> => {
const dialog = await openCreateKey(canvasElement);
await submitCreateKey(dialog);
await waitFor(() =>
expect(
dialog.getByRole('button', { name: 'Create new Ingestion key' }),
).toBeDisabled(),
);
},
};
/**
* A create the gateway rejects: the notification names the conflict and the
* form keeps what was typed.
*/
export const CreateKeyFailed: Story = {
args: { create: 'fails' },
// The deliberate 409 is the state under test.
parameters: { allowConsoleErrors: true },
play: async ({ canvasElement }): Promise<void> => {
const dialog = await openCreateKey(canvasElement);
await submitCreateKey(dialog);
await screen.findByText(
'An ingestion key with this name already exists.',
undefined,
untilLoaded,
);
},
};

View File

@@ -7,6 +7,7 @@ import type {
GatewaytypesIngestionKeyDTO,
GatewaytypesLimitDTO,
GetIngestionKeys200,
RenderErrorResponseDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { IngestionInfo } from 'types/api/settings/ingestion';
@@ -21,6 +22,10 @@ export const EXPIRIES = ['none', 'soon', 'expired'] as const;
export type Expiry = (typeof EXPIRIES)[number];
export const CREATE_OUTCOMES = ['succeeds', 'fails', 'hangs'] as const;
export type CreateOutcome = (typeof CREATE_OUTCOMES)[number];
const KEY_NAMES = [
'production-us-east',
'production-eu-west',
@@ -117,6 +122,18 @@ export const ingestionKeysResponse = (
},
});
export const ingestionKeyCreateError = (): RenderErrorResponseDTO => ({
status: 'error',
error: {
code: 'already_exists',
type: 'already_exists',
message: 'An ingestion key with this name already exists.',
url: '',
errors: [],
suggestions: [],
},
});
/**
* The pre-gateway endpoint, which answers with one record and no limits: the tab
* falls back to it when the gateway feature is off.

View File

@@ -5,6 +5,7 @@
import ROUTES from 'constants/routes';
import { rest } from 'msw';
import { userEvent, within } from 'storybook/test';
import { RoleType } from 'types/roles';
import { choiceControl } from '@/storybook/controls/controls';
@@ -84,3 +85,18 @@ export const roleEditorMocks = defineStoryMocks({
],
config: (values) => ({ route: routeFor(values.mode, values.editor) }),
});
/** Opens the Logs card and returns it. */
export const openLogsCard = async (
canvasElement: HTMLElement,
): Promise<HTMLElement> => {
const header = await within(canvasElement).findByRole(
'button',
{ name: /^Logs:/ },
{ timeout: 15_000 },
);
await userEvent.click(header);
return header.parentElement as HTMLElement;
};

View File

@@ -1,10 +1,10 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { userEvent, within } from 'storybook/test';
import { screen, userEvent, within } from 'storybook/test';
import { storyMocks } from '@/storybook/controls/defineStoryMocks';
import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import { roleEditorMocks } from './RoleEditor.stories.mocks';
import { openLogsCard, roleEditorMocks } from './RoleEditor.stories.mocks';
import SettingsPage from '../../../Settings';
@@ -75,3 +75,27 @@ export const Tooltips: Story = {
export const TooltipsInJsonEditor: Story = {
args: { tooltipsOpen: true, mode: 'edit', editor: 'json' },
};
/** The Logs card's first verb granted over everything instead of nothing. */
export const PermissionEditorScope: Story = {
play: async ({ canvasElement }): Promise<void> => {
const card = await openLogsCard(canvasElement);
const [all] = await within(card).findAllByText('All');
await userEvent.click(all);
},
};
/** The selector wizard, opened from a Logs verb scoped to named objects. */
export const TelemetrySelectorWizard: Story = {
play: async ({ canvasElement }): Promise<void> => {
const card = await openLogsCard(canvasElement);
const [onlySelected] = await within(card).findAllByText('Only selected');
await userEvent.click(onlySelected);
await userEvent.click(
await within(card).findByRole('button', { name: 'Wizard' }),
);
await screen.findByRole('dialog', { name: 'Selector Wizard' });
},
};

View File

@@ -190,3 +190,31 @@ export const TraceOptionsMenu: Story = {
await screen.findByText('Preview fields');
},
};
/** The span panel moved from the right edge to the bottom through its dock toggle. */
export const DockModeSwitched: Story = {
play: async ({ canvasElement }): Promise<void> => {
// The dock options are icon-only and carry no accessible name.
const option = await within(canvasElement).findByTestId(
'dock-mode-docked',
undefined,
untilLoaded,
);
await userEvent.click(option.querySelector('button') ?? option);
},
};
/** The span's percentile badge expanded into the distribution it was ranked in. */
export const SpanPercentileOpen: Story = {
play: async ({ canvasElement }): Promise<void> => {
await userEvent.click(
await within(canvasElement).findByRole(
'button',
{ name: /^p\d+/ },
untilLoaded,
),
);
await screen.findByText(/This span duration is/);
},
};

View File

@@ -1,4 +1,10 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import {
findSuggestion,
openKeySuggestions,
showFilterErrors,
typeFilter,
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/stories/__story_mockdata__/querySearch.play';
import { ExplorerViews } from 'pages/LogsExplorer/utils';
import { expect, screen, userEvent, waitFor } from 'storybook/test';
@@ -142,3 +148,41 @@ export const QuickFiltersSettingsWithBanner: Story = {
args: { banner: 'trial-expiry' },
play: dirtyQuickFiltersSettings,
};
/** The filter focused before anything is typed: span and resource keys together. */
export const FilterKeySuggestions: Story = {
play: async ({ canvasElement }): Promise<void> => {
await openKeySuggestions(canvasElement, 'status_code_string');
},
};
/** A context prefix: only the keys that live on the resource. */
export const FilterResourceKeys: Story = {
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(canvasElement, 'resource.');
await findSuggestion(canvasElement, 'resource.service.name');
},
};
/** A key and an operator: the services the spans came from. */
export const FilterValueSuggestions: Story = {
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(canvasElement, 'service.name = ');
await findSuggestion(canvasElement, 'checkout');
},
};
/** A boolean key offers its two values. */
export const FilterBooleanValues: Story = {
play: async ({ canvasElement }): Promise<void> => {
await typeFilter(canvasElement, 'has_error = ');
await findSuggestion(canvasElement, 'false');
},
};
/** A dangling conjunction after focus left: the marker and its errors. */
export const FilterSyntaxError: Story = {
play: async ({ canvasElement }): Promise<void> => {
await showFilterErrors(canvasElement, 'has_error = true AND');
},
};

View File

@@ -116,6 +116,10 @@ Storybook fills the seams with:
because elapsed-time code reads it — `lodash.debounce` compares two readings
to decide its trailing call is due, and a frozen one leaves every debounced
input in the app filtering nothing. `?storyClock=live` opts out.
- A speech recognizer in place of the browser's, also in
`.storybook/preview-head.html`, because Storybook has no microphone. It
starts listening on `start()` and hears only what a `play` passes to
`webkitSpeechRecognition.listening.hear(text)`.
- Theme from the toolbar (dark/light). `applyThemeBodyClass` puts `<body>` in the
state the app gets from `index.html` plus `AppLayout`: `data-theme="default"`
(every `@signozhq/design-tokens` semantic token is scoped to it, and without it

View File

@@ -1,6 +1,5 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import ROUTES from 'constants/routes';
import { toast } from '@signozhq/ui/sonner';
import { screen, userEvent, waitFor } from 'storybook/test';
import type { GlobalMockArgs } from '../globals';
@@ -109,24 +108,3 @@ export const WithoutStripAddCardModal: Story = {
});
},
};
/** Raises a toast and waits for it, so the shot is taken with it on screen. */
const raiseToast: NonNullable<Story['play']> = async () => {
toast.success('Service account deleted');
await screen.findByText('Service account deleted', undefined, untilLoaded);
};
/**
* A toast with the strip on. Sonner pins itself to the foot of the viewport, so
* without an offset it lands on top of Ask Noz and Support.
*/
export const WithToast: Story = {
args: { noz: true, support: 'pylon' },
play: raiseToast,
};
/** The same toast with the flag off, back at sonner's own inset from the viewport. */
export const WithoutStripToast: Story = {
args: { bottomStrip: false, noz: true, support: 'pylon' },
play: raiseToast,
};

View File

@@ -9,7 +9,6 @@ var (
FeaturePutMetersInZeus = featuretypes.MustNewName("put_meters_in_zeus")
FeatureUseMeterReporter = featuretypes.MustNewName("use_meter_reporter")
FeatureUseJSONBody = featuretypes.MustNewName("use_json_body")
FeatureJSONBodyDualIngestion = featuretypes.MustNewName("json_body_dual_ingestion")
FeatureEnableMetricsReduction = featuretypes.MustNewName("enable_metrics_reduction")
FeatureResolveSemconvFamilies = featuretypes.MustNewName("resolve_semconv_families")
FeatureUseTraceAttributesJSON = featuretypes.MustNewName("use_trace_attributes_json")
@@ -65,14 +64,6 @@ func MustNewRegistry() featuretypes.Registry {
DefaultVariant: featuretypes.MustNewName("disabled"),
Variants: featuretypes.NewBooleanVariants(),
},
&featuretypes.Feature{
Name: FeatureJSONBodyDualIngestion,
Kind: featuretypes.KindBoolean,
Stage: featuretypes.StageExperimental,
Description: "Controls whether the collector's normalize operator keeps the original log body so it is ingested into both the legacy body and the JSON body columns",
DefaultVariant: featuretypes.MustNewName("disabled"),
Variants: featuretypes.NewBooleanVariants(),
},
&featuretypes.Feature{
Name: FeatureEnableMetricsReduction,
Kind: featuretypes.KindBoolean,
@@ -85,7 +76,7 @@ func MustNewRegistry() featuretypes.Registry {
Name: FeatureResolveSemconvFamilies,
Kind: featuretypes.KindBoolean,
Stage: featuretypes.StageExperimental,
Description: "Controls whether trace, log, and metric queries resolve a semantic-convention name to all the spellings of its family",
Description: "Controls whether trace queries resolve a semantic-convention name to all the spellings of its family",
DefaultVariant: featuretypes.MustNewName("disabled"),
Variants: featuretypes.NewBooleanVariants(),
},

View File

@@ -435,7 +435,6 @@ func (m *module) buildFilterClause(ctx context.Context, orgID valuer.UUID, filte
whereClauseSelectors[idx].SelectorMatchType = telemetrytypes.FieldSelectorMatchTypeExact
}
whereClauseSelectors = querybuilder.ExpandKeySelectorsForFamilies(ctx, orgID, m.fl, whereClauseSelectors)
keys, _, err := m.telemetryMetadataStore.GetKeysMulti(ctx, orgID, whereClauseSelectors)
if err != nil {
return nil, err

View File

@@ -936,7 +936,6 @@ func (m *module) buildFilterClause(ctx context.Context, orgID valuer.UUID, filte
// whereClauseSelectors[idx].Source = query.Source
}
whereClauseSelectors = querybuilder.ExpandKeySelectorsForFamilies(ctx, orgID, m.fl, whereClauseSelectors)
keys, _, err := m.telemetryMetadataStore.GetKeysMulti(ctx, orgID, whereClauseSelectors)
if err != nil {
return nil, err

View File

@@ -1,102 +0,0 @@
package querier
import (
"context"
"testing"
"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/types/metrictypes"
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/assert"
"github.com/stretchr/testify/require"
)
// The metric metadata of a query on one name of a metric-name family comes
// from every name of the family: the temporality is Multiple when the names
// differ, and the reduced flag is set when any name has reduced data.
func TestResolveMetricMetadataReadsTheFamily(t *testing.T) {
testCases := []struct {
name string
temporalities map[string]metrictypes.Temporality
reduced map[string]bool
expectedTemporality metrictypes.Temporality
expectedReduced bool
}{
{
name: "SameTemporality_KeepsIt",
temporalities: map[string]metrictypes.Temporality{
"k8s.pod.cpu.usage": metrictypes.Cumulative,
"k8s.pod.cpu.utilization": metrictypes.Cumulative,
},
expectedTemporality: metrictypes.Cumulative,
},
{
name: "DifferentTemporalities_ReadAsMultiple",
temporalities: map[string]metrictypes.Temporality{
"k8s.pod.cpu.usage": metrictypes.Delta,
"k8s.pod.cpu.utilization": metrictypes.Cumulative,
},
expectedTemporality: metrictypes.Multiple,
},
{
name: "OnlyOldNameKnown_TakesItsTemporality",
temporalities: map[string]metrictypes.Temporality{
"k8s.pod.cpu.utilization": metrictypes.Delta,
},
expectedTemporality: metrictypes.Delta,
},
{
name: "OldNameReduced_MarksTheAggregationReduced",
temporalities: map[string]metrictypes.Temporality{
"k8s.pod.cpu.usage": metrictypes.Cumulative,
},
reduced: map[string]bool{"k8s.pod.cpu.utilization": true},
expectedTemporality: metrictypes.Cumulative,
expectedReduced: true,
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
metadataStore := telemetrytypestest.NewMockMetadataStore()
metadataStore.TemporalityMap = testCase.temporalities
metadataStore.TypeMap = map[string]metrictypes.Type{
"k8s.pod.cpu.usage": metrictypes.GaugeType,
"k8s.pod.cpu.utilization": metrictypes.GaugeType,
}
metadataStore.ReducedMap = testCase.reduced
q := &querier{
logger: instrumentationtest.New().Logger(),
metadataStore: metadataStore,
fl: flaggertest.WithBooleanFlags(t, map[string]bool{
flagger.FeatureResolveSemconvFamilies.String(): true,
}),
}
queries := []qbtypes.QueryEnvelope{{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "A",
Signal: telemetrytypes.SignalMetrics,
Aggregations: []qbtypes.MetricAggregation{{
MetricName: "k8s.pod.cpu.usage",
TimeAggregation: metrictypes.TimeAggregationAvg,
SpaceAggregation: metrictypes.SpaceAggregationAvg,
}},
},
}}
missing, warnings, err := q.resolveMetricMetadata(context.Background(), valuer.UUID{}, queries, 0, 0, qbtypes.RequestTypeTimeSeries)
require.NoError(t, err)
assert.Empty(t, missing)
assert.Empty(t, warnings)
spec := queries[0].Spec.(qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation])
assert.Equal(t, testCase.expectedTemporality, spec.Aggregations[0].Temporality)
assert.Equal(t, testCase.expectedReduced, spec.Aggregations[0].Reduced)
})
}
}

View File

@@ -369,8 +369,6 @@ func (q *querier) populateQBEvent(event *qbtypes.QBEvent, queries []qbtypes.Quer
// resolved: never-seen metrics and dormant metrics (seen but no data in
// the query window).
// - err: Internal when a metadata fetch fails.
//
// Metric metadata resolves through every name of a metric-name family.
func (q *querier) resolveMetricMetadata(ctx context.Context, orgID valuer.UUID, queries []qbtypes.QueryEnvelope, start, end uint64, requestType qbtypes.RequestType) (missingMetricQueries []string, metricWarnings []string, err error) {
metricNames := make([]string, 0)
for idx := range queries {
@@ -383,7 +381,7 @@ func (q *querier) resolveMetricMetadata(ctx context.Context, orgID valuer.UUID,
}
for _, agg := range spec.Aggregations {
if agg.MetricName != "" {
metricNames = append(metricNames, querybuilder.FamilyMetricNames(ctx, orgID, q.fl, agg.MetricName)...)
metricNames = append(metricNames, agg.MetricName)
}
}
}
@@ -411,16 +409,14 @@ func (q *querier) resolveMetricMetadata(ctx context.Context, orgID valuer.UUID,
presentAggregations := make([]qbtypes.MetricAggregation, 0, len(spec.Aggregations))
for i := range spec.Aggregations {
familyNames := querybuilder.FamilyMetricNames(ctx, orgID, q.fl, spec.Aggregations[i].MetricName)
if spec.Aggregations[i].MetricName != "" && spec.Aggregations[i].Temporality == metrictypes.Unknown {
spec.Aggregations[i].Temporality = familyTemporality(metricTemporality, familyNames)
if temp, ok := metricTemporality[spec.Aggregations[i].MetricName]; ok && temp != metrictypes.Unknown {
spec.Aggregations[i].Temporality = temp
}
}
if spec.Aggregations[i].MetricName != "" && spec.Aggregations[i].Type == metrictypes.UnspecifiedType {
for _, member := range familyNames {
if foundMetricType, ok := metricTypes[member]; ok && foundMetricType != metrictypes.UnspecifiedType {
spec.Aggregations[i].Type = foundMetricType
break
}
if foundMetricType, ok := metricTypes[spec.Aggregations[i].MetricName]; ok && foundMetricType != metrictypes.UnspecifiedType {
spec.Aggregations[i].Type = foundMetricType
}
}
if spec.Aggregations[i].Type == metrictypes.UnspecifiedType {
@@ -438,11 +434,8 @@ func (q *querier) resolveMetricMetadata(ctx context.Context, orgID valuer.UUID,
return nil, nil, err
}
}
for _, member := range familyNames {
if reducedMetricsSet[member] {
spec.Aggregations[i].Reduced = true
break
}
if reducedMetricsSet[spec.Aggregations[i].MetricName] {
spec.Aggregations[i].Reduced = true
}
presentAggregations = append(presentAggregations, spec.Aggregations[i])
}
@@ -512,26 +505,6 @@ func (q *querier) resolveMetricMetadata(ctx context.Context, orgID valuer.UUID,
return missingMetricQueries, warnings, nil
}
// familyTemporality is the temporality the family names share, or Multiple
// when they differ.
func familyTemporality(temporalities map[string]metrictypes.Temporality, names []string) metrictypes.Temporality {
found := metrictypes.Unknown
for _, name := range names {
temporality, ok := temporalities[name]
if !ok || temporality == metrictypes.Unknown {
continue
}
if found == metrictypes.Unknown {
found = temporality
continue
}
if found != temporality {
return metrictypes.Multiple
}
}
return found
}
func (q *querier) QueryRawStream(ctx context.Context, orgID valuer.UUID, req *qbtypes.QueryRangeRequest, client *qbtypes.RawStream) {
// Coerce the window to epoch milliseconds up front (End may be 0 for the

View File

@@ -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 (
@@ -3202,14 +3202,7 @@ func (r *ClickHouseReader) GetMetricAttributeValues(ctx context.Context, orgID v
query = query + fmt.Sprintf(" LIMIT %d;", req.Limit)
}
names := []string{req.AggregateAttribute}
current := semconv.Current(semconv.KindMetric, telemetrytypes.FieldKeySelector{
Name: req.AggregateAttribute,
Signal: telemetrytypes.SignalMetrics,
FieldContext: telemetrytypes.FieldContextMetric,
})
if current != req.AggregateAttribute {
names = append(names, current)
}
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())

View File

@@ -220,26 +220,7 @@ func (ic *LogParsingPipelineController) ValidatePipelines(ctx context.Context,
return err
}
// withNormalizePipeline places normalize where the read path dictates. Ahead of user pipelines
// when queries run on body_v2 (use_json_body), so operators see the body the explorer shows.
// After them when dual ingestion alone writes body_v2, so operators keep seeing the raw body
// users still query. Absent when neither flag is on.
func (ic *LogParsingPipelineController) withNormalizePipeline(ctx context.Context, orgID valuer.UUID, pipelines []pipelinetypes.GettablePipeline) []pipelinetypes.GettablePipeline {
evalCtx := featuretypes.NewFlaggerEvaluationContext(orgID)
dualIngestion := ic.fl.BooleanOrEmpty(ctx, flagger.FeatureJSONBodyDualIngestion, evalCtx)
switch {
case ic.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, evalCtx):
return append([]pipelinetypes.GettablePipeline{getNormalizePipeline(dualIngestion)}, pipelines...)
case dualIngestion:
return append(slices.Clone(pipelines), getNormalizePipeline(true))
default:
return pipelines
}
}
// stashOriginalBody makes normalize carry the pre-normalization body in an internal attribute
// for the exporter to restore into the legacy body column.
func getNormalizePipeline(stashOriginalBody bool) pipelinetypes.GettablePipeline {
func (ic *LogParsingPipelineController) getNormalizePipeline() pipelinetypes.GettablePipeline {
return pipelinetypes.GettablePipeline{
StoreablePipeline: pipelinetypes.StoreablePipeline{
Name: "Default Pipeline - PreProcessing Body",
@@ -258,11 +239,10 @@ func getNormalizePipeline(stashOriginalBody bool) pipelinetypes.GettablePipeline
},
Config: []pipelinetypes.PipelineOperator{
{
ID: uuid.NewString(),
Type: "normalize",
Enabled: true,
If: "body != nil",
JSONBodyDualIngestion: stashOriginalBody,
ID: uuid.NewString(),
Type: "normalize",
Enabled: true,
If: "body != nil",
},
},
}
@@ -371,11 +351,8 @@ func (ic *LogParsingPipelineController) PreviewLogsPipelines(
}
// The collector gets the same pipeline prepended over opamp; see RecommendAgentConfig.
// Under dual ingestion alone it runs after user operators and only feeds body_v2, which
// the explorer does not show yet, so the preview leaves it out. The original-body stash
// is left off: the preview has no exporter to restore and strip it.
if ic.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(orgID)) {
pipelines = append([]pipelinetypes.GettablePipeline{getNormalizePipeline(false)}, pipelines...)
pipelines = append([]pipelinetypes.GettablePipeline{ic.getNormalizePipeline()}, pipelines...)
}
result, collectorLogs, err := SimulatePipelinesProcessing(ctx, pipelines, request.Logs)
@@ -396,16 +373,16 @@ func (pc *LogParsingPipelineController) AgentFeatureType() agentConf.AgentFeatur
// Implements agentConf.AgentFeature interface.
// RecommendAgentConfig generates the collector config to be sent to agents.
// The normalize pipeline (when use_json_body or json_body_dual_ingestion is on) is placed
// here, after rawPipelineData is serialized. So it is only present in the config sent to
// The normalize pipeline (when use_json_body feature flag is on) is injected here, after
// rawPipelineData is serialized. So it is only present in the config sent to
// the collector and never persisted to the database as part of the user's pipeline list.
//
// NOTE: The configId sent to agents is derived from the pipeline version number
// (e.g. "LogPipelines:5"), not the YAML content. If server-side logic changes
// the generated YAML without bumping the version (e.g. toggling the use_json_body or
// json_body_dual_ingestion flags or updating operator IfExpressions), agents that already
// applied that version will not re-apply the new config. In such cases, users must save a
// new pipeline version via the API to force agents to pick up the change.
// the generated YAML without bumping the version (e.g. toggling the use_json_body
// flag or updating operator IfExpressions), agents that already applied that version will
// not re-apply the new config. In such cases, users must save a new pipeline version
// via the API to force agents to pick up the change.
func (pc *LogParsingPipelineController) RecommendAgentConfig(
orgId valuer.UUID,
currentConfYaml []byte,
@@ -431,8 +408,10 @@ func (pc *LogParsingPipelineController) RecommendAgentConfig(
return nil, "", err
}
// normalize is only for sending to the collector, never persisted
enrichedPipelines = pc.withNormalizePipeline(ctx, orgId, enrichedPipelines)
if pc.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(orgId)) {
// add default normalize pipeline at the beginning, only for sending to collector
enrichedPipelines = append([]pipelinetypes.GettablePipeline{pc.getNormalizePipeline()}, enrichedPipelines...)
}
updatedConf, err := GenerateCollectorConfigWithPipelines(currentConfYaml, enrichedPipelines)
if err != nil {

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

View File

@@ -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
@@ -235,12 +234,12 @@ func ClickHouseFormattedValue(v interface{}) string {
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)

View File

@@ -1,7 +1,6 @@
package utils
import (
"github.com/stretchr/testify/assert"
"reflect"
"testing"
@@ -484,21 +483,3 @@ func TestGetEpochNanoSecs(t *testing.T) {
})
}
}
// The legacy readers redirect an old metric name to its current name.
func TestClickHouseFormattedMetricNames(t *testing.T) {
testCases := []struct {
name string
metric string
expected string
}{
{name: "OldName_RedirectsToCurrent", metric: "k8s.pod.cpu.utilization", expected: "['k8s.pod.cpu.usage']"},
{name: "CurrentName_Unchanged", metric: "k8s.pod.cpu.usage", expected: "['k8s.pod.cpu.usage']"},
{name: "OutsideFamily_Unchanged", metric: "http.server.duration", expected: "['http.server.duration']"},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
assert.Equal(t, testCase.expected, ClickHouseFormattedMetricNames(testCase.metric))
})
}
}

View File

@@ -4,61 +4,57 @@ import (
"context"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/semconv"
"github.com/SigNoz/signoz/pkg/types/featuretypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"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 a selector for each other spelling of
// the family a selector names, so the fetched metadata holds every member.
// Off, or for a fuzzy selector, it returns the selectors as they are. A call
// site without the prefetch stays literal and never merges wrong.
// 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
}
// The same name under another context, signal, or data type needs its
// own siblings. The metric context is not part of the key: metric callers
// duplicate the selectors per metric name after this expansion.
type identity struct {
signal telemetrytypes.Signal
fieldContext telemetrytypes.FieldContext
fieldDataType telemetrytypes.FieldDataType
name string
}
out := selectors
seen := make(map[identity]bool, len(selectors))
seen := make(map[string]bool, len(selectors))
for _, selector := range selectors {
seen[identity{selector.Signal, selector.FieldContext, selector.FieldDataType, selector.Name}] = true
seen[selector.Name] = true
}
for _, selector := range selectors {
if selector.SelectorMatchType == telemetrytypes.FieldSelectorMatchTypeFuzzy {
if selector.Signal != telemetrytypes.SignalTraces ||
selector.SelectorMatchType == telemetrytypes.FieldSelectorMatchTypeFuzzy {
continue
}
members := familySpellings(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 {
id := identity{selector.Signal, selector.FieldContext, selector.FieldDataType, member}
if seen[id] {
if seen[member] {
continue
}
seen[id] = true
seen[member] = true
expanded := *selector
expanded.Name = member
out = append(out, &expanded)

View File

@@ -20,9 +20,8 @@ import (
// member reads, current member first. It is present when any member is
// present, and absent when no member is present. A row without any member
// reads what the tail of the merge reads: the sentinel for a string family,
// NULL for the others. When every member reads its sentinel as a value, so
// does the family, and the keyless contract of the signal survives the
// merge. A member with a value map reads in the current vocabulary.
// NULL for the others. A member with a value map reads in the current
// vocabulary.
func LogicalRead(ctx context.Context, q qbtypes.QueryInfo, storage qbtypes.Storage, logical *telemetrytypes.LogicalField) (qbtypes.Read, error) {
if !logical.IsFamily() {
return memberRead(ctx, q, storage, logical, 0)
@@ -36,7 +35,7 @@ func LogicalRead(ctx context.Context, q qbtypes.QueryInfo, storage qbtypes.Stora
reads = append(reads, read)
}
merged := qbtypes.Read{WhenAbsent: familyAbsence(logical, reads)}
merged := qbtypes.Read{WhenAbsent: familyAbsence(logical)}
guards := make([]string, 0, len(reads))
for _, read := range reads {
guards = append(guards, read.Presence)
@@ -98,16 +97,10 @@ func clickHouseStringArray(values []string) string {
}
// familyAbsence is what the merged read yields for a row without any
// member: the sentinel tail of a string family, NULL for the others. When
// every member's sentinel is a value, the tail is one too.
func familyAbsence(logical *telemetrytypes.LogicalField, reads []qbtypes.Read) qbtypes.Absent {
if logical.FieldDataType != telemetrytypes.FieldDataTypeString {
return qbtypes.AbsentIsNull
// member: the sentinel tail of a string family, NULL for the others.
func familyAbsence(logical *telemetrytypes.LogicalField) qbtypes.Absent {
if logical.FieldDataType == telemetrytypes.FieldDataTypeString {
return qbtypes.AbsentIsSentinel
}
for _, read := range reads {
if read.WhenAbsent != qbtypes.AbsentIsValue {
return qbtypes.AbsentIsSentinel
}
}
return qbtypes.AbsentIsValue
return qbtypes.AbsentIsNull
}

View File

@@ -48,7 +48,7 @@ func TestFamiliesOffByDefault(t *testing.T) {
}},
}
fields := matchingLogicalFields(false, telemetrytypes.SignalUnspecified, nil, &telemetrytypes.TelemetryFieldKey{Name: "deployment.environment.name"}, fieldKeys)
fields := matchingLogicalFields(false, telemetrytypes.SignalUnspecified, &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(true, telemetrytypes.SignalUnspecified, nil, &telemetrytypes.TelemetryFieldKey{Name: requested}, fieldKeys)
fields := matchingLogicalFields(true, telemetrytypes.SignalUnspecified, &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(true, telemetrytypes.SignalUnspecified, nil, &telemetrytypes.TelemetryFieldKey{
fields := matchingLogicalFields(true, telemetrytypes.SignalUnspecified, &telemetrytypes.TelemetryFieldKey{
Name: "deployment.environment.name",
FieldContext: telemetrytypes.FieldContextResource,
}, fieldKeys)
@@ -115,92 +115,26 @@ 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) {
fieldKeys := map[string][]*telemetrytypes.TelemetryFieldKey{
"deployment.environment.name": {{
Name: "deployment.environment.name",
// 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,
Signal: telemetrytypes.SignalLogs,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
}},
"deployment.environment": {{
Name: "deployment.environment",
Signal: telemetrytypes.SignalLogs,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
}},
}
}
fields := matchingLogicalFields(true, 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]))
}
// Metric entries of a span-metrics metric group across the plain and the
// resource_ 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) {
fieldKeys := map[string][]*telemetrytypes.TelemetryFieldKey{
"deployment.environment.name": {{
Name: "deployment.environment.name",
Signal: telemetrytypes.SignalMetrics,
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeString,
}},
"resource_deployment.environment.name": {{
Name: "resource_deployment.environment.name",
Signal: telemetrytypes.SignalMetrics,
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeString,
}},
"deployment.environment": {{
Name: "deployment.environment",
Signal: telemetrytypes.SignalMetrics,
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeString,
}},
"resource_deployment.environment": {{
Name: "resource_deployment.environment",
Signal: telemetrytypes.SignalMetrics,
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeString,
}},
"deployment.environment.name": {logsKey("deployment.environment.name")},
"deployment.environment": {logsKey("deployment.environment")},
}
fields := matchingLogicalFields(true, telemetrytypes.SignalMetrics, &telemetrytypes.MetricContext{MetricName: "signoz_calls_total"}, &telemetrytypes.TelemetryFieldKey{Name: "deployment.environment"}, fieldKeys)
fields := matchingLogicalFields(true, telemetrytypes.SignalUnspecified, &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", "resource_deployment.environment.name",
"deployment.environment", "resource_deployment.environment",
}, memberNames(fields[0]))
}
// A non-string entry never joins a family: the merged read has no common
// ClickHouse type across the storages.
func TestMatchingLogicalFieldsKeepsNumberEntriesSingle(t *testing.T) {
fieldKeys := map[string][]*telemetrytypes.TelemetryFieldKey{
"deployment.environment.name": {{
Name: "deployment.environment.name",
Signal: telemetrytypes.SignalMetrics,
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeNumber,
}},
"deployment.environment": {{
Name: "deployment.environment",
Signal: telemetrytypes.SignalMetrics,
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeNumber,
}},
}
fields := matchingLogicalFields(true, telemetrytypes.SignalMetrics, nil, &telemetrytypes.TelemetryFieldKey{Name: "deployment.environment"}, fieldKeys)
require.Len(t, fields, 2)
for _, logical := range fields {
assert.False(t, logical.IsFamily())
}
assert.False(t, fields[0].IsFamily())
assert.Equal(t, []string{"deployment.environment.name"}, memberNames(fields[0]))
}
// A family and a genuine same-name collision stack cleanly: the family stays
@@ -231,7 +165,7 @@ func TestResolveLogicalFieldsKeepsFamilyThroughAmbiguity(t *testing.T) {
}
requested := &telemetrytypes.TelemetryFieldKey{Name: "deployment.environment.name"}
fields := matchingLogicalFields(true, telemetrytypes.SignalUnspecified, nil, requested, fieldKeys)
fields := matchingLogicalFields(true, telemetrytypes.SignalUnspecified, requested, fieldKeys)
require.Len(t, fields, 2, "resource family + attribute collision")
resolved, warning := ResolveLogicalFields(requested, fields)
@@ -295,7 +229,7 @@ func TestMatchingLogicalFieldsNeverMergesAcrossDataTypes(t *testing.T) {
}},
}
fields := matchingLogicalFields(true, telemetrytypes.SignalUnspecified, nil, &telemetrytypes.TelemetryFieldKey{Name: "deployment.environment.name"}, fieldKeys)
fields := matchingLogicalFields(true, telemetrytypes.SignalUnspecified, &telemetrytypes.TelemetryFieldKey{Name: "deployment.environment.name"}, fieldKeys)
require.Len(t, fields, 2)
for _, logical := range fields {
assert.False(t, logical.IsFamily())
@@ -320,15 +254,11 @@ func TestExpandKeySelectorsForFamilies(t *testing.T) {
"service.name",
"deployment.environment.name",
"deployment.environment",
"deployment.environment",
}, names, "each selector identity gets its own sibling, and a non-family name stays untouched")
}, names, "one sibling selector for the trace family member; logs and non-family names untouched")
tracesSibling := expanded[len(expanded)-2]
assert.Equal(t, telemetrytypes.SignalTraces, tracesSibling.Signal)
assert.Equal(t, telemetrytypes.FieldSelectorMatchTypeExact, tracesSibling.SelectorMatchType)
logsSibling := expanded[len(expanded)-1]
assert.Equal(t, telemetrytypes.SignalLogs, logsSibling.Signal,
"a same-named selector under another signal must not take the sibling")
sibling := expanded[len(expanded)-1]
assert.Equal(t, telemetrytypes.SignalTraces, sibling.Signal)
assert.Equal(t, telemetrytypes.FieldSelectorMatchTypeExact, sibling.SelectorMatchType)
}
func TestExpandKeySelectorsForFamiliesDeduplicatesAndSkipsFuzzy(t *testing.T) {

View File

@@ -1,64 +0,0 @@
package querybuilder
import (
"context"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/semconv"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
// The span-metrics processor in signoz-otel-collector writes resource
// attributes as labels with a resource_ prefix on these metrics only. The
// signoz_latency histogram is stored as its dotted sub-metrics.
const spanMetricsResourcePrefix = "resource_"
var spanMetrics = map[string]struct{}{
"signoz_calls_total": {},
"signoz_latency": {},
"signoz_latency.bucket": {},
"signoz_latency.sum": {},
"signoz_latency.count": {},
"signoz_latency.min": {},
"signoz_latency.max": {},
"signoz_db_latency_sum": {},
"signoz_db_latency_count": {},
"signoz_external_call_latency_sum": {},
"signoz_external_call_latency_count": {},
}
// MetricLabelSpellings returns the family members of selector.Name, current
// first, and on a span-metrics metric each member with the resource_ prefix
// too. A name outside a family, or one the selector leaves ambiguous, is
// returned as it is.
func MetricLabelSpellings(selector telemetrytypes.FieldKeySelector) []string {
members := semconv.Members(semconv.KindAttribute, selector)
if len(members) <= 1 {
return []string{selector.Name}
}
if selector.MetricContext == nil {
return members
}
if _, ok := spanMetrics[selector.MetricContext.MetricName]; !ok {
return members
}
spellings := make([]string, 0, len(members)*2)
for _, member := range members {
spellings = append(spellings, member, spanMetricsResourcePrefix+member)
}
return spellings
}
// FamilyMetricNames returns the metric-name family of metricName when the
// flag is on for the org, else the name alone.
func FamilyMetricNames(ctx context.Context, orgID valuer.UUID, fl flagger.Flagger, metricName string) []string {
if !SemconvFamiliesEnabled(ctx, orgID, fl) {
return []string{metricName}
}
return semconv.Members(semconv.KindMetric, telemetrytypes.FieldKeySelector{
Name: metricName,
Signal: telemetrytypes.SignalMetrics,
FieldContext: telemetrytypes.FieldContextMetric,
})
}

View File

@@ -1,100 +0,0 @@
package querybuilder
import (
"context"
"testing"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/flagger/flaggertest"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/assert"
)
func TestMetricLabelSpellingsReturnsTheFamilyMembers(t *testing.T) {
selector := telemetrytypes.FieldKeySelector{
Name: "deployment.environment",
Signal: telemetrytypes.SignalMetrics,
MetricContext: &telemetrytypes.MetricContext{MetricName: "k8s.pod.cpu.usage"},
}
assert.Equal(t, []string{"deployment.environment.name", "deployment.environment"}, MetricLabelSpellings(selector))
}
func TestMetricLabelSpellingsAddsTheResourcePrefixForSpanMetrics(t *testing.T) {
testCases := []struct {
name string
metric string
expected []string
}{
{
name: "SpanMetric_ReadsPlainAndResourceSpellings",
metric: "signoz_calls_total",
expected: []string{
"deployment.environment.name", "resource_deployment.environment.name",
"deployment.environment", "resource_deployment.environment",
},
},
{
name: "LatencyHistogramSubMetric_ReadsPlainAndResourceSpellings",
metric: "signoz_latency.bucket",
expected: []string{
"deployment.environment.name", "resource_deployment.environment.name",
"deployment.environment", "resource_deployment.environment",
},
},
{
name: "OtherSignozMetric_ReadsPlainSpellings",
metric: "signoz_other_metric",
expected: []string{"deployment.environment.name", "deployment.environment"},
},
{
name: "UnderscoreLatencySubMetric_ReadsPlainSpellings",
metric: "signoz_latency_bucket",
expected: []string{"deployment.environment.name", "deployment.environment"},
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
selector := telemetrytypes.FieldKeySelector{
Name: "deployment.environment",
Signal: telemetrytypes.SignalMetrics,
MetricContext: &telemetrytypes.MetricContext{MetricName: testCase.metric},
}
assert.Equal(t, testCase.expected, MetricLabelSpellings(selector))
})
}
}
// A requested name is never rewritten: a resource_ spelling that is not a
// family member stays literal, on a span metric too.
func TestMetricLabelSpellingsKeepsThePrefixedRequestLiteral(t *testing.T) {
selector := telemetrytypes.FieldKeySelector{
Name: "resource_deployment.environment",
Signal: telemetrytypes.SignalMetrics,
MetricContext: &telemetrytypes.MetricContext{MetricName: "signoz_calls_total"},
}
assert.Equal(t, []string{"resource_deployment.environment"}, MetricLabelSpellings(selector))
}
func TestMetricLabelSpellingsStaysLiteralOutsideTheVocabulary(t *testing.T) {
selector := telemetrytypes.FieldKeySelector{
Name: "http.route",
Signal: telemetrytypes.SignalMetrics,
}
assert.Equal(t, []string{"http.route"}, MetricLabelSpellings(selector))
}
func TestFamilyMetricNames(t *testing.T) {
on := flaggertest.WithBooleanFlags(t, map[string]bool{
flagger.FeatureResolveSemconvFamilies.String(): true,
})
assert.Equal(t, []string{"k8s.pod.cpu.usage", "k8s.pod.cpu.utilization"}, FamilyMetricNames(context.Background(), valuer.UUID{}, on, "k8s.pod.cpu.utilization"))
assert.Equal(t, []string{"k8s.pod.cpu.usage", "k8s.pod.cpu.utilization"}, FamilyMetricNames(context.Background(), valuer.UUID{}, on, "k8s.pod.cpu.usage"))
assert.Equal(t, []string{"http.server.duration"}, FamilyMetricNames(context.Background(), valuer.UUID{}, on, "http.server.duration"))
off := flaggertest.WithBooleanFlags(t, map[string]bool{})
assert.Equal(t, []string{"k8s.pod.cpu.utilization"}, FamilyMetricNames(context.Background(), valuer.UUID{}, off, "k8s.pod.cpu.utilization"))
}

View File

@@ -21,7 +21,7 @@ func NewQueryInfo(ctx context.Context, orgID valuer.UUID, fl flagger.Flagger, si
EndNs: endNs,
Signal: signal,
Metric: metric,
FamiliesOn: SemconvFamiliesEnabled(ctx, orgID, fl),
FamiliesOn: semconvFamiliesEnabled(ctx, orgID, fl),
}
if fl != nil {
evalCtx := featuretypes.NewFlaggerEvaluationContext(orgID)
@@ -61,7 +61,7 @@ func Resolve(
traits := storage.Traits()
lookup := key
matches := matchingLogicalFields(q.FamiliesOn, q.Signal, q.Metric, key, fieldKeys)
matches := matchingLogicalFields(q.FamiliesOn, q.Signal, key, fieldKeys)
if len(matches) == 0 && slices.Contains(traits.OwnContexts, key.FieldContext) {
// a column the storage knows under the key's own context is the key
// as written, and only a miss corrects to the bare spelling
@@ -71,7 +71,7 @@ func Resolve(
}
}
lookup = telemetrytypes.NewTelemetryFieldKey(key.Name, telemetrytypes.FieldContextUnspecified, key.FieldDataType)
matches = matchingLogicalFields(q.FamiliesOn, q.Signal, q.Metric, lookup, fieldKeys)
matches = matchingLogicalFields(q.FamiliesOn, q.Signal, lookup, fieldKeys)
}
resolved := qbtypes.Resolved{Key: key, Ambiguous: len(matches) > 1}

View File

@@ -1012,36 +1012,43 @@ func assignIfEmpty(s *string, value string) {
}
}
// familySpellings returns the storage spellings for the selector. Metrics
// add the span-metrics label layout.
func familySpellings(selector telemetrytypes.FieldKeySelector) []string {
if selector.Signal == telemetrytypes.SignalMetrics {
return MetricLabelSpellings(selector)
// familyMemberNames returns the physical spellings to look up for the
// referenced key: the semantic-convention family members (current-first) when
// families are on and the query can resolve to traces, else just the requested
// name. Only the traces storage understands families today. Logs and
// metrics keep the requested spelling until theirs land.
func familyMemberNames(familiesOn bool, signal telemetrytypes.Signal, field *telemetrytypes.TelemetryFieldKey) []string {
if !familiesOn {
return []string{field.Name}
}
return semconv.Members(semconv.KindAttribute, selector)
if signal != telemetrytypes.SignalUnspecified && signal != telemetrytypes.SignalTraces {
return []string{field.Name}
}
return semconv.Members(semconv.KindAttribute, telemetrytypes.FieldKeySelector{
Name: field.Name,
Signal: telemetrytypes.SignalTraces,
FieldContext: field.FieldContext,
})
}
// matchingLogicalFields resolves the key against the metadata map. Members
// of one family group into one logical field per (signal, context, data
// type) identity, current first. Every other match is its own single-member
// field. The length of the result is the ambiguity: a family is never
// ambiguous with itself. Members alias the map entries. With families off,
// only the requested name is looked up. The key's own signal wins over the
// query's signal.
func matchingLogicalFields(familiesOn bool, signal telemetrytypes.Signal, metric *telemetrytypes.MetricContext, field *telemetrytypes.TelemetryFieldKey, fieldKeys map[string][]*telemetrytypes.TelemetryFieldKey) []*telemetrytypes.LogicalField {
members := []string{field.Name}
if familiesOn {
if field.Signal != telemetrytypes.SignalUnspecified {
signal = field.Signal
}
members = familySpellings(telemetrytypes.FieldKeySelector{
Name: field.Name,
Signal: signal,
FieldContext: field.FieldContext,
MetricContext: metric,
})
}
matches := collectMemberMatches(field, members, metric, fieldKeys)
// 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 (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 families are on for the query. Off,
// every match stays a single-member logical field.
func matchingLogicalFields(familiesOn bool, signal telemetrytypes.Signal, field *telemetrytypes.TelemetryFieldKey, fieldKeys map[string][]*telemetrytypes.TelemetryFieldKey) []*telemetrytypes.LogicalField {
members := familyMemberNames(familiesOn, signal, field)
matches := collectMemberMatches(field, members, fieldKeys)
return groupIntoLogicalFields(field.Name, len(members) > 1, matches)
}
@@ -1053,35 +1060,48 @@ type memberMatch struct {
rank int
}
// collectMemberMatches finds the metadata entries for every member spelling,
// under the member name and under its context-prefixed spelling, because a
// context can be part of a stored name. An unspecified context or data type
// matches any.
func collectMemberMatches(field *telemetrytypes.TelemetryFieldKey, members []string, metric *telemetrytypes.MetricContext, fieldKeys map[string][]*telemetrytypes.TelemetryFieldKey) []memberMatch {
// matchesRequestedIdentity reports whether the entry fits the context and data
// type that the request specified; unspecified matches any. A context-prefixed
// lookup already matched the context through the lookup key itself.
func matchesRequestedIdentity(field, item *telemetrytypes.TelemetryFieldKey, contextMatched bool) bool {
if !contextMatched && field.FieldContext != telemetrytypes.FieldContextUnspecified && field.FieldContext != item.FieldContext {
return false
}
if field.FieldDataType != telemetrytypes.FieldDataTypeUnspecified && field.FieldDataType != item.FieldDataType {
return false
}
return true
}
// inFamilyScope reports whether a match found under a sibling member name is
// 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, 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] {
// A context-prefixed lookup matched the context through the key.
if !contextMatched && field.FieldContext != telemetrytypes.FieldContextUnspecified && field.FieldContext != item.FieldContext {
if !matchesRequestedIdentity(field, item, contextMatched) {
continue
}
if field.FieldDataType != telemetrytypes.FieldDataTypeUnspecified && field.FieldDataType != item.FieldDataType {
if memberName != field.Name && !inFamilyScope(field, item, memberName) {
continue
}
if memberName != field.Name {
// A sibling can match a same-named field where the family does
// not apply, so the member must be a spelling of the requested
// name for the entry's own signal and context.
spellings := familySpellings(telemetrytypes.FieldKeySelector{
Name: field.Name,
Signal: item.Signal,
FieldContext: item.FieldContext,
MetricContext: metric,
})
if !slices.Contains(spellings, memberName) {
continue
}
}
matches = append(matches, memberMatch{key: item, rank: rank})
}
}
@@ -1097,22 +1117,18 @@ func collectMemberMatches(field *telemetrytypes.TelemetryFieldKey, members []str
return matches
}
// groupIntoLogicalFields groups a string entry of a family signal under the
// resource or attribute context by its (signal, context, data type)
// identity. Every other entry is its own single-member field. Members sort
// by family rank, not by lookup order.
// 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 {
familySignal := match.key.Signal == telemetrytypes.SignalTraces ||
match.key.Signal == telemetrytypes.SignalLogs ||
match.key.Signal == telemetrytypes.SignalMetrics
familyContext := match.key.FieldContext == telemetrytypes.FieldContextResource ||
match.key.FieldContext == telemetrytypes.FieldContextAttribute
if !familyMode || !familySignal || !familyContext || match.key.FieldDataType != telemetrytypes.FieldDataTypeString {
if !familyMode || match.key.Signal != telemetrytypes.SignalTraces {
fields = append(fields, telemetrytypes.SingleLogicalField(requestedName, match.key))
continue
}
@@ -1129,10 +1145,7 @@ func groupIntoLogicalFields(requestedName string, familyMode bool, matches []mem
groups[identity] = group
fields = append(fields, group)
}
alreadyMember := slices.ContainsFunc(group.Members, func(member *telemetrytypes.TelemetryFieldKey) bool {
return member.Name == match.key.Name
})
if alreadyMember {
if groupHasMemberNamed(group, match.key.Name) {
continue
}
ranks[match.key] = match.rank
@@ -1146,3 +1159,12 @@ func groupIntoLogicalFields(requestedName string, familyMode bool, matches []mem
}
return fields
}
func groupHasMemberNamed(group *telemetrytypes.LogicalField, name string) bool {
for _, member := range group.Members {
if member.Name == name {
return true
}
}
return false
}

View File

@@ -589,7 +589,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(false, telemetrytypes.SignalUnspecified, nil, key, tt.fieldKeys)
matching := matchingLogicalFields(false, telemetrytypes.SignalUnspecified, key, tt.fieldKeys)
resolved, warning := ResolveLogicalFields(key, matching)
keys := make([]*telemetrytypes.TelemetryFieldKey, 0, len(resolved))
for _, logical := range resolved {

View File

@@ -2,37 +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: "deployment.environment.name",
kind: KindAttribute,
members: []Member{
{name: "deployment.environment"},
},
contexts: []telemetrytypes.FieldContext{telemetrytypes.FieldContextAttribute, telemetrytypes.FieldContextResource},
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,
},
}

View File

@@ -1,7 +1,6 @@
package semconv
import (
"iter"
"slices"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
@@ -15,47 +14,16 @@ type Kind struct {
valuer.String
}
// Member is one historical spelling with the scope of its rename edges. A
// nil axis is unconstrained.
type Member struct {
name string
contexts []telemetrytypes.FieldContext
signals []telemetrytypes.Signal
applyToMetrics []string
}
func (m Member) Name() string {
return m.name
}
// Family is one logical telemetry field. Members run from the most recent
// predecessor to the oldest, which is the fallback order. The family-level
// contexts and signals come from the overlay and gate where the family
// resolves. The member scopes come from the schema edges and gate which
// members apply.
// 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
}
func (f Family) Current() string {
return f.current
}
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 (
@@ -70,143 +38,90 @@ func (Kind) Enum() []any {
return []any{KindAttribute, KindMetric}
}
// Members returns the current name first, followed by the historical spellings
// admitted for the selector, in fallback order. A name outside an enabled
// family is returned unchanged. So is a name the selector leaves ambiguous.
// The returned slice must not be modified.
// 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 {
return Family{}, false
}
return families[idx], true
}
// 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
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
}
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))
add := func(name string, i int) {
if !slices.Contains(index[name], i) {
index[name] = append(index[name], i)
}
}
for i, family := range families {
members[i] = make([]string, 0, len(family.members)+1)
members[i] = append(members[i], family.current)
add(family.current, i)
for _, member := range family.members {
members[i] = append(members[i], member.name)
add(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
}
// lookupIndex returns the one family that admits selector.Name for kind.
// An axis the selector leaves empty constrains nothing. When more than one
// family is admitted, the name stays literal.
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
}
// familyAdmits reports whether the family gate admits the selector and the
// name is the current name or an admitted member.
func familyAdmits(family Family, kind Kind, selector telemetrytypes.FieldKeySelector) bool {
if family.kind != kind {
func matchesSelector(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
}
}
return false
}
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. An empty
// axis on either side admits.
func memberAdmits(member Member, selector telemetrytypes.FieldKeySelector) bool {
if !axisAdmits(member.signals, selector.Signal, telemetrytypes.SignalUnspecified) {
return false
if selector.Signal != telemetrytypes.SignalUnspecified && len(family.Signals) > 0 {
if !slices.Contains(family.Signals, selector.Signal) {
return false
}
}
if !axisAdmits(member.contexts, selector.FieldContext, telemetrytypes.FieldContextUnspecified) {
return false
if selector.FieldContext != telemetrytypes.FieldContextUnspecified && len(family.Contexts) > 0 {
if !slices.Contains(family.Contexts, selector.FieldContext) {
return false
}
}
if len(member.applyToMetrics) > 0 &&
selector.MetricContext != nil && selector.MetricContext.MetricName != "" &&
!slices.Contains(member.applyToMetrics, selector.MetricContext.MetricName) {
return false
if selector.Signal == telemetrytypes.SignalMetrics && len(family.ApplyToMetrics) > 0 {
if selector.MetricContext == nil {
return false
}
return slices.Contains(family.ApplyToMetrics, selector.MetricContext.MetricName)
}
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)
}

View File

@@ -78,116 +78,3 @@ func TestMembersReturnsInputWhenKindDoesNotMatch(t *testing.T) {
"an attribute family must not match a metric-name lookup",
)
}
func TestFamilySignalsGateResolution(t *testing.T) {
swapFamilies(t, []Family{{
current: "gated.current",
kind: KindAttribute,
members: []Member{{name: "gated.old"}},
signals: []telemetrytypes.Signal{telemetrytypes.SignalLogs, telemetrytypes.SignalTraces},
}})
metrics := telemetrytypes.FieldKeySelector{Name: "gated.old", Signal: telemetrytypes.SignalMetrics}
logs := telemetrytypes.FieldKeySelector{Name: "gated.old", Signal: telemetrytypes.SignalLogs}
assert.Equal(t, []string{"gated.old"}, Members(KindAttribute, metrics),
"a family gated to traces and logs must stay literal for metrics")
assert.Equal(t, []string{"gated.current", "gated.old"}, 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 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")
}

View File

@@ -1,256 +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 resource 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())
testCases := []struct {
name string
familyOn bool
expected string
}{
{
name: "FamiliesOn",
familyOn: true,
expected: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE (COALESCE(NULLIF(simpleJSONExtractString(labels, 'deployment.environment.name'), ''), NULLIF(simpleJSONExtractString(labels, 'deployment.environment'), ''), '') = ? AND (labels LIKE ? OR labels LIKE ?) AND (labels LIKE ? OR labels LIKE ?)) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? ORDER BY __result_0 DESC",
},
{
name: "FamiliesOff",
familyOn: false,
expected: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE (simpleJSONExtractString(labels, 'deployment.environment') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? ORDER BY __result_0 DESC",
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
fl := flaggertest.WithBooleanFlags(t, map[string]bool{
flagger.FeatureResolveSemconvFamilies.String(): testCase.familyOn,
})
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
keys := logstelemetryschema.BuildCompleteFieldKeyMap(releaseTime)
for _, name := range []string{"deployment.environment.name", "deployment.environment"} {
keys[name] = []*telemetrytypes.TelemetryFieldKey{{
Name: name,
Signal: telemetrytypes.SignalLogs,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
}}
}
mockMetadataStore.KeysMap = keys
storage := logstelemetryschema.NewStorage()
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage, fl, telemetrytypes.SignalLogs)
statementBuilder := NewLogQueryStatementBuilder(
instrumentationtest.New().ToProviderSettings(),
mockMetadataStore, storage, 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: "resource.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, testCase.expected, q.Query)
})
}
}
// The predicate of a filtered aggregation resolves the family exactly like
// the main WHERE clause.
func TestStatementBuilderResolvesLogFamilyFilteredAggregation(t *testing.T) {
releaseTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)
releaseTimeNano := uint64(releaseTime.UnixNano())
testCases := []struct {
name string
familyOn bool
expected string
}{
{name: "FamiliesOn", familyOn: true, expected: "SELECT countIf((COALESCE(NULLIF(multiIf(resource.`deployment.environment.name` IS NOT NULL, resource.`deployment.environment.name`::String, mapContains(resources_string, 'deployment.environment.name'), resources_string['deployment.environment.name'], NULL), ''), NULLIF(multiIf(resource.`deployment.environment` IS NOT NULL, resource.`deployment.environment`::String, mapContains(resources_string, 'deployment.environment'), resources_string['deployment.environment'], NULL), ''), '') = ? AND (multiIf(resource.`deployment.environment.name` IS NOT NULL, resource.`deployment.environment.name`::String, mapContains(resources_string, 'deployment.environment.name'), resources_string['deployment.environment.name'], NULL) IS NOT NULL OR multiIf(resource.`deployment.environment` IS NOT NULL, resource.`deployment.environment`::String, mapContains(resources_string, 'deployment.environment'), resources_string['deployment.environment'], NULL) IS NOT NULL))) AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? ORDER BY __result_0 DESC"},
{name: "FamiliesOff", familyOn: false, expected: "SELECT countIf(multiIf(resource.`deployment.environment.name` IS NOT NULL, resource.`deployment.environment.name`::String, mapContains(resources_string, 'deployment.environment.name'), resources_string['deployment.environment.name'], NULL) = ?) AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? ORDER BY __result_0 DESC"},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
fl := flaggertest.WithBooleanFlags(t, map[string]bool{
flagger.FeatureResolveSemconvFamilies.String(): testCase.familyOn,
})
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
keys := logstelemetryschema.BuildCompleteFieldKeyMap(releaseTime)
for _, name := range []string{"deployment.environment.name", "deployment.environment"} {
keys[name] = []*telemetrytypes.TelemetryFieldKey{{
Name: name,
Signal: telemetrytypes.SignalLogs,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
}}
}
mockMetadataStore.KeysMap = keys
storage := logstelemetryschema.NewStorage()
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage, fl, telemetrytypes.SignalLogs)
statementBuilder := NewLogQueryStatementBuilder(
instrumentationtest.New().ToProviderSettings(),
mockMetadataStore, storage, 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: "countIf(deployment.environment.name = '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, testCase.expected, q.Query)
})
}
}
// The mid-migration state: metadata holds one spelling of the family, and
// the query names the other. The filter and the group by both read the one
// stored spelling.
func TestStatementBuilderResolvesSingleSpellingAcrossNames(t *testing.T) {
releaseTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)
releaseTimeNano := uint64(releaseTime.UnixNano())
testCases := []struct {
name string
stored string
queried string
expected string
}{
{name: "OldDataQueriedByCurrentName", stored: "deployment.environment", queried: "deployment.environment.name", expected: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE (simpleJSONExtractString(labels, 'deployment.environment') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT toString(multiIf(resource.`deployment.environment` IS NOT NULL, resource.`deployment.environment`::String, mapContains(resources_string, 'deployment.environment'), resources_string['deployment.environment'], NULL)) AS `__GROUP_BY_KEY_0_deployment.environment.name`, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY `__GROUP_BY_KEY_0_deployment.environment.name` ORDER BY __result_0 DESC"},
{name: "CurrentDataQueriedByOldName", stored: "deployment.environment.name", queried: "deployment.environment", expected: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_logs.distributed_logs_v2_resource WHERE (simpleJSONExtractString(labels, 'deployment.environment.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT toString(multiIf(resource.`deployment.environment.name` IS NOT NULL, resource.`deployment.environment.name`::String, mapContains(resources_string, 'deployment.environment.name'), resources_string['deployment.environment.name'], NULL)) AS `__GROUP_BY_KEY_0_deployment.environment`, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY `__GROUP_BY_KEY_0_deployment.environment` ORDER BY __result_0 DESC"},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
fl := flaggertest.WithBooleanFlags(t, map[string]bool{
flagger.FeatureResolveSemconvFamilies.String(): true,
})
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
keys := logstelemetryschema.BuildCompleteFieldKeyMap(releaseTime)
keys[testCase.stored] = []*telemetrytypes.TelemetryFieldKey{{
Name: testCase.stored,
Signal: telemetrytypes.SignalLogs,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
}}
mockMetadataStore.KeysMap = keys
storage := logstelemetryschema.NewStorage()
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage, fl, telemetrytypes.SignalLogs)
statementBuilder := NewLogQueryStatementBuilder(
instrumentationtest.New().ToProviderSettings(),
mockMetadataStore, storage, 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: testCase.queried + " = 'production'"},
GroupBy: []qbtypes.GroupByKey{
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: testCase.queried}},
},
}
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, testCase.expected, q.Query)
})
}
}
// Group by resolves the family exactly like the filter. The merged column
// reads the spellings current-first with empty falling through, and a row
// with no member keeps the NULL group of a single key.
func TestStatementBuilderResolvesLogFamilyGroupBy(t *testing.T) {
releaseTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)
releaseTimeNano := uint64(releaseTime.UnixNano())
testCases := []struct {
name string
familyOn bool
expected string
}{
{name: "FamiliesOn", familyOn: true, expected: "SELECT toString(multiIf((multiIf(resource.`deployment.environment.name` IS NOT NULL, resource.`deployment.environment.name`::String, mapContains(resources_string, 'deployment.environment.name'), resources_string['deployment.environment.name'], NULL) IS NOT NULL OR multiIf(resource.`deployment.environment` IS NOT NULL, resource.`deployment.environment`::String, mapContains(resources_string, 'deployment.environment'), resources_string['deployment.environment'], NULL) IS NOT NULL), COALESCE(NULLIF(multiIf(resource.`deployment.environment.name` IS NOT NULL, resource.`deployment.environment.name`::String, mapContains(resources_string, 'deployment.environment.name'), resources_string['deployment.environment.name'], NULL), ''), NULLIF(multiIf(resource.`deployment.environment` IS NOT NULL, resource.`deployment.environment`::String, mapContains(resources_string, 'deployment.environment'), resources_string['deployment.environment'], NULL), ''), ''), NULL)) AS `__GROUP_BY_KEY_0_deployment.environment.name`, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY `__GROUP_BY_KEY_0_deployment.environment.name` ORDER BY __result_0 DESC"},
{name: "FamiliesOff", familyOn: false, expected: "SELECT toString(multiIf(resource.`deployment.environment.name` IS NOT NULL, resource.`deployment.environment.name`::String, mapContains(resources_string, 'deployment.environment.name'), resources_string['deployment.environment.name'], NULL)) AS `__GROUP_BY_KEY_0_deployment.environment.name`, count() AS __result_0 FROM signoz_logs.distributed_logs_v2 WHERE timestamp >= ? AND ts_bucket_start >= ? AND timestamp < ? AND ts_bucket_start <= ? GROUP BY `__GROUP_BY_KEY_0_deployment.environment.name` ORDER BY __result_0 DESC"},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
fl := flaggertest.WithBooleanFlags(t, map[string]bool{
flagger.FeatureResolveSemconvFamilies.String(): testCase.familyOn,
})
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
keys := logstelemetryschema.BuildCompleteFieldKeyMap(releaseTime)
for _, name := range []string{"deployment.environment.name", "deployment.environment"} {
keys[name] = []*telemetrytypes.TelemetryFieldKey{{
Name: name,
Signal: telemetrytypes.SignalLogs,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
}}
}
mockMetadataStore.KeysMap = keys
storage := logstelemetryschema.NewStorage()
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage, fl, telemetrytypes.SignalLogs)
statementBuilder := NewLogQueryStatementBuilder(
instrumentationtest.New().ToProviderSettings(),
mockMetadataStore, storage, 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()"}},
GroupBy: []qbtypes.GroupByKey{
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "deployment.environment.name"}},
},
}
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, testCase.expected, q.Query)
})
}
}

View File

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

View File

@@ -1,175 +0,0 @@
package metricsstatementbuilder
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/telemetryschema/metricstelemetryschema"
"github.com/SigNoz/signoz/pkg/types/metrictypes"
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"
)
// The flag merges the label spellings of the family and unions the storage
// names of the metric-name family, in the filter, the group by column, and
// every metric_name filter.
func TestStatementBuilderResolvesFamilies(t *testing.T) {
fl := flaggertest.WithBooleanFlags(t, map[string]bool{
flagger.FeatureResolveSemconvFamilies.String(): true,
})
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
mockMetadataStore.KeysMap = map[string][]*telemetrytypes.TelemetryFieldKey{}
for _, name := range []string{"deployment.environment.name", "deployment.environment"} {
mockMetadataStore.KeysMap[name] = []*telemetrytypes.TelemetryFieldKey{{
Name: name,
Signal: telemetrytypes.SignalMetrics,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
}}
}
statementBuilder := NewMetricQueryStatementBuilder(
instrumentationtest.New().ToProviderSettings(),
mockMetadataStore,
metricstelemetryschema.NewStorage(),
fl,
)
query := qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Signal: telemetrytypes.SignalMetrics,
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
Aggregations: []qbtypes.MetricAggregation{
{
MetricName: "k8s.pod.cpu.utilization",
Type: metrictypes.GaugeType,
Temporality: metrictypes.Unspecified,
TimeAggregation: metrictypes.TimeAggregationAvg,
SpaceAggregation: metrictypes.SpaceAggregationAvg,
},
},
Filter: &qbtypes.Filter{
Expression: "deployment.environment = 'production'",
},
GroupBy: []qbtypes.GroupByKey{
{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: "deployment.environment.name",
},
},
},
}
q, err := statementBuilder.Build(context.Background(), valuer.UUID{}, 1747947419000, 1747983448000, qbtypes.RequestTypeTimeSeries, query, nil)
require.NoError(t, err)
require.Equal(t, "WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(30)) AS ts, `__GROUP_BY_KEY_0_deployment.environment.name`, avg(value) AS per_series_value FROM signoz_metrics.distributed_samples_v4 AS points INNER JOIN (SELECT fingerprint, COALESCE(NULLIF(JSONExtractString(labels, 'deployment.environment.name'), ''), NULLIF(JSONExtractString(labels, 'deployment.environment'), ''), '') AS `__GROUP_BY_KEY_0_deployment.environment.name` FROM signoz_metrics.time_series_v4_6hrs WHERE metric_name IN (?, ?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND COALESCE(NULLIF(JSONExtractString(labels, 'deployment.environment.name'), ''), NULLIF(JSONExtractString(labels, 'deployment.environment'), ''), '') = ? GROUP BY fingerprint, `__GROUP_BY_KEY_0_deployment.environment.name`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?, ?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts, `__GROUP_BY_KEY_0_deployment.environment.name` ORDER BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_deployment.environment.name`, avg(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts, `__GROUP_BY_KEY_0_deployment.environment.name`) SELECT * FROM __spatial_aggregation_cte ORDER BY `__GROUP_BY_KEY_0_deployment.environment.name`, ts", q.Query)
require.Equal(t, []any{"k8s.pod.cpu.usage", "k8s.pod.cpu.utilization", uint64(1747936800000), uint64(1747983420000), "unspecified", "production", "k8s.pod.cpu.usage", "k8s.pod.cpu.utilization", uint64(1747947390000), uint64(1747983420000), 0}, q.Args)
}
// The mid-migration state: metadata holds only the old label spelling, and
// the query names the current one. The filter and the group by both read
// the one stored label.
func TestStatementBuilderResolvesSingleSpellingAcrossNames(t *testing.T) {
fl := flaggertest.WithBooleanFlags(t, map[string]bool{
flagger.FeatureResolveSemconvFamilies.String(): true,
})
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
mockMetadataStore.KeysMap = map[string][]*telemetrytypes.TelemetryFieldKey{}
for _, name := range []string{"deployment.environment"} {
mockMetadataStore.KeysMap[name] = []*telemetrytypes.TelemetryFieldKey{{
Name: name,
Signal: telemetrytypes.SignalMetrics,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
}}
}
statementBuilder := NewMetricQueryStatementBuilder(
instrumentationtest.New().ToProviderSettings(),
mockMetadataStore,
metricstelemetryschema.NewStorage(),
fl,
)
query := qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Signal: telemetrytypes.SignalMetrics,
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
Aggregations: []qbtypes.MetricAggregation{
{
MetricName: "k8s.pod.cpu.utilization",
Type: metrictypes.GaugeType,
Temporality: metrictypes.Unspecified,
TimeAggregation: metrictypes.TimeAggregationAvg,
SpaceAggregation: metrictypes.SpaceAggregationAvg,
},
},
Filter: &qbtypes.Filter{
Expression: "deployment.environment.name = 'production'",
},
GroupBy: []qbtypes.GroupByKey{
{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: "deployment.environment.name",
},
},
},
}
q, err := statementBuilder.Build(context.Background(), valuer.UUID{}, 1747947419000, 1747983448000, qbtypes.RequestTypeTimeSeries, query, nil)
require.NoError(t, err)
require.Equal(t, "WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(30)) AS ts, `__GROUP_BY_KEY_0_deployment.environment.name`, avg(value) AS per_series_value FROM signoz_metrics.distributed_samples_v4 AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'deployment.environment') AS `__GROUP_BY_KEY_0_deployment.environment.name` FROM signoz_metrics.time_series_v4_6hrs WHERE metric_name IN (?, ?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND JSONExtractString(labels, 'deployment.environment') = ? GROUP BY fingerprint, `__GROUP_BY_KEY_0_deployment.environment.name`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?, ?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts, `__GROUP_BY_KEY_0_deployment.environment.name` ORDER BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_deployment.environment.name`, avg(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts, `__GROUP_BY_KEY_0_deployment.environment.name`) SELECT * FROM __spatial_aggregation_cte ORDER BY `__GROUP_BY_KEY_0_deployment.environment.name`, ts", q.Query)
}
// With the flag at its default, both the labels and the metric name stay
// literal.
func TestStatementBuilderKeepsFamiliesLiteralByDefault(t *testing.T) {
fl := flaggertest.WithBooleanFlags(t, map[string]bool{
flagger.FeatureResolveSemconvFamilies.String(): false,
})
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
mockMetadataStore.KeysMap = map[string][]*telemetrytypes.TelemetryFieldKey{}
for _, name := range []string{"deployment.environment.name", "deployment.environment"} {
mockMetadataStore.KeysMap[name] = []*telemetrytypes.TelemetryFieldKey{{
Name: name,
Signal: telemetrytypes.SignalMetrics,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
}}
}
statementBuilder := NewMetricQueryStatementBuilder(
instrumentationtest.New().ToProviderSettings(),
mockMetadataStore,
metricstelemetryschema.NewStorage(),
fl,
)
query := qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Signal: telemetrytypes.SignalMetrics,
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
Aggregations: []qbtypes.MetricAggregation{
{
MetricName: "k8s.pod.cpu.utilization",
Type: metrictypes.GaugeType,
Temporality: metrictypes.Unspecified,
TimeAggregation: metrictypes.TimeAggregationAvg,
SpaceAggregation: metrictypes.SpaceAggregationAvg,
},
},
Filter: &qbtypes.Filter{
Expression: "deployment.environment = 'production'",
},
GroupBy: []qbtypes.GroupByKey{
{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: "deployment.environment.name",
},
},
},
}
q, err := statementBuilder.Build(context.Background(), valuer.UUID{}, 1747947419000, 1747983448000, qbtypes.RequestTypeTimeSeries, query, nil)
require.NoError(t, err)
require.Equal(t, "WITH __temporal_aggregation_cte AS (SELECT fingerprint, toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(30)) AS ts, `__GROUP_BY_KEY_0_deployment.environment.name`, avg(value) AS per_series_value FROM signoz_metrics.distributed_samples_v4 AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'deployment.environment.name') AS `__GROUP_BY_KEY_0_deployment.environment.name` FROM signoz_metrics.time_series_v4_6hrs WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) AND JSONExtractString(labels, 'deployment.environment') = ? GROUP BY fingerprint, `__GROUP_BY_KEY_0_deployment.environment.name`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY fingerprint, ts, `__GROUP_BY_KEY_0_deployment.environment.name` ORDER BY fingerprint, ts), __spatial_aggregation_cte AS (SELECT ts, `__GROUP_BY_KEY_0_deployment.environment.name`, avg(per_series_value) AS value FROM __temporal_aggregation_cte WHERE isNaN(per_series_value) = ? GROUP BY ts, `__GROUP_BY_KEY_0_deployment.environment.name`) SELECT * FROM __spatial_aggregation_cte ORDER BY `__GROUP_BY_KEY_0_deployment.environment.name`, ts", q.Query)
require.Equal(t, []any{"k8s.pod.cpu.utilization", uint64(1747936800000), uint64(1747983420000), "unspecified", "production", "k8s.pod.cpu.utilization", uint64(1747947390000), uint64(1747983420000), 0}, q.Args)
}

View File

@@ -117,9 +117,7 @@ func (b *StatementBuilder) Build(
query qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation],
variables map[string]qbtypes.VariableItem,
) (*qbtypes.Statement, error) {
keySelectors := querybuilder.ExpandKeySelectorsForFamilies(ctx, orgID, b.flagger, GetKeySelectors(query))
metricNames := querybuilder.FamilyMetricNames(ctx, orgID, b.flagger, query.Aggregations[0].MetricName)
keySelectors = expandSelectorsForMetricNames(keySelectors, metricNames)
keySelectors := GetKeySelectors(query)
keys, _, err := b.metadataStore.GetKeysMulti(ctx, orgID, keySelectors)
if err != nil {
return nil, err
@@ -127,30 +125,7 @@ func (b *StatementBuilder) Build(
start, end = querybuilder.AdjustedMetricTimeRange(start, end, uint64(query.StepInterval.Seconds()), query)
return b.buildPipelineStatement(ctx, orgID, start, end, requestType, query, keys, metricNames, variables)
}
// expandSelectorsForMetricNames duplicates the selectors per family metric
// name. Label-key metadata is filtered by the exact metric_name.
func expandSelectorsForMetricNames(selectors []*telemetrytypes.FieldKeySelector, metricNames []string) []*telemetrytypes.FieldKeySelector {
if len(metricNames) <= 1 {
return selectors
}
out := selectors
for _, selector := range selectors {
if selector.MetricContext == nil {
continue
}
for _, metricName := range metricNames {
if metricName == selector.MetricContext.MetricName {
continue
}
expanded := *selector
expanded.MetricContext = &telemetrytypes.MetricContext{MetricName: metricName}
out = append(out, &expanded)
}
}
return out
return b.buildPipelineStatement(ctx, orgID, start, end, requestType, query, keys, variables)
}
func (b *StatementBuilder) buildPipelineStatement(
@@ -160,7 +135,6 @@ func (b *StatementBuilder) buildPipelineStatement(
requestType qbtypes.RequestType,
query qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation],
keys map[string][]*telemetrytypes.TelemetryFieldKey,
metricNames []string,
variables map[string]qbtypes.VariableItem,
) (*qbtypes.Statement, error) {
var (
@@ -192,13 +166,13 @@ func (b *StatementBuilder) buildPipelineStatement(
var filterWarnings []string
var err error
if timeSeriesCTE, timeSeriesCTEArgs, filterWarnings, err = b.buildTimeSeriesCTE(ctx, orgID, tsStart, tsEnd, cteQuery, keys, metricNames, variables, tsTable); err != nil {
if timeSeriesCTE, timeSeriesCTEArgs, filterWarnings, err = b.buildTimeSeriesCTE(ctx, orgID, tsStart, tsEnd, cteQuery, keys, variables, tsTable); err != nil {
return nil, err
}
if qbtypes.CanShortCircuitDelta(agg) {
// spatial_aggregation_cte directly for certain delta queries
if frag, args, err := b.buildTemporalAggDeltaFastPath(start, end, cteQuery, metricNames, samplesTable, timeSeriesCTE, timeSeriesCTEArgs); err != nil {
if frag, args, err := b.buildTemporalAggDeltaFastPath(start, end, cteQuery, samplesTable, timeSeriesCTE, timeSeriesCTEArgs); err != nil {
return nil, err
} else if frag != "" {
cteFragments = append(cteFragments, frag)
@@ -206,7 +180,7 @@ func (b *StatementBuilder) buildPipelineStatement(
}
} else {
// temporal_aggregation_cte
if frag, args, err := b.buildTemporalAggregationCTE(ctx, start, end, cteQuery, keys, metricNames, samplesTable, timeSeriesCTE, timeSeriesCTEArgs); err != nil {
if frag, args, err := b.buildTemporalAggregationCTE(ctx, start, end, cteQuery, keys, samplesTable, timeSeriesCTE, timeSeriesCTEArgs); err != nil {
return nil, err
} else if frag != "" {
cteFragments = append(cteFragments, frag)
@@ -227,16 +201,16 @@ func (b *StatementBuilder) buildPipelineStatement(
var tsArgs []any
// time series rows are written on hour boundaries
tsStart := start - (start % metricstelemetryschema.OneHourInMilliseconds)
if tsCTE, tsArgs, err = b.buildReducedTimeSeriesCTE(ctx, orgID, tsStart, end, cteQuery, keys, metricNames, variables); err != nil {
if tsCTE, tsArgs, err = b.buildReducedTimeSeriesCTE(ctx, orgID, tsStart, end, cteQuery, keys, variables); err != nil {
return nil, err
}
if qbtypes.CanShortCircuitReduced(agg) {
// spatial_aggregation_cte directly, no per-series level
if spatialFrag, spatialArgs, ok := b.buildReducedSpatialAggFastPath(start, end, cteQuery, metricNames, tsCTE, tsArgs); ok {
if spatialFrag, spatialArgs, ok := b.buildReducedSpatialAggFastPath(start, end, cteQuery, tsCTE, tsArgs); ok {
reducedFragments = []string{spatialFrag}
reducedArgs = [][]any{spatialArgs}
}
} else if temporalFrag, temporalArgs, ok := b.buildReducedTemporalAggregationCTE(start, end, cteQuery, metricNames, tsCTE, tsArgs); ok {
} else if temporalFrag, temporalArgs, ok := b.buildReducedTemporalAggregationCTE(start, end, cteQuery, tsCTE, tsArgs); ok {
spatialFrag, spatialArgs := b.buildReducedSpatialAggregationCTE(cteQuery)
reducedFragments = []string{temporalFrag, spatialFrag}
reducedArgs = [][]any{temporalArgs, spatialArgs}
@@ -294,7 +268,6 @@ func (b *StatementBuilder) buildReducedTimeSeriesCTE(
start, end uint64,
query qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation],
keys map[string][]*telemetrytypes.TelemetryFieldKey,
metricNames []string,
variables map[string]qbtypes.VariableItem,
) (string, []any, error) {
sb := sqlbuilder.NewSelectBuilder()
@@ -327,7 +300,7 @@ func (b *StatementBuilder) buildReducedTimeSeriesCTE(
sb.SelectMore(sqlbuilder.Escape(fmt.Sprintf("%s AS %s", col, GroupByColumnAlias(i, g.Name))))
}
sb.Where(
sb.In("metric_name", sqlbuilder.List(metricNames)),
sb.In("metric_name", query.Aggregations[0].MetricName),
sb.GTE("unix_milli", start),
sb.LTE("unix_milli", end),
)
@@ -352,7 +325,6 @@ func (b *StatementBuilder) buildReducedTimeSeriesCTE(
func (b *StatementBuilder) buildReducedSpatialAggFastPath(
start, end uint64,
query qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation],
metricNames []string,
timeSeriesCTE string,
timeSeriesCTEArgs []any,
) (string, []any, bool) {
@@ -373,7 +345,7 @@ func (b *StatementBuilder) buildReducedSpatialAggFastPath(
sb.From(fmt.Sprintf("%s.%s AS points FINAL", metricstelemetryschema.DBName, metricstelemetryschema.WhichReducedSamplesTableToUse(agg.Type)))
sb.JoinWithOption(sqlbuilder.InnerJoin, timeSeriesCTE, "points.reduced_fingerprint = filtered_time_series.fingerprint")
sb.Where(
sb.In("metric_name", sqlbuilder.List(metricNames)),
sb.In("metric_name", agg.MetricName),
sb.GTE("unix_milli", start),
sb.LT("unix_milli", end),
)
@@ -387,7 +359,6 @@ func (b *StatementBuilder) buildReducedSpatialAggFastPath(
func (b *StatementBuilder) buildReducedTemporalAggregationCTE(
start, end uint64,
query qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation],
metricNames []string,
timeSeriesCTE string,
timeSeriesCTEArgs []any,
) (string, []any, bool) {
@@ -416,7 +387,7 @@ func (b *StatementBuilder) buildReducedTemporalAggregationCTE(
sb.From(fmt.Sprintf("%s.%s AS points FINAL", metricstelemetryschema.DBName, metricstelemetryschema.WhichReducedSamplesTableToUse(agg.Type)))
sb.JoinWithOption(sqlbuilder.InnerJoin, timeSeriesCTE, "points.reduced_fingerprint = filtered_time_series.fingerprint")
sb.Where(
sb.In("metric_name", sqlbuilder.List(metricNames)),
sb.In("metric_name", agg.MetricName),
sb.GTE("unix_milli", start),
sb.LT("unix_milli", end),
)
@@ -457,7 +428,6 @@ func (b *StatementBuilder) buildReducedSpatialAggregationCTE(
func (b *StatementBuilder) buildTemporalAggDeltaFastPath(
start, end uint64,
query qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation],
metricNames []string,
samplesTable string,
timeSeriesCTE string,
timeSeriesCTEArgs []any,
@@ -499,7 +469,7 @@ func (b *StatementBuilder) buildTemporalAggDeltaFastPath(
sb.From(fmt.Sprintf("%s.%s AS points", metricstelemetryschema.DBName, samplesTable))
sb.JoinWithOption(sqlbuilder.InnerJoin, timeSeriesCTE, "points.fingerprint = filtered_time_series.fingerprint")
sb.Where(
sb.In("metric_name", sqlbuilder.List(metricNames)),
sb.In("metric_name", query.Aggregations[0].MetricName),
sb.GTE("unix_milli", start),
sb.LT("unix_milli", end),
)
@@ -516,7 +486,6 @@ func (b *StatementBuilder) buildTimeSeriesCTE(
start, end uint64,
query qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation],
keys map[string][]*telemetrytypes.TelemetryFieldKey,
metricNames []string,
variables map[string]qbtypes.VariableItem,
tsTable string,
) (string, []any, []string, error) {
@@ -553,7 +522,7 @@ func (b *StatementBuilder) buildTimeSeriesCTE(
}
sb.Where(
sb.In("metric_name", sqlbuilder.List(metricNames)),
sb.In("metric_name", query.Aggregations[0].MetricName),
sb.GTE("unix_milli", start),
sb.LTE("unix_milli", end),
)
@@ -585,24 +554,22 @@ func (b *StatementBuilder) buildTemporalAggregationCTE(
start, end uint64,
query qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation],
_ map[string][]*telemetrytypes.TelemetryFieldKey,
metricNames []string,
samplesTable string,
timeSeriesCTE string,
timeSeriesCTEArgs []any,
) (string, []any, error) {
if query.Aggregations[0].Temporality == metrictypes.Delta {
return b.buildTemporalAggDelta(ctx, start, end, query, metricNames, samplesTable, timeSeriesCTE, timeSeriesCTEArgs)
return b.buildTemporalAggDelta(ctx, start, end, query, samplesTable, timeSeriesCTE, timeSeriesCTEArgs)
} else if query.Aggregations[0].Temporality != metrictypes.Multiple {
return b.buildTemporalAggCumulativeOrUnspecified(ctx, start, end, query, metricNames, samplesTable, timeSeriesCTE, timeSeriesCTEArgs)
return b.buildTemporalAggCumulativeOrUnspecified(ctx, start, end, query, samplesTable, timeSeriesCTE, timeSeriesCTEArgs)
}
return b.buildTemporalAggForMultipleTemporalities(ctx, start, end, query, metricNames, samplesTable, timeSeriesCTE, timeSeriesCTEArgs)
return b.buildTemporalAggForMultipleTemporalities(ctx, start, end, query, samplesTable, timeSeriesCTE, timeSeriesCTEArgs)
}
func (b *StatementBuilder) buildTemporalAggDelta(
_ context.Context,
start, end uint64,
query qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation],
metricNames []string,
samplesTable string,
timeSeriesCTE string,
timeSeriesCTEArgs []any,
@@ -634,7 +601,7 @@ func (b *StatementBuilder) buildTemporalAggDelta(
sb.From(fmt.Sprintf("%s.%s AS points", metricstelemetryschema.DBName, samplesTable))
sb.JoinWithOption(sqlbuilder.InnerJoin, timeSeriesCTE, "points.fingerprint = filtered_time_series.fingerprint")
sb.Where(
sb.In("metric_name", sqlbuilder.List(metricNames)),
sb.In("metric_name", query.Aggregations[0].MetricName),
sb.GTE("unix_milli", start),
sb.LT("unix_milli", end),
)
@@ -650,7 +617,6 @@ func (b *StatementBuilder) buildTemporalAggCumulativeOrUnspecified(
_ context.Context,
start, end uint64,
query qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation],
metricNames []string,
samplesTable string,
timeSeriesCTE string,
timeSeriesCTEArgs []any,
@@ -676,7 +642,7 @@ func (b *StatementBuilder) buildTemporalAggCumulativeOrUnspecified(
baseSb.From(fmt.Sprintf("%s.%s AS points", metricstelemetryschema.DBName, samplesTable))
baseSb.JoinWithOption(sqlbuilder.InnerJoin, timeSeriesCTE, "points.fingerprint = filtered_time_series.fingerprint")
baseSb.Where(
baseSb.In("metric_name", sqlbuilder.List(metricNames)),
baseSb.In("metric_name", query.Aggregations[0].MetricName),
baseSb.GTE("unix_milli", start),
baseSb.LT("unix_milli", end),
)
@@ -717,7 +683,6 @@ func (b *StatementBuilder) buildTemporalAggForMultipleTemporalities(
_ context.Context,
start, end uint64,
query qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation],
metricNames []string,
samplesTable string,
timeSeriesCTE string,
timeSeriesCTEArgs []any,
@@ -768,7 +733,7 @@ func (b *StatementBuilder) buildTemporalAggForMultipleTemporalities(
sb.From(fmt.Sprintf("%s.%s AS points", metricstelemetryschema.DBName, samplesTable))
sb.JoinWithOption(sqlbuilder.InnerJoin, timeSeriesCTE, "points.fingerprint = filtered_time_series.fingerprint")
sb.Where(
sb.In("metric_name", sqlbuilder.List(metricNames)),
sb.In("metric_name", query.Aggregations[0].MetricName),
sb.GTE("unix_milli", start),
sb.LT("unix_milli", end),
)

View File

@@ -2062,51 +2062,3 @@ func TestStatementBuilderSemconvFamilies(t *testing.T) {
})
}
}
// The mid-migration state: metadata holds only the old spelling, and the
// query names the current one. The resource-filter condition and the group
// by column both read the stored spelling, so the filter and the groups
// agree.
func TestStatementBuilderSemconvSingleSpelling(t *testing.T) {
fl := flaggertest.WithBooleanFlags(t, map[string]bool{
flagger.FeatureResolveSemconvFamilies.String(): true,
})
storage := tracestelemetryschema.NewStorage()
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
mockMetadataStore.KeysMap = map[string][]*telemetrytypes.TelemetryFieldKey{
"deployment.environment": {{
Name: "deployment.environment",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
}},
}
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage, fl, telemetrytypes.SignalTraces)
statementBuilder := NewTraceQueryStatementBuilder(
instrumentationtest.New().ToProviderSettings(),
mockMetadataStore,
storage,
aggExprRewriter,
nil,
fl,
false,
100000,
)
query := qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Signal: telemetrytypes.SignalTraces,
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
Aggregations: []qbtypes.TraceAggregation{{Expression: "count()"}},
Filter: &qbtypes.Filter{
Expression: "deployment.environment.name = 'production'",
},
GroupBy: []qbtypes.GroupByKey{
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "deployment.environment.name"}},
},
}
q, err := statementBuilder.Build(context.Background(), valuer.UUID{}, 1747947419000, 1747983448000, qbtypes.RequestTypeScalar, query, nil)
require.NoError(t, err)
require.Equal(t, "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'deployment.environment') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT toString(multiIf(resource.`deployment.environment` IS NOT NULL, resource.`deployment.environment`::String, mapContains(resources_string, 'deployment.environment'), resources_string['deployment.environment'], NULL)) AS `__GROUP_BY_KEY_0_deployment.environment.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY `__GROUP_BY_KEY_0_deployment.environment.name` ORDER BY __result_0 DESC", q.Query)
}

View File

@@ -1,150 +0,0 @@
package telemetrymetadata
import (
"context"
"testing"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/flagger/flaggertest"
"github.com/SigNoz/signoz/pkg/querybuilder"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/huandu/go-sqlbuilder"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// The flagger provider registration is process-global and keyed by provider
// name, so each flagger must be used before the next one is created.
func TestFamilyValueNames(t *testing.T) {
selector := &telemetrytypes.FieldValueSelector{
FieldKeySelector: &telemetrytypes.FieldKeySelector{Name: "deployment.environment"},
}
off := &telemetryMetaStore{fl: flaggertest.WithBooleanFlags(t, map[string]bool{})}
assert.Equal(t,
[]string{"deployment.environment"},
off.familyValueNames(context.Background(), valuer.UUID{}, telemetrytypes.SignalLogs, selector),
"the flag default keeps values literal")
on := &telemetryMetaStore{fl: flaggertest.WithBooleanFlags(t, map[string]bool{
flagger.FeatureResolveSemconvFamilies.String(): true,
})}
assert.Equal(t,
[]string{"deployment.environment.name", "deployment.environment"},
on.familyValueNames(context.Background(), valuer.UUID{}, telemetrytypes.SignalLogs, selector),
"values for one spelling must cover the whole family")
assert.Equal(t,
[]string{"deployment.environment.name", "deployment.environment"},
on.familyValueNames(context.Background(), valuer.UUID{}, telemetrytypes.SignalMetrics, selector),
"metric values cover the family members")
spanMetric := &telemetrytypes.FieldValueSelector{
FieldKeySelector: &telemetrytypes.FieldKeySelector{
Name: "deployment.environment",
MetricContext: &telemetrytypes.MetricContext{MetricName: "signoz_calls_total"},
},
}
assert.Equal(t,
[]string{
"deployment.environment.name", "resource_deployment.environment.name",
"deployment.environment", "resource_deployment.environment",
},
on.familyValueNames(context.Background(), valuer.UUID{}, telemetrytypes.SignalMetrics, spanMetric),
"span-metrics values cover the resource_ spellings too")
}
// A family condition on the related values table follows the shared guard
// rule: the operator applies to the current-first merge, a positive operator
// takes the presence guard, and a negative operator keeps the keyless rows.
func TestConditionForFamilyMergedSemantics(t *testing.T) {
fl := flaggertest.WithBooleanFlags(t, map[string]bool{
flagger.FeatureResolveSemconvFamilies.String(): true,
})
storage := NewStorage()
fieldKeys := map[string][]*telemetrytypes.TelemetryFieldKey{
"deployment.environment.name": {{
Name: "deployment.environment.name",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
}},
"deployment.environment": {{
Name: "deployment.environment",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
}},
}
testCases := []struct {
name string
operator qbtypes.FilterOperator
expected string
}{
{
name: "Equal_TakesPresenceGuard",
operator: qbtypes.FilterOperatorEqual,
expected: "SELECT 1 WHERE (COALESCE(NULLIF(resource_attributes['deployment.environment.name'], ''), NULLIF(resource_attributes['deployment.environment'], ''), '') = ? AND (mapContains(resource_attributes, 'deployment.environment.name') OR mapContains(resource_attributes, 'deployment.environment')))",
},
{
name: "NotEqual_KeepsKeylessRows",
operator: qbtypes.FilterOperatorNotEqual,
expected: "SELECT 1 WHERE COALESCE(NULLIF(resource_attributes['deployment.environment.name'], ''), NULLIF(resource_attributes['deployment.environment'], ''), '') <> ?",
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
sb := sqlbuilder.NewSelectBuilder()
q := querybuilder.NewQueryInfo(context.Background(), valuer.UUID{}, fl, telemetrytypes.SignalTraces, nil, 0, 0)
conds, _, err := querybuilder.Conditions(context.Background(), q, storage,
&telemetrytypes.TelemetryFieldKey{Name: "deployment.environment"}, testCase.operator, "production", fieldKeys, false, sb)
require.NoError(t, err)
sb.Select("1").Where(conds...)
sql, _ := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
assert.Equal(t, testCase.expected, sql)
})
}
}
// The related values search applies to the current-first merge that the
// suggestion shows, also when the request carries no data type.
func TestContainsConditionsSearchesFamilyAsOneField(t *testing.T) {
fl := flaggertest.WithBooleanFlags(t, map[string]bool{
flagger.FeatureResolveSemconvFamilies.String(): true,
})
q := querybuilder.NewQueryInfo(context.Background(), valuer.UUID{}, fl, telemetrytypes.SignalTraces, nil, 0, 0)
store := &telemetryMetaStore{storage: NewStorage()}
key := &telemetrytypes.TelemetryFieldKey{
Name: "deployment.environment",
Signal: telemetrytypes.SignalTraces,
FieldContext: telemetrytypes.FieldContextResource,
}
testCases := []struct {
name string
names []string
expected string
}{
{
name: "FamilySpellings_MergeIntoOneCondition",
names: []string{"deployment.environment.name", "deployment.environment"},
expected: "SELECT 1 WHERE (LOWER(COALESCE(NULLIF(resource_attributes['deployment.environment.name'], ''), NULLIF(resource_attributes['deployment.environment'], ''), '')) LIKE LOWER(?) AND (mapContains(resource_attributes, 'deployment.environment.name') OR mapContains(resource_attributes, 'deployment.environment')))",
},
{
name: "SingleSpelling_StaysLiteral",
names: []string{"deployment.environment"},
expected: "SELECT 1 WHERE if(mapContains(resource_attributes, ?), LOWER(resource_attributes['deployment.environment']) LIKE LOWER(?), false)",
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
sb := sqlbuilder.NewSelectBuilder()
conds, err := store.containsConditions(context.Background(), q, key, testCase.names, "prod", sb)
require.NoError(t, err)
sb.Select("1").Where(conds...)
sql, _ := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
assert.Equal(t, testCase.expected, sql)
})
}
}

View File

@@ -15,7 +15,6 @@ import (
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/flagger"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/semconv"
"github.com/SigNoz/signoz/pkg/telemetryschema/audittelemetryschema"
"github.com/SigNoz/signoz/pkg/telemetryschema/logstelemetryschema"
"github.com/SigNoz/signoz/pkg/telemetryschema/metertelemetryschema"
@@ -1293,41 +1292,25 @@ func (t *telemetryMetaStore) getRelatedValues(ctx context.Context, orgID valuer.
FieldDataType: fieldValueSelector.FieldDataType,
}
q := querybuilder.NewQueryInfo(ctx, orgID, t.fl, fieldValueSelector.Signal, nil, 0, 0)
// One column per family spelling, merged current-first, so the
// suggestions cover rows that carry only an old spelling.
names := t.familyValueNames(ctx, orgID, fieldValueSelector.Signal, fieldValueSelector)
memberColumns := make([]string, 0, len(names))
for _, name := range names {
memberKey := &telemetrytypes.TelemetryFieldKey{
Name: name,
Signal: fieldValueSelector.Signal,
FieldContext: fieldValueSelector.FieldContext,
FieldDataType: fieldValueSelector.FieldDataType,
}
memberRead, err := t.storage.Read(ctx, q, memberKey)
memberColumn := memberRead.SQL
if err != nil {
// we don't have a explicit column to select from the related metadata table
// so we will select either from resource_attributes or attributes table
// in that order
resourceRead, _ := t.storage.Read(ctx, q, &telemetrytypes.TelemetryFieldKey{
Name: name,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
})
attributeRead, _ := t.storage.Read(ctx, q, &telemetrytypes.TelemetryFieldKey{
Name: name,
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeString,
})
memberColumn = fmt.Sprintf("if(notEmpty(%s), %s, %s)", resourceRead.SQL, resourceRead.SQL, attributeRead.SQL)
}
memberColumns = append(memberColumns, memberColumn)
}
selectColumn := memberColumns[len(memberColumns)-1]
for i := len(memberColumns) - 2; i >= 0; i-- {
selectColumn = fmt.Sprintf("if(notEmpty(%s), %s, %s)", memberColumns[i], memberColumns[i], selectColumn)
q := querybuilder.NewQueryInfo(ctx, orgID, nil, fieldValueSelector.Signal, nil, 0, 0)
selectRead, err := t.storage.Read(ctx, q, key)
selectColumn := selectRead.SQL
if err != nil {
// we don't have a explicit column to select from the related metadata table
// so we will select either from resource_attributes or attributes table
// in that order
resourceRead, _ := t.storage.Read(ctx, q, &telemetrytypes.TelemetryFieldKey{
Name: key.Name,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
})
attributeRead, _ := t.storage.Read(ctx, q, &telemetrytypes.TelemetryFieldKey{
Name: key.Name,
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeString,
})
selectColumn = fmt.Sprintf("if(notEmpty(%s), %s, %s)", resourceRead.SQL, resourceRead.SQL, attributeRead.SQL)
}
sb := sqlbuilder.Select("DISTINCT " + selectColumn).From(t.relatedMetadataDBName + "." + t.relatedMetadataTblName)
@@ -1337,7 +1320,6 @@ func (t *telemetryMetaStore) getRelatedValues(ctx context.Context, orgID valuer.
for _, keySelector := range keySelectors {
keySelector.Signal = fieldValueSelector.Signal
}
keySelectors = querybuilder.ExpandKeySelectorsForFamilies(ctx, orgID, t.fl, keySelectors)
keys, _, err := t.GetKeysMulti(ctx, orgID, keySelectors)
if err != nil {
return nil, false, err
@@ -1379,20 +1361,20 @@ func (t *telemetryMetaStore) getRelatedValues(ctx context.Context, orgID valuer.
// search on attributes
key.FieldContext = telemetrytypes.FieldContextAttribute
attrConds, err := t.containsConditions(ctx, q, key, names, fieldValueSelector.Value, sb)
attrConds, err := t.containsConditions(ctx, q, key, fieldValueSelector.Value, sb)
if err == nil {
conds = append(conds, attrConds...)
}
// search on resource
key.FieldContext = telemetrytypes.FieldContextResource
resourceConds, err := t.containsConditions(ctx, q, key, names, fieldValueSelector.Value, sb)
resourceConds, err := t.containsConditions(ctx, q, key, fieldValueSelector.Value, sb)
if err == nil {
conds = append(conds, resourceConds...)
}
key.FieldContext = origContext
} else {
keyConds, err := t.containsConditions(ctx, q, key, names, fieldValueSelector.Value, sb)
keyConds, err := t.containsConditions(ctx, q, key, fieldValueSelector.Value, sb)
if err == nil {
conds = append(conds, keyConds...)
}
@@ -1450,7 +1432,7 @@ func (t *telemetryMetaStore) GetRelatedValues(ctx context.Context, orgID valuer.
return t.getRelatedValues(ctx, orgID, fieldValueSelector)
}
func (t *telemetryMetaStore) getSpanFieldValues(ctx context.Context, orgID valuer.UUID, fieldValueSelector *telemetrytypes.FieldValueSelector) (*telemetrytypes.TelemetryFieldValues, bool, error) {
func (t *telemetryMetaStore) getSpanFieldValues(ctx context.Context, fieldValueSelector *telemetrytypes.FieldValueSelector) (*telemetrytypes.TelemetryFieldValues, bool, error) {
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalTraces.StringValue(),
instrumentationtypes.CodeNamespace: "metadata",
@@ -1461,12 +1443,11 @@ func (t *telemetryMetaStore) getSpanFieldValues(ctx context.Context, orgID value
return values, true, nil
}
knownBool := isKnownBoolField(fieldValueSelector, tracestelemetryschema.IntrinsicFields, tracestelemetryschema.CalculatedFields)
names := t.familyValueNames(ctx, orgID, telemetrytypes.SignalTraces, fieldValueSelector)
// unix_milli is the hour of the span start
return t.getTagTableValues(ctx, t.tracesDBName+"."+t.tracesFieldsTblName, fieldValueSelector, names, knownBool)
return t.getTagTableValues(ctx, t.tracesDBName+"."+t.tracesFieldsTblName, fieldValueSelector, knownBool)
}
func (t *telemetryMetaStore) getLogFieldValues(ctx context.Context, orgID valuer.UUID, fieldValueSelector *telemetrytypes.FieldValueSelector) (*telemetrytypes.TelemetryFieldValues, bool, error) {
func (t *telemetryMetaStore) getLogFieldValues(ctx context.Context, fieldValueSelector *telemetrytypes.FieldValueSelector) (*telemetrytypes.TelemetryFieldValues, bool, error) {
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
instrumentationtypes.TelemetrySignal: telemetrytypes.SignalLogs.StringValue(),
instrumentationtypes.CodeNamespace: "metadata",
@@ -1474,18 +1455,8 @@ func (t *telemetryMetaStore) getLogFieldValues(ctx context.Context, orgID valuer
})
knownBool := isKnownBoolField(fieldValueSelector, logstelemetryschema.IntrinsicFields)
names := t.familyValueNames(ctx, orgID, telemetrytypes.SignalLogs, fieldValueSelector)
// unix_milli is the hour the log was ingested, not the log's own timestamp
return t.getTagTableValues(ctx, t.logsDBName+"."+t.logsFieldsTblName, fieldValueSelector, names, knownBool)
}
// tagKeyCondition matches the requested key, or every spelling of its
// family when there is more than one.
func tagKeyCondition(sb *sqlbuilder.SelectBuilder, name string, names []string) string {
if len(names) > 1 {
return sb.In("tag_key", sqlbuilder.List(names))
}
return sb.E("tag_key", name)
return t.getTagTableValues(ctx, t.logsDBName+"."+t.logsFieldsTblName, fieldValueSelector, knownBool)
}
// tagTableSinceDay restricts rows to the tag table's day partitions from the
@@ -1501,9 +1472,9 @@ func tagTableSinceDay(sb *sqlbuilder.SelectBuilder, startUnixMilli int64) {
// tagTableHasBoolRows reports whether the tag table holds a bool row for the
// key. Bool rows carry no value, so one row is enough to know the key takes
// the values true and false.
func (t *telemetryMetaStore) tagTableHasBoolRows(ctx context.Context, table string, selector *telemetrytypes.FieldValueSelector, names []string) (bool, error) {
func (t *telemetryMetaStore) tagTableHasBoolRows(ctx context.Context, table string, selector *telemetrytypes.FieldValueSelector) (bool, error) {
sb := sqlbuilder.Select("1").From(table)
sb.Where(tagKeyCondition(sb, selector.Name, names))
sb.Where(sb.E("tag_key", selector.Name))
sb.Where(sb.E("tag_data_type", telemetrytypes.FieldDataTypeBool.TagDataType()))
if selector.FieldContext != telemetrytypes.FieldContextUnspecified {
sb.Where(sb.E("tag_type", selector.FieldContext.TagType()))
@@ -1523,7 +1494,7 @@ func (t *telemetryMetaStore) tagTableHasBoolRows(ctx context.Context, table stri
// getTagTableValues returns the string and number values of the key from a
// tag table, and true and false when the key is a known bool field or the
// table holds bool rows for it. Bool rows do not count towards the limit.
func (t *telemetryMetaStore) getTagTableValues(ctx context.Context, table string, fieldValueSelector *telemetrytypes.FieldValueSelector, names []string, knownBool bool) (*telemetrytypes.TelemetryFieldValues, bool, error) {
func (t *telemetryMetaStore) getTagTableValues(ctx context.Context, table string, fieldValueSelector *telemetrytypes.FieldValueSelector, knownBool bool) (*telemetrytypes.TelemetryFieldValues, bool, error) {
limit := fieldValueSelector.Limit
if limit == 0 {
limit = 50
@@ -1536,7 +1507,7 @@ func (t *telemetryMetaStore) getTagTableValues(ctx context.Context, table string
return values, true, nil
}
} else if fieldValueSelector.FieldDataType == telemetrytypes.FieldDataTypeUnspecified {
hasBoolRows, err := t.tagTableHasBoolRows(ctx, table, fieldValueSelector, names)
hasBoolRows, err := t.tagTableHasBoolRows(ctx, table, fieldValueSelector)
if err != nil {
return nil, false, err
}
@@ -1548,7 +1519,7 @@ func (t *telemetryMetaStore) getTagTableValues(ctx context.Context, table string
sb := sqlbuilder.Select("DISTINCT string_value, number_value").From(table)
if fieldValueSelector.Name != "" {
sb.Where(tagKeyCondition(sb, fieldValueSelector.Name, names))
sb.Where(sb.E("tag_key", fieldValueSelector.Name))
}
sb.Where(sb.NE("tag_data_type", telemetrytypes.FieldDataTypeBool.TagDataType()))
@@ -1748,11 +1719,7 @@ func (t *telemetryMetaStore) getMetricFieldValues(ctx context.Context, orgID val
From(t.metricsDBName + "." + t.metricsFieldsTblName)
if fieldValueSelector.Name != "" {
if names := t.familyValueNames(ctx, orgID, telemetrytypes.SignalMetrics, fieldValueSelector); len(names) > 1 {
sb.Where(sb.In("attr_name", sqlbuilder.List(names)))
} else {
sb.Where(sb.E("attr_name", fieldValueSelector.Name))
}
sb.Where(sb.E("attr_name", fieldValueSelector.Name))
}
if fieldValueSelector.FieldContext != telemetrytypes.FieldContextUnspecified {
@@ -1764,11 +1731,7 @@ func (t *telemetryMetaStore) getMetricFieldValues(ctx context.Context, orgID val
}
if fieldValueSelector.MetricContext != nil && fieldValueSelector.MetricContext.MetricName != "" {
if metricNames := querybuilder.FamilyMetricNames(ctx, orgID, t.fl, fieldValueSelector.MetricContext.MetricName); len(metricNames) > 1 {
sb.Where(sb.In("metric_name", sqlbuilder.List(metricNames)))
} else {
sb.Where(sb.E("metric_name", fieldValueSelector.MetricContext.MetricName))
}
sb.Where(sb.E("metric_name", fieldValueSelector.MetricContext.MetricName))
}
if fieldValueSelector.MetricContext != nil && fieldValueSelector.MetricContext.MetricNamespace != "" {
sb.Where(sb.Like("metric_name", clickhousesql.LikePattern(fieldValueSelector.MetricContext.MetricNamespace)+"%"))
@@ -2090,12 +2053,12 @@ func (t *telemetryMetaStore) GetAllValues(ctx context.Context, orgID valuer.UUID
switch fieldValueSelector.Signal {
case telemetrytypes.SignalTraces:
values, complete, err = t.getSpanFieldValues(ctx, orgID, fieldValueSelector)
values, complete, err = t.getSpanFieldValues(ctx, fieldValueSelector)
case telemetrytypes.SignalLogs:
if fieldValueSelector.Source == telemetrytypes.SourceAudit {
values, complete, err = t.getAuditFieldValues(ctx, fieldValueSelector)
} else {
values, complete, err = t.getLogFieldValues(ctx, orgID, fieldValueSelector)
values, complete, err = t.getLogFieldValues(ctx, fieldValueSelector)
}
case telemetrytypes.SignalMetrics:
if fieldValueSelector.Source == telemetrytypes.SourceMeter {
@@ -2108,13 +2071,13 @@ func (t *telemetryMetaStore) GetAllValues(ctx context.Context, orgID valuer.UUID
mapOfRelatedValues := make(map[any]bool)
allUnspecifiedValues := &telemetrytypes.TelemetryFieldValues{}
tracesValues, tracesComplete, err := t.getSpanFieldValues(ctx, orgID, fieldValueSelector)
tracesValues, tracesComplete, err := t.getSpanFieldValues(ctx, fieldValueSelector)
if err == nil {
populateComplete := populateAllUnspecifiedValues(allUnspecifiedValues, mapOfValues, mapOfRelatedValues, tracesValues, limit)
complete = complete && tracesComplete && populateComplete
}
logsValues, logsComplete, err := t.getLogFieldValues(ctx, orgID, fieldValueSelector)
logsValues, logsComplete, err := t.getLogFieldValues(ctx, fieldValueSelector)
if err == nil {
populateComplete := populateAllUnspecifiedValues(allUnspecifiedValues, mapOfValues, mapOfRelatedValues, logsValues, limit)
complete = complete && logsComplete && populateComplete
@@ -2626,42 +2589,10 @@ func (t *telemetryMetaStore) fetchLastSeenInfoForTable(ctx context.Context, tabl
return lastSeenInfo, nil
}
// containsConditions compiles a contains search over the key and its family
// spellings, which stand as their own metadata.
func (t *telemetryMetaStore) containsConditions(ctx context.Context, q qbtypes.QueryInfo, key *telemetrytypes.TelemetryFieldKey, names []string, value string, sb *sqlbuilder.SelectBuilder) ([]string, error) {
// A family forms only over string keys with a signal. The table stores
// only string maps, so an unspecified data type is a string here.
dataType := key.FieldDataType
if dataType == telemetrytypes.FieldDataTypeUnspecified {
dataType = telemetrytypes.FieldDataTypeString
}
fieldKeys := make(map[string][]*telemetrytypes.TelemetryFieldKey, len(names))
for _, name := range names {
fieldKeys[name] = []*telemetrytypes.TelemetryFieldKey{{
Name: name,
Signal: key.Signal,
FieldContext: key.FieldContext,
FieldDataType: dataType,
}}
}
// containsConditions compiles a contains search on one key of the related
// values table. The key is its own metadata.
func (t *telemetryMetaStore) containsConditions(ctx context.Context, q qbtypes.QueryInfo, key *telemetrytypes.TelemetryFieldKey, value string, sb *sqlbuilder.SelectBuilder) ([]string, error) {
fieldKeys := map[string][]*telemetrytypes.TelemetryFieldKey{key.Name: {key}}
conds, _, err := querybuilder.Conditions(ctx, q, t.storage, key, qbtypes.FilterOperatorContains, value, fieldKeys, false, sb)
return conds, err
}
// familyValueNames returns the spellings whose values merge into the
// suggestions. With the flag off, the requested name alone.
func (t *telemetryMetaStore) familyValueNames(ctx context.Context, orgID valuer.UUID, signal telemetrytypes.Signal, fieldValueSelector *telemetrytypes.FieldValueSelector) []string {
if !querybuilder.SemconvFamiliesEnabled(ctx, orgID, t.fl) {
return []string{fieldValueSelector.Name}
}
selector := telemetrytypes.FieldKeySelector{
Name: fieldValueSelector.Name,
Signal: signal,
FieldContext: fieldValueSelector.FieldContext,
MetricContext: fieldValueSelector.MetricContext,
}
if signal == telemetrytypes.SignalMetrics {
return querybuilder.MetricLabelSpellings(selector)
}
return semconv.Members(semconv.KindAttribute, selector)
}

Some files were not shown because too many files have changed in this diff Show More