Compare commits

..

16 Commits

Author SHA1 Message Date
Naman Verma
283504da3f Merge branch 'main' into nv/cache-grid 2026-11-03 00:57:09 +05:30
Vikrant Gupta
ff6e0a26fc feat(roles): pick the grant key in the telemetry selector wizard (#13226)
#### Description

- The role wizard's Key field becomes a dropdown of the supported
telemetry grant keys, `signoz.workspace.key.id`, `resource.service.name`
and `resource.deployment.environment.name`, instead of a fixed value. A
typed selector selects its key in the dropdown, and
`resource.signoz.workspace.key.id` folds to the bare workspace key as
the backend does.
- Covered by the Role Editor Storybook story (`Telemetry Key Dropdown`,
`Telemetry Key Scoped`).

#### Issues closed by this PR

Contributes to SigNoz/keystone-pod#73
2026-11-02 18:39:53 +00:00
Nikhil Mantri
a02ea41446 feat(authz): downtime schedule FGA (#13250)
#### Description

- Move `/api/v1/downtime_schedules` routes from role gates to
`CheckResources` with resource defs.
- Create and update check `rule:attach` on every id in `alertIds`.
Update diffs against the stored schedule: attach for added, detach for
removed.
- Migration 142 backfills planned-maintenance tuples plus attach and
detach on planned-maintenance and rule for admin and editor.
- Integration tests cover viewer, editor, admin, and a custom role
without rule attach or detach.

#### Issues closed by this PR

Closes SigNoz/pulse-pod#289
Part of SigNoz/pulse-pod#355

#### Additional Information

Second of five stacked PRs splitting #12927. Stacked on #13249.

---------

Co-authored-by: Naman Verma <naman.verma@signoz.io>
2026-11-02 18:29:40 +00:00
Aditya Singh
c63f818789 feat(traces-explorer): fetch all span fields in the list view (#13275)
#### Description

- The traces explorer list view no longer sends the selected columns as
`selectFields`. The API now returns every span field for each row. The
span details drawer in the next PR uses this data, so it can open
without another request.
- Adding, removing or reordering a column no longer refetches the list.
- The table reads attribute and resource columns from the `attributes`
and `resource` maps on each row. It uses `getSpanAttribute`, the same
lookup as trace details.
- `getSpanAttribute` now keeps `false` values. Before, boolean `false`
attributes showed "-" in the table and dropped out of the trace details
hover previews. Color-by now treats `0` and `false` as real values, not
`unknown`.

#### Issues closed by this PR

Part of https://github.com/SigNoz/events-pod/issues/195


#### Screenshots/Screen recording

No refetch on add/remove columns


https://github.com/user-attachments/assets/799b39b3-3594-441e-b5dc-0c8177c29afb

All fields fetched

<img width="1687" height="1000" alt="all fields fetched"
src="https://github.com/user-attachments/assets/af8dd374-6b30-40a5-92dc-caa07defc463"
/>



#### Additional Information

- Two backend gaps show "-" in some columns:
- `scope.name` and `scope.version`: the all-fields mode does not select
the `scope` column. Value suggestions for these fields are also empty.
The fix is to add `scope` to `ContextualSpanColumns`.
- Old camelCase column names (`durationNano`, `httpMethod`, ...) in
saved views and column preferences: the backend resolved them only when
they were in `selectFields`. The fix is a backend migration that renames
them. The workaround is to remove the column and add it again.
- Each row is larger, because the response carries all span fields.
- Export and add to dashboard still use the selected columns.
- The List view story rows now use the nested `resource` shape. The page
renders the same, so the existing story covers it.
- cc. @tewarig
2026-11-02 17:56:08 +00:00
Naman Verma
0e0b9a7a2c chore: switch to get many in put 2026-11-02 22:04:48 +05:30
Vikrant Gupta
6026e2372d feat(authz): support service and environment keys in telemetry selectors (#13036)
#### Description

- Telemetry grants can now scope on `resource.service.name` and
`resource.deployment.environment.name`, alongside
`signoz.workspace.key.id`.

#### Issues closed by this PR

Closes SigNoz/keystone-pod#73
2026-11-02 16:28:30 +00:00
Naman Verma
3c223834c4 Merge branch 'main' into nv/cache-grid 2026-11-02 21:24:45 +05:30
Nikhil Soni
be83a3e464 feat(traces): return instrumentation scope in waterfall, flamegraph and list view (#13280)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

- Waterfall spans and the empty-`selectFields` list view now return
`scope: {name, version, attributes}`, read from the `scope` JSON column.
- Flamegraph `selectFields` accept `fieldContext: scope`: `scope.name` /
`scope.version` resolve to the declared paths, any other name to a scope
attribute, the same way the query builder resolves them.
- Scope attributes are flat dotted keys, like span attributes.
- Span details drawer shows `scope`.

#### Issues closed by this PR

Closes https://github.com/SigNoz/events-pod/issues/220

For Frontend changes https://github.com/SigNoz/events-pod/issues/230

#### Additional Information

- Stacked on #13274.
- API contract change: OpenAPI spec and frontend client regenerated in
their own commits.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 10:32:58 +00:00
Nikhil Soni
34b6cbb98e fix(saved-views): rename old camelCase trace select fields (#13326)
#### Description

- Support for camelCase trace field names (`durationNano`,
`serviceName`, `httpMethod`, …) in select fields is being removed.
- Adds migration 141, which renames them in traces saved views'
`spec.selectedFields` using the same mapping as `oldToNew`.
- Drops `fieldContext` on renamed entries: legacy columns carry
`attribute` from migration 114, which would make `service.name` resolve
to a span attribute. Without it the query builder resolves the field
itself.
- If the new name is already selected, the renamed entry is dropped.
Unparseable rows are logged and skipped.
- Prod backups: 52 traces views across 29 deployments still use these
names, mostly the old default columns. Tested on a restored backup by
running migrations 049–141 through the server.

#### Additional Information

- Logs views and old names in filters / orderBy / groupBy are not
touched.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-11-02 10:24:17 +00:00
Gaurav Tewari
5acf61dd04 refactor(saved-views): key by source page instead of data source (#13296)
#### Description

- Key options menu, saved view options and preference slices by saved
view source page (`SavedviewtypesSourceDTO`) instead of the query's
`DataSource`.
- `PREFERENCE_SOURCE_PAGES` (logs, traces) is the one list of pages with
column and formatting preferences; the type and the
`isPreferenceSourcePage` guard come from it.
- Saved-view behavior on metrics and meter is unchanged: the hooks still
fall back to the logs options and drop them where the source has no
preferences.
- Tests use typed mocks (`jest.mocked`, `jest.MockedFunction`) and check
preference fixtures with `satisfies PreferenceContextValue`.

#### Issues closed by this PR

#### Screenshots / Screen Recordings

- [sbshot visual
diff](https://claude.ai/artifact/9yZWJ8t5J9vX65n43m2cK1): 188 Storybook
pairs (logs, live tail, traces, saved views, fields selector, metrics,
meter; dark + light) vs merge-base. 170 identical; 18 moved, all noise
(wall-clock mock timestamps, spinner frames, antialiasing).

#### Additional Information

- Review commit by commit: preferences → options menu → saved views. The
layers depend on each other, so only the last commit builds.
- `PreferenceContextValue` gains `ai_observability` in #13297; the
`useOptionsMenu` fixtures get the AI slice there.

---------

Co-authored-by: Gaurav Tewari <tewarig@users.noreply.github.com>
2026-11-02 07:01:03 +00:00
Abhi kumar
53611631d6 feat(storybook): query builder component stories (#13325)
#### Description

- `Components/Query Builder`: the builder as each host mounts it (logs,
traces, metrics and meter explorers, V2 panel editor, logs alert), props
mirrored from each call site and the starting query seeded through the
route.
- A `QueryProbe` below the builder prints the `query_range` payload its
live state would send, so plays assert on the query instead of the DOM.
- `Components/Query Builder/Behaviour`: add-ons per panel kind and
signal, metric-type defaults (gauge → avg/avg, histogram → p90) and the
single-aggregation limit on Value panels.
- Shared msw handlers now answer `/api/v1/fields/values`,
`/api/v2/metrics`, `/api/v2/metrics/metadata` and the `ai_observability`
fields, so any story rendering the builder has them.

#### Issues closed by this PR

Closes SigNoz/pulse-pod#663

#### Additional Information

- Filter suggestion loading/empty/error states are already covered by
the logs page stories, so they are not repeated here.
- The test runner reports existing a11y warnings in the builder's
markup; out of scope here.
2026-11-02 06:45:15 +00:00
Abhi kumar
1e052cbc64 refactor(dashboard): group panel type picker into components and hooks (#13170)
#### Description

- `PanelTypeSelectionModal/` was 20 flat files. Each component now lives
in `components/<Name>/` with its styles, and the picker-target hooks in
`hooks/`. The drawer, section-option `types`/`utils` and the test stay
at the root.
- `PanelTypeTile` gets its own stylesheet, split out of
`PanelTypeBrowser.module.scss`.
- No behaviour change. Outside the folder, only `PanelTypeSwitcher`'s
import path changes.
2026-11-02 06:42:02 +00:00
Nityananda Gohain
a621fb2b49 feat(aiobservability): read the messages the Langfuse SDK writes (#13331)
Some checks failed
build-staging / staging (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

- Langfuse SDK spans reached the thread with empty formatted messages:
the SDK writes them to `langfuse.observation.input/output`, which the
mapper never moved onto `gen_ai.*.messages`.
- `gen_ai.llm` v5 moves `langfuse.observation.input/output` onto
`gen_ai.input/output.messages` and copies `langfuse.observation.type`
onto `gen_ai.operation.name`, above every existing source. New spans
only.
- Formatter handles the shapes these spans carry: a superjson `{json}`
envelope, a single message object (incl. role-less `{tool_calls}` from
the LangChain handler), a bare list of Responses items (LiteLLM
`langfuse_otel` tool turns), and falls back to the raw output when the
OpenInference bucket is empty. Mixed-format warning dropped when one
side is generic or OpenInference.

  #### Additional Information

- Verified with unit tests, the shipped definition through the
in-process collector pipeline.
- Why Langfuse keeps its own keys instead of emitting semconv, checked
in the Langfuse repos:
- Docs ([OpenTelemetry
integration](https://langfuse.com/integrations/native/opentelemetry)):
`langfuse.*` attributes "always take precedence over the generic
OpenTelemetry conventions" and are "recommended for all users that are
manually instrumenting"; semconv is "still evolving", so OTel traces are
mapped into the Langfuse model.
- [Discussion #2509](https://github.com/orgs/langfuse/discussions/2509),
Sep 2024, maintainer: Langfuse features "not yet covered by the
conventions" and the risk of "breaking backwards compatibility once
conventions are in place" are the reasons for an own namespace.
- Ingestion (`OtelIngestionProcessor.ts`, `extractInputAndOutput`): own
keys first, then vendor keys, then semconv `gen_ai.*.messages`, then
events. Semconv is one vendor format among many.
- SDK sources: input and output are any JSON on any observation type
(span, agent, tool, generation, …), looser than `gen_ai.input.messages`.
- Still unmapped: `usage_details`, `cost_details`, `model.parameters`
are JSON objects; the mapper cannot extract fields, so pure Langfuse
spans have no tokens or cost.


Closes https://github.com/SigNoz/nerve-pod/issues/579
2026-11-01 07:54:17 +00:00
Vinicius Lourenço
72443c8267 chore(deps): bump axios to 1.21.0 (#13339)
Some checks failed
build-staging / staging (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

- Bump `axios` from 1.18.0 to 1.21.0 to fix the high-severity advisories
affecting `< 1.20.0`.
2026-11-01 05:35:32 +00:00
Naman Verma
f4d84b41fe Merge branch 'main' into nv/cache-grid 2026-10-30 19:58:53 +05:30
Naman Verma
5dcc195c72 perf: divide cache buckets for a query into cells in a grid 2026-10-30 19:55:47 +05:30
177 changed files with 4690 additions and 1511 deletions

View File

@@ -12131,6 +12131,8 @@ components:
additionalProperties:
type: string
type: object
scope:
$ref: '#/components/schemas/SpantypesScope'
spanId:
type: string
timestamp:
@@ -12147,6 +12149,7 @@ components:
- event
- attributes
- resource
- scope
type: object
SpantypesGettableFlamegraphTrace:
properties:
@@ -12387,6 +12390,16 @@ components:
nullable: true
type: array
type: object
SpantypesScope:
properties:
attributes:
additionalProperties: {}
type: object
name:
type: string
version:
type: string
type: object
SpantypesSpanAggregation:
properties:
aggregation:
@@ -12756,6 +12769,8 @@ components:
type: object
response_status_code:
type: string
scope:
$ref: '#/components/schemas/SpantypesScope'
span_id:
type: string
status_code:
@@ -15064,9 +15079,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- planned-maintenance:list
- tokenizer:
- VIEWER
- planned-maintenance:list
summary: List downtime schedules
tags:
- downtimeschedules
@@ -15121,9 +15136,13 @@ paths:
description: Internal Server Error
security:
- api_key:
- EDITOR
- planned-maintenance:create
- planned-maintenance:attach
- rule:attach
- tokenizer:
- EDITOR
- planned-maintenance:create
- planned-maintenance:attach
- rule:attach
summary: Create downtime schedule
tags:
- downtimeschedules
@@ -15168,9 +15187,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- EDITOR
- planned-maintenance:delete
- tokenizer:
- EDITOR
- planned-maintenance:delete
summary: Delete downtime schedule
tags:
- downtimeschedules
@@ -15226,9 +15245,9 @@ paths:
description: Internal Server Error
security:
- api_key:
- VIEWER
- planned-maintenance:read
- tokenizer:
- VIEWER
- planned-maintenance:read
summary: Get downtime schedule by ID
tags:
- downtimeschedules
@@ -15283,9 +15302,17 @@ paths:
description: Internal Server Error
security:
- api_key:
- EDITOR
- planned-maintenance:update
- planned-maintenance:attach
- planned-maintenance:detach
- rule:attach
- rule:detach
- tokenizer:
- EDITOR
- planned-maintenance:update
- planned-maintenance:attach
- planned-maintenance:detach
- rule:attach
- rule:detach
summary: Update downtime schedule
tags:
- downtimeschedules

View File

@@ -17,7 +17,7 @@ Condition: `model`, `llm.input_messages` (OpenInference sets `llm.model_name` fr
| `gen_ai.response.model`, last source for the request model | vLLM, which writes only the response model |
| `ai.model.id`, `ai.model.provider`, `ai.response.model`, `ai.response.finishReason`, `ai.prompt.messages`, `ai.response.text`, `ai.response.toolCalls`, `ai.usage.promptTokens`, `ai.usage.completionTokens` | Vercel AI SDK v4, Mastra |
| `ai.usage.inputTokens`, `ai.usage.outputTokens`, `ai.usage.cachedInputTokens`, `ai.usage.reasoningTokens` | Vercel AI SDK v5 |
| `langfuse.observation.model.name`, `langfuse.session.id` | Langfuse SDK v3 |
| `langfuse.observation.model.name`, `langfuse.observation.input`, `langfuse.observation.output`, `langfuse.observation.type`, `langfuse.session.id` | Langfuse SDK v3 (Python) and v4 (JS), LiteLLM `langfuse_otel` callback |
| `session.id` | OpenInference, Langfuse, Claude Code |
| `gen_ai.client.session_id` | opentelemetry-hooks runner (Cursor, Copilot, Claude Code hooks) |
| `model`, `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_creation_tokens` | Claude Code `claude_code.llm_request` spans ([docs](https://code.claude.com/docs/en/monitoring-usage#span-attributes)) |

View File

@@ -70,7 +70,7 @@
"antd": "5.11.0",
"antd-table-saveas-excel": "2.2.1",
"antlr4": "4.13.2",
"axios": "1.18.0",
"axios": "1.21.0",
"babel-jest": "^29.6.4",
"chart.js": "3.9.1",
"chartjs-adapter-date-fns": "^2.0.0",

View File

@@ -127,8 +127,8 @@ importers:
specifier: 4.13.2
version: 4.13.2
axios:
specifier: 1.18.0
version: 1.18.0
specifier: 1.21.0
version: 1.21.0
babel-jest:
specifier: ^29.6.4
version: 29.6.4(@babel/core@7.29.7)
@@ -4593,8 +4593,8 @@ packages:
resolution: {integrity: sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==}
engines: {node: '>=4'}
axios@1.18.0:
resolution: {integrity: sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==}
axios@1.21.0:
resolution: {integrity: sha512-YCzXMB53Q9PZBsh7yN7wtapit4WepwcHS2fP8QhGsBxSz5YtgYobV7GGlWlAJi+RxfU77GQTQi2DSLUma42EYA==}
babel-jest@29.6.4:
resolution: {integrity: sha512-meLj23UlSLddj6PC+YTOFRgDAtjnZom8w/ACsrx0gtPtv5cJZk0A5Unk5bV4wixD7XaPCN1fQvpww8czkZURmw==}
@@ -14283,7 +14283,7 @@ snapshots:
axe-core@4.13.0: {}
axios@1.18.0:
axios@1.21.0:
dependencies:
follow-redirects: 1.16.0
form-data: 4.0.6
@@ -20310,7 +20310,7 @@ snapshots:
wait-on@7.2.0:
dependencies:
axios: 1.18.0
axios: 1.21.0
joi: 17.13.7
lodash: 4.18.1
minimist: 1.2.8

View File

@@ -13264,6 +13264,23 @@ export type SpantypesFlamegraphSpanDTOAttributes = { [key: string]: unknown };
export type SpantypesFlamegraphSpanDTOResource = { [key: string]: string };
export type SpantypesScopeDTOAttributes = { [key: string]: unknown };
export interface SpantypesScopeDTO {
/**
* @type object
*/
attributes?: SpantypesScopeDTOAttributes;
/**
* @type string
*/
name?: string;
/**
* @type string
*/
version?: string;
}
export interface SpantypesFlamegraphSpanDTO {
/**
* @type object
@@ -13299,6 +13316,7 @@ export interface SpantypesFlamegraphSpanDTO {
* @type object
*/
resource: SpantypesFlamegraphSpanDTOResource;
scope: SpantypesScopeDTO;
/**
* @type string
*/
@@ -13844,6 +13862,7 @@ export interface SpantypesWaterfallSpanDTO {
* @type string
*/
response_status_code?: string;
scope?: SpantypesScopeDTO;
/**
* @type string
*/

View File

@@ -1,4 +1,3 @@
import { extractOnlyMessageBody } from 'lib/logs/logBody';
import { cloneDeep, isEmpty } from 'lodash-es';
import { SuccessResponse, Warning } from 'types/api';
import { MetricRangePayloadV3 } from 'types/api/metrics/getQueryRange';
@@ -282,6 +281,19 @@ function convertScalarWithFormatForWeb(
});
}
function extractOnlyMessageBody(body: unknown): unknown {
const isJsonBody = body && typeof body === 'object' && !Array.isArray(body);
if (isJsonBody) {
const keys = Object.keys(body);
const hasOnlyMessageKey = keys.length === 1 && keys[0] === 'message';
if (hasOnlyMessageKey) {
const { message } = body as { message: unknown };
return typeof message === 'string' ? message : JSON.stringify(message);
}
}
return body;
}
/**
* Converts V5 RawData to legacy format
*/

View File

@@ -4,10 +4,12 @@ import { Button } from '@signozhq/ui/button';
import { Drawer, Tooltip } from 'antd';
import { ToggleGroup } from '@signozhq/ui/toggle-group';
import { Typography } from '@signozhq/ui/typography';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import LogStateIndicator from 'components/Logs/LogStateIndicator/LogStateIndicator';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
import { convertExpressionToFilters } from 'components/QueryBuilderV2/utils';
import { FeatureKeys } from 'constants/features';
import { LOCALSTORAGE } from 'constants/localStorage';
import ContextView from 'container/LogDetailedView/ContextView/ContextView';
import InfraMetrics from 'container/LogDetailedView/InfraMetrics/InfraMetrics';
@@ -21,7 +23,6 @@ import { useOptionsMenu } from 'container/OptionsMenu';
import { FontSize } from 'container/OptionsMenu/types';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { getBodyMessage } from 'lib/logs/logBody';
import { cloneDeep } from 'lodash-es';
import {
ArrowDown,
@@ -32,6 +33,8 @@ import {
TextSelect,
X,
} from '@signozhq/icons';
import { useAppContext } from 'providers/App/App';
import { ILogBody } from 'types/api/logs/log';
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource, StringOperators } from 'types/common/queryBuilder';
@@ -64,6 +67,10 @@ function LogDetailInner({
const [selectedView, setSelectedView] = useState<VIEWS>(selectedTab);
const [isFilterVisible, setIsFilterVisible] = useState<boolean>(false);
const { featureFlags } = useAppContext();
const isBodyJsonQueryEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.USE_JSON_BODY)
?.active || false;
const [filters, setFilters] = useState<TagFilter | null>(null);
const [isEdit, setIsEdit] = useState<boolean>(false);
@@ -141,7 +148,7 @@ function LogDetailInner({
const { options } = useOptionsMenu({
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
dataSource: DataSource.LOGS,
sourcePage: SavedviewtypesSourceDTO.logs,
aggregateOperator: listQuery?.aggregateOperator || StringOperators.NOOP,
});
@@ -166,7 +173,20 @@ function LogDetailInner({
}
};
const logBody = useMemo(() => getBodyMessage(log?.body), [log?.body]);
const logBody = useMemo(() => {
if (!isBodyJsonQueryEnabled) {
return (log?.body as string) ?? '';
}
// Feature enabled: body is always a map; message is always a string
const bodyObj = log?.body as ILogBody;
if (!bodyObj) {
return '';
}
if (bodyObj.message) {
return bodyObj.message;
}
return JSON.stringify(bodyObj);
}, [isBodyJsonQueryEnabled, log?.body]);
const htmlBody = useMemo(
() => ({

View File

@@ -5,19 +5,19 @@ import cx from 'classnames';
import { VIEW_TYPES } from 'components/LogDetail/constants';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { ChangeViewFunctionType } from 'hooks/useHandleExplorerTabChange';
import { getSanitizedLogBody } from 'container/LogDetailedView/utils';
import {
getBodyDisplayString,
getSanitizedLogBody,
} from 'container/LogDetailedView/utils';
import { FontSize } from 'container/OptionsMenu/types';
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
import { useIsBodyJsonEnabled } from 'hooks/logs/useIsBodyJsonEnabled';
import { useIsDarkMode } from 'hooks/useDarkMode';
// utils
import { FlatLogData, getLogFieldValue } from 'lib/logs/flatLogData';
import { getBodyDisplay } from 'lib/logs/logBody';
import { FlatLogData } from 'lib/logs/flatLogData';
import { useTimezone } from 'providers/Timezone';
// interfaces
import { IField } from 'types/api/logs/fields';
import { ILog } from 'types/api/logs/log';
import { coerceToString } from 'utils/stringUtils';
// components
import AddToQueryHOC, { AddToQueryHOCProps } from '../AddToQueryHOC';
@@ -26,6 +26,7 @@ import LogStateIndicator from '../LogStateIndicator/LogStateIndicator';
import { getLogIndicatorType } from '../LogStateIndicator/utils';
// styles
import { Container, LogContainer, LogText } from './styles';
import { isValidLogField } from './util';
import './ListLogView.styles.scss';
@@ -37,7 +38,7 @@ interface LogFieldProps {
}
type LogSelectedFieldProps = Omit<LogFieldProps, 'linesPerRow'> &
Partial<Pick<AddToQueryHOCProps, 'onAddToQuery'>>;
Pick<AddToQueryHOCProps, 'onAddToQuery'>;
function LogGeneralField({
fieldKey,
@@ -74,31 +75,23 @@ function LogSelectedField({
onAddToQuery,
fontSize,
}: LogSelectedFieldProps): JSX.Element {
const fieldKeyText = (
<Typography.Text>
<span
style={{ color: blue[4] }}
className={cx('selected-log-field-key', fontSize)}
>
{fieldKey}
</span>
</Typography.Text>
);
return (
<div className="log-selected-fields">
{onAddToQuery ? (
<AddToQueryHOC
fieldKey={fieldKey}
fieldValue={fieldValue}
onAddToQuery={onAddToQuery}
fontSize={fontSize}
>
{fieldKeyText}
</AddToQueryHOC>
) : (
fieldKeyText
)}
<AddToQueryHOC
fieldKey={fieldKey}
fieldValue={fieldValue}
onAddToQuery={onAddToQuery}
fontSize={fontSize}
>
<Typography.Text>
<span
style={{ color: blue[4] }}
className={cx('selected-log-field-key', fontSize)}
>
{fieldKey}
</span>
</Typography.Text>
</AddToQueryHOC>
<Typography.Text truncate={1} className={cx('selected-log-kv', fontSize)}>
<span className={cx('selected-log-field-key', fontSize)}>{': '}</span>
<span className={cx('selected-log-value', fontSize)}>
@@ -137,7 +130,6 @@ function ListLogView({
onClearActiveLog,
}: ListLogViewProps): JSX.Element {
const flattenLogData = useMemo(() => FlatLogData(logData), [logData]);
const isBodyJsonEnabled = useIsBodyJsonEnabled();
const { isHighlighted, isLogsExplorerPage, onLogCopy } = useCopyLogLink(
logData.id,
@@ -207,7 +199,7 @@ function ListLogView({
{updatedSelecedFields.some((field) => field.name === 'body') && (
<LogGeneralField
fieldKey="Log"
fieldValue={getBodyDisplay(logData.body)}
fieldValue={getBodyDisplayString(logData.body)}
linesPerRow={linesPerRow}
fontSize={fontSize}
/>
@@ -229,22 +221,17 @@ function ListLogView({
{updatedSelecedFields
.filter((field) => !['timestamp', 'body'].includes(field.name))
.map((field) => {
const value = getLogFieldValue(logData, field.name, isBodyJsonEnabled);
if (value == null) {
return null;
}
const isObjectValue = typeof value === 'object' && value !== null;
return (
.map((field) =>
isValidLogField(flattenLogData[field.name] as never) ? (
<LogSelectedField
key={field.name}
fieldKey={field.name}
fieldValue={coerceToString(value)}
onAddToQuery={isObjectValue ? undefined : onAddToQuery}
fieldValue={flattenLogData[field.name] as never}
onAddToQuery={onAddToQuery}
fontSize={fontSize}
/>
);
})}
) : null,
)}
</LogContainer>
</div>
</div>

View File

@@ -0,0 +1 @@
export const isValidLogField = (value: never): boolean => value !== undefined;

View File

@@ -9,16 +9,16 @@ import { Color } from '@signozhq/design-tokens';
import { Tooltip } from 'antd';
import { VIEW_TYPES } from 'components/LogDetail/constants';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { getSanitizedLogBody } from 'container/LogDetailedView/utils';
import {
getBodyDisplayString,
getSanitizedLogBody,
} from 'container/LogDetailedView/utils';
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
import { useIsBodyJsonEnabled } from 'hooks/logs/useIsBodyJsonEnabled';
// hooks
import { useIsDarkMode } from 'hooks/useDarkMode';
import { getLogFieldValue } from 'lib/logs/flatLogData';
import { getBodyDisplay } from 'lib/logs/logBody';
import { isBoolean, isEmpty, isNumber, isUndefined } from 'lodash-es';
import { FlatLogData } from 'lib/logs/flatLogData';
import { isEmpty, isNumber, isUndefined } from 'lodash-es';
import { useTimezone } from 'providers/Timezone';
import { coerceToString } from 'utils/stringUtils';
import LogLinesActionButtons from '../LogLinesActionButtons/LogLinesActionButtons';
import LogStateIndicator from '../LogStateIndicator/LogStateIndicator';
@@ -46,7 +46,7 @@ function RawLogView({
isLogsExplorerPage,
onLogCopy,
} = useCopyLogLink(data.id);
const isBodyJsonEnabled = useIsBodyJsonEnabled();
const flattenLogData = useMemo(() => FlatLogData(data), [data]);
const isDarkMode = useIsDarkMode();
const isReadOnlyLog = !isLogsExplorerPage || isReadOnly;
@@ -60,16 +60,15 @@ function RawLogView({
const attributesValues = updatedSelecedFields
.filter((field) => !['timestamp', 'body'].includes(field.name))
.map((field) => getLogFieldValue(data, field.name, isBodyJsonEnabled))
.map((field) => flattenLogData[field.name])
.filter((attribute) => {
// loadash isEmpty doesnot work with numbers
if (isNumber(attribute) || isBoolean(attribute)) {
if (isNumber(attribute)) {
return true;
}
return !isUndefined(attribute) && !isEmpty(attribute);
})
.map((attribute) => coerceToString(attribute));
});
let attributesText = attributesValues.join(' | ');
@@ -103,7 +102,7 @@ function RawLogView({
// Check if body is selected
const showBody = selectedFields.some((field) => field.name === 'body');
if (showBody) {
parts.push(`${attributesText} ${getBodyDisplay(data.body)}`);
parts.push(`${attributesText} ${getBodyDisplayString(data.body)}`);
} else {
parts.push(attributesText);
}

View File

@@ -1,7 +1,6 @@
import { render, renderHook, screen } from '@testing-library/react';
import { renderHook } from '@testing-library/react';
import { FontSize } from 'container/OptionsMenu/types';
import { IField } from 'types/api/logs/fields';
import { ILog } from 'types/api/logs/log';
import { useLogsTableColumns } from '../useLogsTableColumns';
@@ -166,36 +165,3 @@ describe('useLogsTableColumns — selectColumns-order respected', () => {
expect(result.current.map((c) => c.id)).toStrictEqual(['state-indicator']);
});
});
describe('useLogsTableColumns — cell values', () => {
const asLog = (partial: Partial<ILog>): ILog => partial as unknown as ILog;
it('shows an empty body cell for a row without a body', () => {
const { result } = renderHook(() =>
useLogsTableColumns({
fields: [field('body', 'log')],
fontSize: FontSize.SMALL,
}),
);
const bodyCol = result.current.find((c) => c.id === 'log:body');
expect(bodyCol?.accessorFn?.(asLog({}))).toBe('');
});
it('renders an object-valued field as JSON, not [object Object]', () => {
const { result } = renderHook(() =>
useLogsTableColumns({
fields: [field('order')],
fontSize: FontSize.SMALL,
}),
);
const orderCol = result.current.find((c) => c.id === 'order');
const Cell = orderCol?.cell as (props: { value: unknown }) => JSX.Element;
render(<Cell value={{ id: 8412, coupons: ['WELCOME10'] }} />);
expect(
screen.getByText('{"id":8412,"coupons":["WELCOME10"]}'),
).toBeInTheDocument();
});
});

View File

@@ -2,16 +2,18 @@ import type { ReactElement } from 'react';
import { useMemo } from 'react';
import TanStackTable from 'components/TanStackTableView';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { getSanitizedLogBody } from 'container/LogDetailedView/utils';
import { FeatureKeys } from 'constants/features';
import {
getBodyDisplayString,
getSanitizedLogBody,
} from 'container/LogDetailedView/utils';
import { FontSize } from 'container/OptionsMenu/types';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import { useIsBodyJsonEnabled } from 'hooks/logs/useIsBodyJsonEnabled';
import { getLogFieldValue } from 'lib/logs/flatLogData';
import { getBodyDisplay } from 'lib/logs/logBody';
import { useAppContext } from 'providers/App/App';
import { useTimezone } from 'providers/Timezone';
import { IField } from 'types/api/logs/fields';
import { ILog } from 'types/api/logs/log';
import { coerceToString } from 'utils/stringUtils';
import type { TableColumnDef } from '../../TanStackTableView/types';
import LogStateIndicator from '../LogStateIndicator/LogStateIndicator';
@@ -26,7 +28,10 @@ export function useLogsTableColumns({
fontSize,
}: UseLogsTableColumnsProps): TableColumnDef<ILog>[] {
const { formatTimezoneAdjustedTimestamp } = useTimezone();
const isBodyJsonEnabled = useIsBodyJsonEnabled();
const { featureFlags } = useAppContext();
const isBodyJsonEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.USE_JSON_BODY)
?.active || false;
return useMemo<TableColumnDef<ILog>[]>(() => {
const stateIndicatorCol: TableColumnDef<ILog> = {
@@ -70,7 +75,7 @@ export function useLogsTableColumns({
const bodyCol: TableColumnDef<ILog> = {
id: buildCompositeKey('body', 'log'),
header: 'Body',
accessorFn: (log): string => getBodyDisplay(log.body),
accessorFn: (log): string => getBodyDisplayString(log.body),
canBeHidden: false,
enableRemove: false,
width: { default: '100%', min: 300 },
@@ -94,7 +99,7 @@ export function useLogsTableColumns({
enableRemove: true,
width: { min: 192 },
cell: ({ value }): ReactElement => (
<TanStackTable.Text>{coerceToString(value)}</TanStackTable.Text>
<TanStackTable.Text>{String(value ?? '')}</TanStackTable.Text>
),
});

View File

@@ -0,0 +1,169 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, waitFor } from 'storybook/test';
import { withCanvas } from '@/storybook/decorators/withCanvas';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { DataSource } from 'types/common/queryBuilder';
import { QueryBuilderV2 } from '../QueryBuilderV2';
import {
addOnToggles,
appendToAggregation,
DEFAULT_ARGS,
expectFirstQuery,
PANEL_EDITOR_ARGS,
PANEL_EDITOR_PATH,
payloadIn,
routeWith,
selectMetric,
} from './QueryBuilderV2.stories.mocks';
import QueryProbe from './QueryProbe';
const METRICS_EXPLORER_ARGS = {
config: { initialDataSource: DataSource.METRICS, queryVariant: 'static' },
showFunctions: false,
} as const;
const metricsExplorerRoute = routeWith(
ROUTES.METRICS_EXPLORER_EXPLORER,
initialQueriesMap.metrics,
);
/**
* Builder rules that hold without a backend: which add-ons a panel kind
* offers, the aggregations a metric type defaults to, and input limits. The
* probe below the builder prints the payload each play asserts on.
*/
const meta = {
title: 'Components/Query Builder/Behaviour',
component: QueryBuilderV2,
tags: ['play'],
decorators: [withCanvas({ maxWidth: 1100 })],
args: DEFAULT_ARGS,
render: (args): JSX.Element => (
<>
<QueryBuilderV2 {...args} />
<QueryProbe />
</>
),
} satisfies Meta<typeof QueryBuilderV2>;
export default meta;
type Story = StoryObj<typeof meta>;
/** A Value panel offers only the legend add-on. */
export const LogsOnValuePanel: Story = {
args: { ...PANEL_EDITOR_ARGS, panelType: PANEL_TYPES.VALUE },
parameters: {
signoz: { route: routeWith(PANEL_EDITOR_PATH, initialQueriesMap.logs) },
},
play: async ({ canvasElement }) => {
await waitFor(() =>
expect(addOnToggles(canvasElement)).toStrictEqual(['legend_format']),
);
},
};
/** Metrics on a Table: reduce-to is offered; group by lives in the aggregation row. */
export const MetricsOnTablePanel: Story = {
args: { ...PANEL_EDITOR_ARGS, panelType: PANEL_TYPES.TABLE },
parameters: {
signoz: { route: routeWith(PANEL_EDITOR_PATH, initialQueriesMap.metrics) },
},
play: async ({ canvasElement }) => {
await waitFor(() =>
expect(addOnToggles(canvasElement)).toStrictEqual([
'having',
'order_by',
'limit',
'legend_format',
'reduce_to',
]),
);
},
};
/** Traces on a time series: every add-on, with group by as a toggle. */
export const TracesOnTimeSeriesPanel: Story = {
args: PANEL_EDITOR_ARGS,
parameters: {
signoz: { route: routeWith(PANEL_EDITOR_PATH, initialQueriesMap.traces) },
},
play: async ({ canvasElement }) => {
await waitFor(() =>
expect(addOnToggles(canvasElement)).toStrictEqual([
'group_by',
'having',
'order_by',
'limit',
'legend_format',
]),
);
},
};
/** Picking a gauge defaults to avg over time and avg across series. */
export const GaugeMetricDefaults: Story = {
args: METRICS_EXPLORER_ARGS,
parameters: { signoz: { route: metricsExplorerRoute } },
play: async ({ canvasElement }) => {
await selectMetric(canvasElement, 'container_cpu_utilization');
await expectFirstQuery(canvasElement, {
aggregations: [
expect.objectContaining({
metricName: 'container_cpu_utilization',
timeAggregation: 'avg',
spaceAggregation: 'avg',
}),
],
});
},
};
/** Picking a histogram defaults to the p90 across series. */
export const HistogramMetricDefaults: Story = {
args: METRICS_EXPLORER_ARGS,
parameters: { signoz: { route: metricsExplorerRoute } },
play: async ({ canvasElement }) => {
await selectMetric(canvasElement, 'signoz_latency_bucket');
await expectFirstQuery(canvasElement, {
aggregations: [
expect.objectContaining({
metricName: 'signoz_latency_bucket',
spaceAggregation: 'p90',
}),
],
});
},
};
/** A Value panel takes a single aggregation: a second one is not accepted. */
export const ValuePanelTakesOneAggregation: Story = {
args: { ...PANEL_EDITOR_ARGS, panelType: PANEL_TYPES.VALUE },
parameters: {
signoz: { route: routeWith(PANEL_EDITOR_PATH, initialQueriesMap.logs) },
},
play: async ({ canvasElement }) => {
await expectFirstQuery(canvasElement, {
aggregations: [{ expression: 'count()' }],
});
const { before, after } = appendToAggregation(
canvasElement,
', count_distinct(service.name)',
);
await expect(after).toBe(before);
await expectFirstQuery(canvasElement, {
aggregations: [{ expression: 'count()' }],
});
await expect(
payloadIn(canvasElement).queries[0].spec.aggregations,
).toHaveLength(1);
},
};

View File

@@ -0,0 +1,134 @@
/**
* AI-owned. Generated and maintained by the `signoz-page-story` skill.
* Do not hand-edit: regenerate instead.
*/
import { EditorView } from '@codemirror/view';
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import type { QueryBuilderV2 } from '../QueryBuilderV2';
type QueryBuilderProps = Parameters<typeof QueryBuilderV2>[0];
/** The builder's provider reads its starting query from the URL, as every host seeds it. */
export const routeWith = (path: string, query: Query): string =>
`${path}?compositeQuery=${encodeURIComponent(JSON.stringify(query))}`;
export const meterQuery: Query = {
...initialQueriesMap.metrics,
builder: {
...initialQueriesMap.metrics.builder,
queryData: [
{ ...initialQueriesMap.metrics.builder.queryData[0], source: 'meter' },
],
},
};
/** `PanelEditorQueryBuilder`'s props, less the per-kind field config. */
export const PANEL_EDITOR_ARGS: Partial<QueryBuilderProps> = {
allowedDataSources: [
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
TelemetrytypesSignalDTO.metrics,
],
showTraceOperator: true,
signalSourceChangeEnabled: true,
savePreviousQuery: true,
};
export const PANEL_EDITOR_PATH = '/dashboard/story/panel/story';
interface ProbedQuery {
type: string;
spec: {
aggregations?: Record<string, unknown>[];
[key: string]: unknown;
};
}
/** The `compositeQuery` the story's `QueryProbe` prints for the builder's state. */
export const payloadIn = (
canvasElement: HTMLElement,
): { queries: ProbedQuery[] } =>
JSON.parse(
within(canvasElement).getByTestId('qb-payload').textContent ?? '{}',
) as { queries: ProbedQuery[] };
export const expectFirstQuery = async (
canvasElement: HTMLElement,
spec: Record<string, unknown>,
): Promise<void> => {
await waitFor(() =>
expect(payloadIn(canvasElement).queries[0]).toMatchObject({
type: 'builder_query',
spec: expect.objectContaining(spec),
}),
);
};
/** The add-on toggles query `A` offers, in order (`group_by`, `having`, …). */
export const addOnToggles = (canvasElement: HTMLElement): string[] =>
within(within(canvasElement).getByTestId('query-A'))
.queryAllByTestId(/^query-add-on-/)
.map((toggle) =>
(toggle.getAttribute('data-testid') ?? '').replace('query-add-on-', ''),
);
/** Picks `metricName` in query `A`'s metric selector; its list portals out. */
export const selectMetric = async (
canvasElement: HTMLElement,
metricName: string,
): Promise<void> => {
const field = within(canvasElement).getByTestId('metric-name-selector-0');
const input = within(field).getByRole('combobox');
await userEvent.click(input);
await userEvent.type(input, metricName);
const option = await waitFor(async () => {
const match = Array.from(
document.querySelectorAll<HTMLElement>(
'.ant-select-dropdown:not(.ant-select-dropdown-hidden) .ant-select-item-option',
),
).find((candidate) => candidate.textContent?.includes(metricName));
await expect(match).toBeDefined();
return match as HTMLElement;
});
await userEvent.click(option);
await waitFor(() => expect(screen.queryByRole('listbox')).toBeNull());
};
/**
* Appends `text` to query `A`'s aggregation editor and returns its document
* before and after. Dispatched as a CodeMirror transaction: `userEvent.type`
* strands the caret when the line redraws, and a transaction runs the editor's
* own filters, which is what an input limit is.
*/
export const appendToAggregation = (
canvasElement: HTMLElement,
text: string,
): { before: string; after: string } => {
const content = within(canvasElement)
.getByTestId('query-aggregation-editor')
.querySelector<HTMLElement>('.cm-content');
const view = content ? EditorView.findFromDOM(content) : null;
if (!view) {
throw new Error('aggregation editor not mounted');
}
const before = view.state.doc.toString();
view.dispatch({
changes: { from: before.length, insert: text },
selection: { anchor: before.length + text.length },
userEvent: 'input.type',
});
// Read synchronously: the document is the editor's own verdict, before
// React state catches up.
return { before, after: view.state.doc.toString() };
};
export const DEFAULT_ARGS: Partial<QueryBuilderProps> = {
panelType: PANEL_TYPES.TIME_SERIES,
version: 'v3',
};

View File

@@ -0,0 +1,145 @@
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, within } from 'storybook/test';
import { withCanvas } from '@/storybook/decorators/withCanvas';
import { initialQueriesMap } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { DataSource } from 'types/common/queryBuilder';
import { QueryBuilderV2 } from '../QueryBuilderV2';
import {
DEFAULT_ARGS,
expectFirstQuery,
meterQuery,
PANEL_EDITOR_ARGS,
PANEL_EDITOR_PATH,
routeWith,
} from './QueryBuilderV2.stories.mocks';
import QueryProbe from './QueryProbe';
/**
* The query builder as each host mounts it: the props come from that host's
* call site, the starting query from its route. The probe below the builder
* prints the `query_range` payload its state would send.
*/
const meta = {
title: 'Components/Query Builder',
component: QueryBuilderV2,
tags: ['play'],
decorators: [withCanvas({ maxWidth: 1100 })],
args: DEFAULT_ARGS,
render: (args): JSX.Element => (
<>
<QueryBuilderV2 {...args} />
<QueryProbe />
</>
),
} satisfies Meta<typeof QueryBuilderV2>;
export default meta;
type Story = StoryObj<typeof meta>;
/** Logs explorer, graph view: a fixed logs signal with `count()`. */
export const LogsExplorer: Story = {
args: {
config: { initialDataSource: DataSource.LOGS, queryVariant: 'static' },
},
parameters: {
signoz: { route: routeWith(ROUTES.LOGS_EXPLORER, initialQueriesMap.logs) },
},
play: async ({ canvasElement }) => {
await expectFirstQuery(canvasElement, {
signal: 'logs',
aggregations: [{ expression: 'count()' }],
});
},
};
/** Traces explorer: a fixed traces signal, with trace matching offered. */
export const TracesExplorer: Story = {
args: {
config: { initialDataSource: DataSource.TRACES, queryVariant: 'static' },
showTraceOperator: true,
},
parameters: {
signoz: {
route: routeWith(ROUTES.TRACES_EXPLORER, initialQueriesMap.traces),
},
},
play: async ({ canvasElement }) => {
await expectFirstQuery(canvasElement, { signal: 'traces' });
},
};
/** Metrics explorer: a fixed metrics signal, functions hidden. */
export const MetricsExplorer: Story = {
args: {
config: { initialDataSource: DataSource.METRICS, queryVariant: 'static' },
showFunctions: false,
},
parameters: {
signoz: {
route: routeWith(
ROUTES.METRICS_EXPLORER_EXPLORER,
initialQueriesMap.metrics,
),
},
},
play: async ({ canvasElement }) => {
await expectFirstQuery(canvasElement, { signal: 'metrics' });
},
};
/** Meter explorer: metrics read from the meter source. */
export const MeterExplorer: Story = {
args: {
config: {
initialDataSource: DataSource.METRICS,
queryVariant: 'static',
signalSource: 'meter',
},
showFunctions: false,
},
parameters: {
signoz: { route: routeWith(ROUTES.METER_EXPLORER, meterQuery) },
},
play: async ({ canvasElement }) => {
await expectFirstQuery(canvasElement, { signal: 'metrics', source: 'meter' });
},
};
/**
* V2 panel editor: a data-source dropdown per query, several queries,
* formulas and trace matching.
*/
export const PanelEditor: Story = {
args: PANEL_EDITOR_ARGS,
parameters: {
signoz: {
route: routeWith(PANEL_EDITOR_PATH, initialQueriesMap.logs),
},
},
play: async ({ canvasElement }) => {
await expectFirstQuery(canvasElement, { signal: 'logs' });
await expect(
within(canvasElement).getByTestId('query-data-source-selector-0'),
).toBeVisible();
},
};
/** Logs alert: a fixed logs signal with functions, as the alert form mounts it. */
export const LogsAlert: Story = {
args: {
config: { initialDataSource: DataSource.LOGS, queryVariant: 'static' },
showFunctions: true,
signalSourceChangeEnabled: true,
},
parameters: {
signoz: { route: routeWith(ROUTES.ALERTS_NEW, initialQueriesMap.logs) },
},
play: async ({ canvasElement }) => {
await expectFirstQuery(canvasElement, { signal: 'logs' });
},
};

View File

@@ -0,0 +1,11 @@
.probe {
margin-top: 16px;
padding: 12px;
max-height: 320px;
overflow: auto;
border: 1px solid var(--l1-border);
border-radius: 4px;
font-size: 12px;
color: var(--l2-foreground);
background: var(--l1-background);
}

View File

@@ -0,0 +1,38 @@
import { useMemo } from 'react';
import { prepareQueryRangePayloadV5 } from 'api/v5/queryRange/prepareQueryRangePayloadV5';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import styles from './QueryProbe.module.scss';
/** Fixed, so the printed payload is the same on every render and capture. */
const WINDOW = { start: 1_700_000_000_000, end: 1_700_003_600_000 };
/**
* Prints the `compositeQuery` the builder's live state would send, so a play
* function asserts on the query rather than on the DOM that edits it.
*/
function QueryProbe(): JSX.Element {
const { currentQuery, panelType } = useQueryBuilder();
const payload = useMemo(() => {
try {
return prepareQueryRangePayloadV5({
query: currentQuery,
graphType: panelType ?? PANEL_TYPES.TIME_SERIES,
selectedTime: 'GLOBAL_TIME',
...WINDOW,
}).queryPayload.compositeQuery;
} catch (error) {
return { error: String(error) };
}
}, [currentQuery, panelType]);
return (
<pre className={styles.probe} data-testid="qb-payload">
{JSON.stringify(payload, null, 2)}
</pre>
);
}
export default QueryProbe;

View File

@@ -3,7 +3,6 @@ import {
MetrictypesTemporalityDTO,
MetrictypesTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
import { defaultTraceSelectedColumns } from 'container/OptionsMenu/constants';
import { createIdFromObjectFields } from 'lib/createIdFromObjectFields';
import { createNewBuilderItemName } from 'lib/newQueryBuilder/createNewBuilderItemName';
import { IAttributeValuesResponse } from 'types/api/queryBuilder/getAttributesValues';
@@ -591,7 +590,6 @@ export const listViewInitialTraceQuery: Query = {
orderBy: [{ columnName: 'timestamp', order: 'desc' }],
offset: 0,
pageSize: 10,
selectColumns: defaultTraceSelectedColumns,
},
],
},

View File

@@ -4,8 +4,7 @@ import {
InframonitoringtypesClusterRecordDTO,
InframonitoringtypesNodeConditionDTO,
} from 'api/generated/services/sigNoz.schemas';
import { TableColumnDef } from 'components/TanStackTableView';
import TanStackTable from 'components/TanStackTableView';
import TanStackTable, { TableColumnDef } from 'components/TanStackTableView';
import { ExpandButtonWrapper } from 'container/InfraMonitoringK8sV2/components';
import ColumnHeader from '../Base/ColumnHeader';

View File

@@ -22,7 +22,6 @@ import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants'
import LoadingContainer from 'container/InfraMonitoringK8sV2/LoadingContainer';
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
import { ChevronDown, ChevronRight } from '@signozhq/icons';
import { getBodyDisplay } from 'lib/logs/logBody';
import { saveRecentQueryByExpression } from 'lib/recentQueries/saveRecentQuery';
import { useQueryState } from 'nuqs';
import { DataSource } from 'types/common/queryBuilder';
@@ -164,7 +163,7 @@ function EntityEventsContent({
key: event.data.id,
id: event.data.id,
timestamp: event.timestamp,
body: getBodyDisplay(event.data.body),
body: event.data.body,
severity: event.data.severity_text,
attributes_string: event.data.attributes_string,
resources_string: event.data.resources_string,

View File

@@ -3,7 +3,6 @@ import { useQuery, useQueryClient } from 'react-query';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { ILog } from 'types/api/logs/log';
import { getEntityEventsQueryPayload } from './utils';
@@ -11,7 +10,7 @@ export const K8S_ENTITY_EVENTS_EXPRESSION_KEY = 'k8sEntityEventsExpression';
export interface EventRowData {
id: string;
body: ILog['body'];
body: string;
severity_text: string;
attributes_string?: Record<string, string>;
resources_string?: Record<string, string>;

View File

@@ -12,9 +12,11 @@ import K8sGroupCell from '../Base/K8sGroupCell';
import ClickableStatusBadge from '../Base/components/StatusFilterCells/ClickableStatusBadge';
import ClickableStatusCounts from '../Base/components/StatusFilterCells/ClickableStatusCounts';
import { formatBytes, getPodStatusItems } from '../commonUtils';
import { INFRA_MONITORING_ATTR_KEYS } from '../constants';
import {
INFRA_MONITORING_ATTR_KEYS,
InfraMonitoringEntity,
} from '../constants';
import { TextNoData, ValidateColumnValueWrapper } from '../components';
import { InfraMonitoringEntity } from '../constants';
import { NODE_CONDITION_COLORS, NODE_CONDITION_LABELS } from './utils';
import { Workflow } from '@signozhq/icons';

View File

@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useAppLocation } from 'lib/router/useAppLocation';
import { Switch } from '@signozhq/ui/switch';
import { Typography } from '@signozhq/ui/typography';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import FieldsSelector from 'components/FieldsSelector';
import LogsFormatOptionsMenu from 'components/LogsFormatOptionsMenu/LogsFormatOptionsMenu';
import { MAX_LOGS_LIST_SIZE } from 'constants/liveTail';
@@ -54,7 +55,7 @@ function LiveLogsContainer({
const { options, config } = useOptionsMenu({
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
dataSource: DataSource.LOGS,
sourcePage: SavedviewtypesSourceDTO.logs,
aggregateOperator: listQuery?.aggregateOperator || StringOperators.NOOP,
});

View File

@@ -5,6 +5,7 @@ import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
import { toast } from '@signozhq/ui/sonner';
import { Card } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import LogDetail from 'components/LogDetail';
import { VIEW_TYPES } from 'components/LogDetail/constants';
import ListLogView from 'components/Logs/ListLogView';
@@ -30,7 +31,7 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
import { useEventSource } from 'providers/EventSource';
// interfaces
import { ILog } from 'types/api/logs/log';
import { DataSource, StringOperators } from 'types/common/queryBuilder';
import { StringOperators } from 'types/common/queryBuilder';
import loadingPlaneUrl from '@/assets/Icons/loading-plane.gif';
import { getAbsoluteUrl } from 'utils/basePath';
@@ -68,7 +69,7 @@ function LiveLogsList({
const { options, config } = useOptionsMenu({
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
dataSource: DataSource.LOGS,
sourcePage: SavedviewtypesSourceDTO.logs,
aggregateOperator: StringOperators.NOOP,
});

View File

@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Virtuoso } from 'react-virtuoso';
import { Button, Skeleton } from 'antd';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import RawLogView from 'components/Logs/RawLogView';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { LOCALSTORAGE } from 'constants/localStorage';
@@ -16,7 +17,7 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import useUrlQuery from 'hooks/useUrlQuery';
import { ILog } from 'types/api/logs/log';
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource, StringOperators } from 'types/common/queryBuilder';
import { StringOperators } from 'types/common/queryBuilder';
import { withBasePath } from 'utils/basePath';
import { useContextLogData } from './useContextLogData';
@@ -45,7 +46,7 @@ function ContextLogRenderer({
const { options } = useOptionsMenu({
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
dataSource: DataSource.LOGS,
sourcePage: SavedviewtypesSourceDTO.logs,
aggregateOperator: listQuery?.aggregateOperator || StringOperators.NOOP,
});

View File

@@ -33,10 +33,6 @@ import {
// Mock the useContextLogData hook
const mockHandleRunQuery = jest.fn();
jest.mock('hooks/logs/useIsBodyJsonEnabled', () => ({
useIsBodyJsonEnabled: (): boolean => false,
}));
jest.mock('container/OptionsMenu', () => ({
useOptionsMenu: (): any => ({
options: {

View File

@@ -1,6 +1,7 @@
import { useCallback, useMemo } from 'react';
import { CircleMinus, CirclePlus, Layers, RefreshCw } from '@signozhq/icons';
import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
import { FeatureKeys } from 'constants/features';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import {
ChangeViewFunctionType,
@@ -12,7 +13,7 @@ import {
PrettyViewAction,
VisibleActionsConfig,
} from 'periscope/components/PrettyView/PrettyView';
import { useIsBodyJsonEnabled } from 'hooks/logs/useIsBodyJsonEnabled';
import { useAppContext } from 'providers/App/App';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { LogDetailsAction } from '../constants';
@@ -53,7 +54,11 @@ export function useLogAttributeActions({
onApplyLogFilter,
}: UseLogAttributeActionsParams): UseLogAttributeActionsResult {
const { stagedQuery, updateQueriesData } = useQueryBuilder();
const isBodyJsonQueryEnabled = useIsBodyJsonEnabled();
const { featureFlags } = useAppContext();
const isBodyJsonQueryEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.USE_JSON_BODY)
?.active || false;
const filterFor = useCallback(
(context: FieldContext, isFilterIn: boolean): void => {

View File

@@ -2,7 +2,11 @@ import * as Sentry from '@sentry/react';
import Convert from 'ansi-to-html';
import { MetricsType } from 'container/MetricsApplication/constant';
import dompurify from 'dompurify';
import { ILog, ILogAggregateAttributesResources } from 'types/api/logs/log';
import {
ILog,
ILogAggregateAttributesResources,
ILogBody,
} from 'types/api/logs/log';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { FORBID_DOM_PURIFY_ATTR, FORBID_DOM_PURIFY_TAGS } from 'utils/app';
@@ -345,3 +349,8 @@ export const getSanitizedLogBody = (
return '{}';
}
};
// Returns a plain string for display contexts (Monaco editor, table cells, raw log row).
export function getBodyDisplayString(body: string | ILogBody): string {
return typeof body === 'string' ? body : JSON.stringify(body as ILogBody);
}

View File

@@ -5,6 +5,7 @@ import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
import { toast } from '@signozhq/ui/sonner';
import { Card } from 'antd';
import logEvent from 'api/common/logEvent';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import LogDetail from 'components/LogDetail';
import { VIEW_TYPES } from 'components/LogDetail/constants';
@@ -77,7 +78,7 @@ function LogsExplorerList({
const { options, config } = useOptionsMenu({
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
dataSource: DataSource.LOGS,
sourcePage: SavedviewtypesSourceDTO.logs,
aggregateOperator:
currentStagedQueryData?.aggregateOperator || StringOperators.NOOP,
});

View File

@@ -1,6 +1,7 @@
import { ReactNode, useState } from 'react';
import { Switch } from '@signozhq/ui/switch';
import { Typography } from '@signozhq/ui/typography';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
import FieldsSelector from 'components/FieldsSelector';
import LogsFormatOptionsMenu from 'components/LogsFormatOptionsMenu/LogsFormatOptionsMenu';
@@ -28,7 +29,7 @@ function LogsActionsContainer({
}): JSX.Element {
const { options, config } = useOptionsMenu({
storageKey: LOCALSTORAGE.LOGS_LIST_OPTIONS,
dataSource: DataSource.LOGS,
sourcePage: SavedviewtypesSourceDTO.logs,
aggregateOperator: listQuery?.aggregateOperator || StringOperators.NOOP,
});

View File

@@ -1,11 +1,20 @@
import { useQueries } from 'react-query';
import { renderHook } from '@testing-library/react';
import { createElement, ReactNode } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import type { NotificationInstance } from 'antd/es/notification/interface';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { useGetQueryKeySuggestions } from 'hooks/querySuggestions/useGetQueryKeySuggestions';
import { useNotifications } from 'hooks/useNotifications';
import useUrlQueryData from 'hooks/useUrlQueryData';
import { usePreferenceContext } from 'providers/preferences/context/PreferenceContextProvider';
import { DataSource } from 'types/common/queryBuilder';
import {
FormattingOptions,
PreferenceContextValue,
PreferenceSlice,
} from 'providers/preferences/types';
import { renderHook } from 'tests/test-utils';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { FontSize } from '../types';
import useOptionsMenu from '../useOptionsMenu';
// Mock all dependencies
@@ -13,104 +22,118 @@ jest.mock('hooks/useNotifications');
jest.mock('providers/preferences/context/PreferenceContextProvider');
jest.mock('hooks/useUrlQueryData');
jest.mock('hooks/querySuggestions/useGetQueryKeySuggestions');
jest.mock('react-query', () => ({
...jest.requireActual('react-query'),
useQueries: jest.fn(),
}));
const mockUseNotifications = jest.mocked(useNotifications);
const mockUsePreferenceContext = jest.mocked(usePreferenceContext);
const mockUseUrlQueryData = jest.mocked(useUrlQueryData);
const mockUseGetQueryKeySuggestions = jest.mocked(useGetQueryKeySuggestions);
type KeySuggestionsResult = ReturnType<typeof useGetQueryKeySuggestions>;
// The hook reads only `data.data.data.keys` and `isFetching`.
const keySuggestions = (
attributeKeys: Record<string, string>[],
): KeySuggestionsResult =>
({
data: { data: { data: { complete: true, keys: { attributeKeys } } } },
isFetching: false,
}) as unknown as KeySuggestionsResult;
const wrapper = ({ children }: { children: ReactNode }): JSX.Element =>
createElement(QueryClientProvider, { client: new QueryClient() }, children);
const toIds = (columns: TelemetryFieldKey[]): string[] =>
columns.map((column) => `${column.fieldContext}:${column.name}`);
describe('useOptionsMenu', () => {
const mockNotifications = { error: jest.fn(), success: jest.fn() };
const mockUpdateColumns = jest.fn();
const mockUpdateFormatting = jest.fn();
const mockRedirectWithQuery = jest.fn();
const mockNotifications = {
error: jest.fn() as jest.MockedFunction<NotificationInstance['error']>,
success: jest.fn() as jest.MockedFunction<NotificationInstance['success']>,
} as Partial<NotificationInstance> as NotificationInstance;
const mockUpdateColumns = jest.fn() as jest.MockedFunction<
PreferenceSlice['updateColumns']
>;
const mockUpdateFormatting = jest.fn() as jest.MockedFunction<
PreferenceSlice['updateFormatting']
>;
const mockRedirectWithQuery = jest.fn() as jest.MockedFunction<
(newQueryData: unknown) => void
>;
const slice = (
columns: TelemetryFieldKey[],
formatting: FormattingOptions,
): PreferenceSlice => ({
preferences: { columns, formatting },
loading: false,
error: null,
updateColumns: mockUpdateColumns,
updateFormatting: mockUpdateFormatting,
});
const rawFormatting: FormattingOptions = {
format: 'raw',
maxLines: 1,
fontSize: FontSize.SMALL,
};
beforeEach(() => {
jest.clearAllMocks();
(useNotifications as jest.Mock).mockReturnValue({
mockUseNotifications.mockReturnValue({
notifications: mockNotifications,
});
(usePreferenceContext as jest.Mock).mockReturnValue({
traces: {
preferences: {
columns: [],
formatting: {
format: 'raw',
maxLines: 1,
fontSize: 'small',
},
},
updateColumns: mockUpdateColumns,
updateFormatting: mockUpdateFormatting,
},
logs: {
preferences: {
columns: [],
formatting: {
format: 'raw',
maxLines: 1,
fontSize: 'small',
},
},
updateColumns: mockUpdateColumns,
updateFormatting: mockUpdateFormatting,
},
});
mockUsePreferenceContext.mockReturnValue({
[SavedviewtypesSourceDTO.traces]: slice([], rawFormatting),
[SavedviewtypesSourceDTO.logs]: slice([], rawFormatting),
} satisfies PreferenceContextValue);
(useUrlQueryData as jest.Mock).mockReturnValue({
mockUseUrlQueryData.mockReturnValue({
query: null,
queryData: undefined,
redirectWithQuery: mockRedirectWithQuery,
});
(useQueries as jest.Mock).mockReturnValue([]);
});
it('does not show isRoot or isEntryPoint in column options when dataSource is TRACES', () => {
it('does not show isRoot or isEntryPoint in column options when sourcePage is traces', () => {
// Mock the query key suggestions to return data including isRoot and isEntryPoint
(useGetQueryKeySuggestions as jest.Mock).mockReturnValue({
data: {
data: {
data: {
keys: {
attributeKeys: [
{
name: 'isRoot',
signal: 'traces',
fieldDataType: 'bool',
fieldContext: '',
},
{
name: 'isEntryPoint',
signal: 'traces',
fieldDataType: 'bool',
fieldContext: '',
},
{
name: 'duration',
signal: 'traces',
fieldDataType: 'float64',
fieldContext: '',
},
{
name: 'serviceName',
signal: 'traces',
fieldDataType: 'string',
fieldContext: '',
},
],
},
},
mockUseGetQueryKeySuggestions.mockReturnValue(
keySuggestions([
{
name: 'isRoot',
signal: 'traces',
fieldDataType: 'bool',
fieldContext: '',
},
},
isFetching: false,
});
{
name: 'isEntryPoint',
signal: 'traces',
fieldDataType: 'bool',
fieldContext: '',
},
{
name: 'duration',
signal: 'traces',
fieldDataType: 'float64',
fieldContext: '',
},
{
name: 'serviceName',
signal: 'traces',
fieldDataType: 'string',
fieldContext: '',
},
]),
);
const { result } = renderHook(() =>
useOptionsMenu({
dataSource: DataSource.TRACES,
aggregateOperator: 'count',
}),
const { result } = renderHook(
() =>
useOptionsMenu({
sourcePage: SavedviewtypesSourceDTO.traces,
aggregateOperator: 'count',
}),
{ wrapper },
);
// Get the column options from the config
@@ -127,98 +150,38 @@ describe('useOptionsMenu', () => {
expect(optionNames).toContain('serviceName');
});
it('does not show body in column options when dataSource is METRICS', () => {
it('shows body in column options when sourcePage is logs', () => {
// Mock the query key suggestions to return data including body
(useGetQueryKeySuggestions as jest.Mock).mockReturnValue({
data: {
data: {
data: {
keys: {
attributeKeys: [
{
name: 'body',
signal: 'logs',
fieldDataType: 'string',
fieldContext: '',
},
{
name: 'status',
signal: 'metrics',
fieldDataType: 'int64',
fieldContext: '',
},
{
name: 'value',
signal: 'metrics',
fieldDataType: 'float64',
fieldContext: '',
},
],
},
},
mockUseGetQueryKeySuggestions.mockReturnValue(
keySuggestions([
{
name: 'body',
signal: 'logs',
fieldDataType: 'string',
fieldContext: '',
},
},
isFetching: false,
});
const { result } = renderHook(() =>
useOptionsMenu({
dataSource: DataSource.METRICS,
aggregateOperator: 'count',
}),
{
name: 'level',
signal: 'logs',
fieldDataType: 'string',
fieldContext: '',
},
{
name: 'timestamp',
signal: 'logs',
fieldDataType: 'int64',
fieldContext: '',
},
]),
);
// Get the column options from the config
const columnOptions = result.current.config.addColumn?.options ?? [];
const optionNames = columnOptions.map((option) => option.label);
// body should NOT be in the options
expect(optionNames).not.toContain('body');
// Other attributes should be present
expect(optionNames).toContain('status');
expect(optionNames).toContain('value');
});
it('does not show body in column options when dataSource is LOGS', () => {
// Mock the query key suggestions to return data including body
(useGetQueryKeySuggestions as jest.Mock).mockReturnValue({
data: {
data: {
data: {
keys: {
attributeKeys: [
{
name: 'body',
signal: 'logs',
fieldDataType: 'string',
fieldContext: '',
},
{
name: 'level',
signal: 'logs',
fieldDataType: 'string',
fieldContext: '',
},
{
name: 'timestamp',
signal: 'logs',
fieldDataType: 'int64',
fieldContext: '',
},
],
},
},
},
},
isFetching: false,
});
const { result } = renderHook(() =>
useOptionsMenu({
dataSource: DataSource.LOGS,
aggregateOperator: 'count',
}),
const { result } = renderHook(
() =>
useOptionsMenu({
sourcePage: SavedviewtypesSourceDTO.logs,
aggregateOperator: 'count',
}),
{ wrapper },
);
// Get the column options from the config
@@ -234,15 +197,15 @@ describe('useOptionsMenu', () => {
});
describe('reorderSelectColumns / handleRemoveSelectedColumn — composite ID lookup', () => {
const baseFormatting = {
const baseFormatting: FormattingOptions = {
format: 'table',
maxLines: 1,
fontSize: 'small',
fontSize: FontSize.SMALL,
};
// Two entries share the same `name` but differ in `fieldContext` — the
// exact case composite IDs are meant to disambiguate.
const seedColumns = [
const seedColumns: TelemetryFieldKey[] = [
{
name: 'body',
fieldContext: 'log',
@@ -264,36 +227,26 @@ describe('useOptionsMenu', () => {
{
name: 'timestamp',
fieldContext: 'log',
fieldDataType: '',
signal: 'logs',
},
];
beforeEach(() => {
(useGetQueryKeySuggestions as jest.Mock).mockReturnValue({
data: { data: { data: { keys: {} } } },
isFetching: false,
});
(usePreferenceContext as jest.Mock).mockReturnValue({
traces: {
preferences: { columns: [], formatting: baseFormatting },
updateColumns: mockUpdateColumns,
updateFormatting: mockUpdateFormatting,
},
logs: {
preferences: { columns: seedColumns, formatting: baseFormatting },
updateColumns: mockUpdateColumns,
updateFormatting: mockUpdateFormatting,
},
});
mockUseGetQueryKeySuggestions.mockReturnValue(keySuggestions([]));
mockUsePreferenceContext.mockReturnValue({
[SavedviewtypesSourceDTO.traces]: slice([], baseFormatting),
[SavedviewtypesSourceDTO.logs]: slice(seedColumns, baseFormatting),
} satisfies PreferenceContextValue);
});
it('reorders by composite IDs — preserves both same-name variants distinctly', () => {
const { result } = renderHook(() =>
useOptionsMenu({
dataSource: DataSource.LOGS,
aggregateOperator: 'count',
}),
const { result } = renderHook(
() =>
useOptionsMenu({
sourcePage: SavedviewtypesSourceDTO.logs,
aggregateOperator: 'count',
}),
{ wrapper },
);
// New order: [attribute:service.name, log:body, resource:service.name, log:timestamp]
@@ -306,12 +259,7 @@ describe('useOptionsMenu', () => {
expect(mockUpdateColumns).toHaveBeenCalledTimes(1);
const reordered = mockUpdateColumns.mock.calls[0][0];
expect(
reordered.map(
(c: { name: string; fieldContext: string }) =>
`${c.fieldContext}:${c.name}`,
),
).toStrictEqual([
expect(toIds(reordered)).toStrictEqual([
'attribute:service.name',
'log:body',
'resource:service.name',
@@ -320,11 +268,13 @@ describe('useOptionsMenu', () => {
});
it('reorder ignores ids that are not columns (e.g. state-indicator) and skips unknown ids', () => {
const { result } = renderHook(() =>
useOptionsMenu({
dataSource: DataSource.LOGS,
aggregateOperator: 'count',
}),
const { result } = renderHook(
() =>
useOptionsMenu({
sourcePage: SavedviewtypesSourceDTO.logs,
aggregateOperator: 'count',
}),
{ wrapper },
);
result.current.config.addColumn?.onReorder([
@@ -338,12 +288,7 @@ describe('useOptionsMenu', () => {
const reordered = mockUpdateColumns.mock.calls[0][0];
// state-indicator + unknown.composite filtered; rest preserved in given order.
expect(
reordered.map(
(c: { name: string; fieldContext: string }) =>
`${c.fieldContext}:${c.name}`,
),
).toStrictEqual([
expect(toIds(reordered)).toStrictEqual([
'log:timestamp',
'log:body',
'resource:service.name',
@@ -352,11 +297,13 @@ describe('useOptionsMenu', () => {
});
it('removes only the variant whose composite ID matches', () => {
const { result } = renderHook(() =>
useOptionsMenu({
dataSource: DataSource.LOGS,
aggregateOperator: 'count',
}),
const { result } = renderHook(
() =>
useOptionsMenu({
sourcePage: SavedviewtypesSourceDTO.logs,
aggregateOperator: 'count',
}),
{ wrapper },
);
// Removing 'resource:service.name' should drop ONLY the resource variant.
@@ -364,20 +311,21 @@ describe('useOptionsMenu', () => {
expect(mockUpdateColumns).toHaveBeenCalledTimes(1);
const remaining = mockUpdateColumns.mock.calls[0][0];
expect(
remaining.map(
(c: { name: string; fieldContext: string }) =>
`${c.fieldContext}:${c.name}`,
),
).toStrictEqual(['log:body', 'attribute:service.name', 'log:timestamp']);
expect(toIds(remaining)).toStrictEqual([
'log:body',
'attribute:service.name',
'log:timestamp',
]);
});
it('removing by a non-matching composite ID is a no-op (filter returns the full list)', () => {
const { result } = renderHook(() =>
useOptionsMenu({
dataSource: DataSource.LOGS,
aggregateOperator: 'count',
}),
const { result } = renderHook(
() =>
useOptionsMenu({
sourcePage: SavedviewtypesSourceDTO.logs,
aggregateOperator: 'count',
}),
{ wrapper },
);
result.current.config.addColumn?.onRemove('unknown.composite');
@@ -390,35 +338,34 @@ describe('useOptionsMenu', () => {
describe('fieldsSelector.value drops legacy columns without a name', () => {
it('excludes entries missing name while keeping valid columns', () => {
(useGetQueryKeySuggestions as jest.Mock).mockReturnValue({
data: { data: { data: { keys: {} } } },
isFetching: false,
});
(usePreferenceContext as jest.Mock).mockReturnValue({
traces: {
preferences: {
columns: [
{ name: 'body', fieldContext: 'log' },
{ key: 'legacy-key-no-name', fieldContext: 'log' },
{ name: 'timestamp', fieldContext: 'log' },
],
formatting: { format: 'table', maxLines: 1, fontSize: 'small' },
mockUseGetQueryKeySuggestions.mockReturnValue(keySuggestions([]));
mockUsePreferenceContext.mockReturnValue({
[SavedviewtypesSourceDTO.traces]: slice(
[
{ name: 'body', fieldContext: 'log' },
// A legacy stored column: `key` only, no `name`.
{
key: 'legacy-key-no-name',
fieldContext: 'log',
} as unknown as TelemetryFieldKey,
{ name: 'timestamp', fieldContext: 'log' },
],
{
format: 'table',
maxLines: 1,
fontSize: FontSize.SMALL,
},
updateColumns: mockUpdateColumns,
updateFormatting: mockUpdateFormatting,
},
logs: {
preferences: { columns: [], formatting: {} },
updateColumns: mockUpdateColumns,
updateFormatting: mockUpdateFormatting,
},
});
),
[SavedviewtypesSourceDTO.logs]: slice([], {}),
} satisfies PreferenceContextValue);
const { result } = renderHook(() =>
useOptionsMenu({
dataSource: DataSource.TRACES,
aggregateOperator: 'count',
}),
const { result } = renderHook(
() =>
useOptionsMenu({
sourcePage: SavedviewtypesSourceDTO.traces,
aggregateOperator: 'count',
}),
{ wrapper },
);
const fields = result.current.config.fieldsSelector?.value ?? [];

View File

@@ -1,4 +1,6 @@
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { TelemetryFieldKey } from 'api/v5/v5';
import { PREFERENCE_SOURCE_PAGES } from 'providers/preferences/constants';
import { DataSource } from 'types/common/queryBuilder';
import { FontSize, OptionsQuery } from './types';
@@ -13,6 +15,14 @@ export const defaultOptionsQuery: OptionsQuery = {
fontSize: FontSize.SMALL,
};
export const SOURCE_PAGE_SIGNAL: Record<
(typeof PREFERENCE_SOURCE_PAGES)[number],
DataSource
> = {
[SavedviewtypesSourceDTO.logs]: DataSource.LOGS,
[SavedviewtypesSourceDTO.traces]: DataSource.TRACES,
};
export const EXCLUDED_COLUMNS: Record<DataSource, string[]> = {
[DataSource.TRACES]: ['body', 'isRoot', 'isEntryPoint'],
[DataSource.METRICS]: ['body'],

View File

@@ -1,6 +1,7 @@
/* eslint-disable sonarjs/cognitive-complexity */
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useQueries } from 'react-query';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
import { TelemetryFieldKey } from 'api/v5/v5';
import { AxiosResponse } from 'axios';
@@ -11,6 +12,7 @@ import useUrlQueryData from 'hooks/useUrlQueryData';
import { has } from 'lodash-es';
import { AllTraceFilterKeyValue } from 'constants/traceFilterKeys';
import { usePreferenceContext } from 'providers/preferences/context/PreferenceContextProvider';
import { PreferenceSourcePage } from 'providers/preferences/types';
import {
QueryKeyRequestProps,
QueryKeySuggestionsResponseProps,
@@ -20,13 +22,13 @@ import {
FieldDataType,
SignalType,
} from 'types/api/v5/queryRange';
import { DataSource } from 'types/common/queryBuilder';
import {
defaultLogsSelectedColumns,
defaultOptionsQuery,
defaultTraceSelectedColumns,
EXCLUDED_COLUMNS,
SOURCE_PAGE_SIGNAL,
URL_OPTIONS,
} from './constants';
import {
@@ -40,7 +42,7 @@ import { buildCompositeKey, getOptionsFromKeys } from './utils';
interface UseOptionsMenuProps {
storageKey?: string;
dataSource: DataSource;
sourcePage: PreferenceSourcePage;
aggregateOperator?: string;
initialOptions?: InitialOptions;
}
@@ -52,15 +54,14 @@ interface UseOptionsMenu {
}
const useOptionsMenu = ({
dataSource,
// aggregateOperator,
sourcePage,
initialOptions = {},
}: UseOptionsMenuProps): UseOptionsMenu => {
const signal = SOURCE_PAGE_SIGNAL[sourcePage];
const { notifications } = useNotifications();
const prefCtx = usePreferenceContext();
// TODO: send null to updateColumns and updateFormatting if dataSource is not logs or traces
const slice = dataSource === DataSource.TRACES ? prefCtx.traces : prefCtx.logs;
const { preferences, updateColumns, updateFormatting } = slice;
const { preferences, updateColumns, updateFormatting } =
usePreferenceContext()[sourcePage];
const [searchText, setSearchText] = useState<string>('');
const [isFocused, setIsFocused] = useState<boolean>(false);
@@ -68,10 +69,10 @@ const useOptionsMenu = ({
const initialQueryParamsV5: QueryKeyRequestProps = useMemo(
() => ({
signal: dataSource,
signal,
searchText: '',
}),
[dataSource],
[signal],
);
const { query: optionsQuery, redirectWithQuery: redirectWithOptionsData } =
@@ -127,7 +128,7 @@ const useOptionsMenu = ({
?.map((column) => attributesData.find(({ name }) => name === column))
.filter((e) => !!e) || []) as TelemetryFieldKey[];
if (dataSource === DataSource.TRACES) {
if (sourcePage === SavedviewtypesSourceDTO.traces) {
initialSelected = initialSelected
?.map((col) => {
if (col && Object.keys(AllTraceFilterKeyValue).includes(col?.name)) {
@@ -157,7 +158,7 @@ const useOptionsMenu = ({
isFetchedInitialAttributes,
initialOptions?.selectColumns,
initialAttributesResult,
dataSource,
sourcePage,
]);
const {
@@ -195,7 +196,7 @@ const useOptionsMenu = ({
fieldDataType: e.fieldDataType,
}));
}
if (dataSource === DataSource.TRACES) {
if (sourcePage === SavedviewtypesSourceDTO.traces) {
return defaultTraceSelectedColumns.map((e) => ({
...e,
name: e.name,
@@ -203,16 +204,16 @@ const useOptionsMenu = ({
}
return [];
}, [dataSource, searchedAttributesDataV5?.data.data.keys]);
}, [sourcePage, searchedAttributesDataV5?.data.data.keys]);
const initialOptionsQuery: OptionsQuery = useMemo(() => {
let defaultColumns: TelemetryFieldKey[] = defaultOptionsQuery.selectColumns;
if (dataSource === DataSource.TRACES) {
if (sourcePage === SavedviewtypesSourceDTO.traces) {
defaultColumns = defaultTraceSelectedColumns.map((e) => ({
...e,
name: e.name,
}));
} else if (dataSource === DataSource.LOGS) {
} else if (sourcePage === SavedviewtypesSourceDTO.logs) {
// eslint-disable-next-line sonarjs/no-identical-functions
defaultColumns = defaultLogsSelectedColumns.map((e) => ({
...e,
@@ -232,7 +233,7 @@ const useOptionsMenu = ({
...initialOptions,
selectColumns: finalSelectColumns,
};
}, [dataSource, initialOptions, initialSelectedColumns]);
}, [sourcePage, initialOptions, initialSelectedColumns]);
const selectedColumnKeys = useMemo(
() => preferences?.columns?.map(({ name }) => name) || [],
@@ -241,7 +242,7 @@ const useOptionsMenu = ({
const optionsFromAttributeKeys = useMemo(() => {
const filteredAttributeKeys = searchedAttributeKeys.filter((item) => {
const exclusions = EXCLUDED_COLUMNS[dataSource];
const exclusions = EXCLUDED_COLUMNS[signal];
if (exclusions) {
return !exclusions.includes(item.name);
}
@@ -249,7 +250,7 @@ const useOptionsMenu = ({
});
return getOptionsFromKeys(filteredAttributeKeys, selectedColumnKeys);
}, [dataSource, searchedAttributeKeys, selectedColumnKeys]);
}, [signal, searchedAttributeKeys, selectedColumnKeys]);
const handleRedirectWithOptionsData = useCallback(
(newQueryData: OptionsQuery) => {
@@ -285,7 +286,10 @@ const useOptionsMenu = ({
buildCompositeKey(f.name, f.fieldContext, f.fieldDataType) !== columnKey,
);
if (!newSelectedColumns?.length && dataSource !== DataSource.LOGS) {
if (
!newSelectedColumns?.length &&
sourcePage !== SavedviewtypesSourceDTO.logs
) {
notifications.error({
message: 'There must be at least one selected column',
});
@@ -294,7 +298,7 @@ const useOptionsMenu = ({
updateColumns(newSelectedColumns || []);
},
[dataSource, notifications, preferences, updateColumns],
[sourcePage, notifications, preferences, updateColumns],
);
const handleFormatChange = useCallback(

View File

@@ -2,8 +2,9 @@ import { useTranslation } from 'react-i18next';
import { Input } from '@signozhq/ui/input';
import { Switch } from '@signozhq/ui/switch';
import { Form, Select, Space } from 'antd';
import { FeatureKeys } from 'constants/features';
import { ModalFooterTitle } from 'container/PipelinePage/styles';
import { useIsBodyJsonEnabled } from 'hooks/logs/useIsBodyJsonEnabled';
import { useAppContext } from 'providers/App/App';
import { ProcessorData } from 'types/api/pipeline/def';
import { formValidationRules } from '../config';
@@ -137,7 +138,10 @@ function ProcessorForm({
selectedProcessorData,
isAdd,
}: ProcessorFormProps): JSX.Element {
const isBodyJsonEnabled = useIsBodyJsonEnabled();
const { featureFlags } = useAppContext();
const isBodyJsonEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.USE_JSON_BODY)
?.active || false;
return (
<div className="processor-form-container">

View File

@@ -2,8 +2,8 @@ import { Expand } from '@signozhq/icons';
import LogDetail from 'components/LogDetail';
import { VIEW_TYPES } from 'components/LogDetail/constants';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { getBodyDisplayString } from 'container/LogDetailedView/utils';
import { useActiveLog } from 'hooks/logs/useActiveLog';
import { getBodyDisplay } from 'lib/logs/logBody';
import { useTimezone } from 'providers/Timezone';
import { ILog } from 'types/api/logs/log';
@@ -27,7 +27,7 @@ function LogsList({ logs }: LogsListProps): JSX.Element {
)}
</div>
<div className="logs-preview-list-item-body">
{getBodyDisplay(log.body)}
{getBodyDisplayString(log.body)}
</div>
<div
className="logs-preview-list-item-expand"

View File

@@ -146,13 +146,114 @@ describe('PermissionEditor - TelemetrySelectorWizard', () => {
await expect(screen.findByText('promql/*')).resolves.toBeInTheDocument();
});
it('hardcodes the key and does not let it be edited', async () => {
it('defaults the key to signoz.workspace.key.id', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
const keyInput = screen.getByTestId('wizard-key-input-logs-read');
expect(keyInput).toHaveValue('signoz.workspace.key.id');
expect(keyInput).toBeDisabled();
expect(screen.getByTestId('wizard-key-select-logs-read')).toHaveTextContent(
'signoz.workspace.key.id',
);
});
it('rewrites the selector when another key is picked', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
await user.type(
screen.getByTestId('wizard-value-input-logs-read'),
'checkout',
);
await user.click(screen.getByTestId('wizard-key-select-logs-read'));
await user.click(
await screen.findByTestId(
'wizard-key-option-resource.service.name-logs-read',
),
);
expect(screen.getByTestId('wizard-selector-input-logs-read')).toHaveValue(
'builder_query/resource.service.name/checkout',
);
await user.click(screen.getByTestId('wizard-add-btn-logs-read'));
await expect(
screen.findByText('builder_query/resource.service.name/checkout'),
).resolves.toBeInTheDocument();
});
it('folds the resource prefix of the workspace key from a typed selector', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
const selectorInput = screen.getByTestId('wizard-selector-input-logs-read');
await user.clear(selectorInput);
await user.type(
selectorInput,
'builder_query/resource.signoz.workspace.key.id/key-a',
);
expect(screen.getByTestId('wizard-key-select-logs-read')).toHaveTextContent(
'signoz.workspace.key.id',
);
await user.type(screen.getByTestId('wizard-value-input-logs-read'), '2');
expect(selectorInput).toHaveValue(
'builder_query/signoz.workspace.key.id/key-a2',
);
});
it('accepts the resource-prefixed workspace key in the hint', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
const selectorInput = screen.getByTestId('wizard-selector-input-logs-read');
await user.clear(selectorInput);
await user.type(
selectorInput,
'builder_query/resource.signoz.workspace.key.id/key-a',
);
expect(
screen.getByTestId('wizard-selector-hint-logs-read'),
).toHaveTextContent(
'Allow signoz.workspace.key.id=key-a for Builder Query queries.',
);
});
it('selects the key from a typed selector', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
const selectorInput = screen.getByTestId('wizard-selector-input-logs-read');
await user.clear(selectorInput);
await user.type(
selectorInput,
'builder_query/resource.deployment.environment.name/prod',
);
expect(screen.getByTestId('wizard-key-select-logs-read')).toHaveTextContent(
'resource.deployment.environment.name',
);
expect(screen.getByTestId('wizard-value-input-logs-read')).toHaveValue(
'prod',
);
});
it('hints when a typed selector uses an unsupported key', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
const selectorInput = screen.getByTestId('wizard-selector-input-logs-read');
await user.clear(selectorInput);
await user.type(selectorInput, 'builder_query/host.name/web-1');
expect(screen.getByTestId('wizard-key-select-logs-read')).toHaveTextContent(
'signoz.workspace.key.id',
);
expect(
screen.getByTestId('wizard-selector-hint-logs-read'),
).toHaveTextContent('"host.name" is not a supported key.');
});
it('hides Key field for query types that do not support key scoping', async () => {
@@ -163,7 +264,7 @@ describe('PermissionEditor - TelemetrySelectorWizard', () => {
await user.click(await screen.findByText('ClickHouse SQL'));
expect(
screen.queryByTestId('wizard-key-input-logs-read'),
screen.queryByTestId('wizard-key-select-logs-read'),
).not.toBeInTheDocument();
});
@@ -288,38 +389,18 @@ describe('PermissionEditor - TelemetrySelectorWizard', () => {
expect(screen.getByRole('checkbox', { name: 'Any value' })).toBeChecked();
});
it('keeps the key input hardcoded when the selector uses another key', async () => {
it('restores the selected key in the selector once the value changes', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
const selectorInput = screen.getByTestId('wizard-selector-input-logs-read');
await user.clear(selectorInput);
await user.type(selectorInput, 'builder_query/service.name/frontend');
expect(screen.getByTestId('wizard-key-input-logs-read')).toHaveValue(
'signoz.workspace.key.id',
);
expect(
screen.getByTestId('wizard-selector-hint-logs-read'),
).toHaveTextContent('Allow service.name=frontend for Builder Query queries.');
expect(screen.getByTestId('wizard-add-btn-logs-read')).not.toHaveAttribute(
'aria-disabled',
'true',
);
});
it('restores the hardcoded key in the selector once the value changes', async () => {
const user = userEvent.setup();
await openLogsWizard(user);
const selectorInput = screen.getByTestId('wizard-selector-input-logs-read');
await user.clear(selectorInput);
await user.type(selectorInput, 'builder_query/service.name/frontend');
await user.type(selectorInput, 'builder_query/host.name/web-1');
await user.type(screen.getByTestId('wizard-value-input-logs-read'), '2');
expect(selectorInput).toHaveValue(
'builder_query/signoz.workspace.key.id/frontend2',
'builder_query/signoz.workspace.key.id/web-12',
);
expect(screen.getByTestId('wizard-add-btn-logs-read')).not.toHaveAttribute(
'aria-disabled',

View File

@@ -30,17 +30,26 @@ export const QUERY_TYPES: readonly QueryTypeOption[] = [
export const DEFAULT_QUERY_TYPE: QueryTypeId = 'builder_query';
export const SUPPORTED_GRANT_KEY = 'signoz.workspace.key.id';
// mirrors telemetryGrantKeys in pkg/types/telemetrytypes/selector.go
export const SUPPORTED_GRANT_KEYS: readonly string[] = [
'signoz.workspace.key.id',
'resource.service.name',
'resource.deployment.environment.name',
];
export const DEFAULT_GRANT_KEY = 'signoz.workspace.key.id';
export const ANY_RESOURCE_VALUE = '*';
export interface SelectorDraft {
queryType: QueryTypeId;
key: string;
value: string;
}
export interface ParsedSelector {
queryType?: QueryTypeId;
key?: string;
value: string;
}

View File

@@ -15,7 +15,7 @@ import { Typography } from '@signozhq/ui/typography';
import {
ANY_RESOURCE_VALUE,
QUERY_TYPES,
SUPPORTED_GRANT_KEY,
SUPPORTED_GRANT_KEYS,
} from './TelemetrySelectorWizard.constants';
import { isQueryTypeAvailable } from './TelemetrySelectorWizard.utils';
import useTelemetrySelectorWizard from './useTelemetrySelectorWizard';
@@ -38,6 +38,7 @@ function TelemetrySelectorWizard({
open,
queryType,
selectedQueryType,
grantKey,
value,
selector,
isAnyResource,
@@ -46,6 +47,7 @@ function TelemetrySelectorWizard({
canAdd,
handleOpenChange,
handleQueryTypeChange,
handleKeyChange,
handleValueChange,
handleAnyResourceChange,
handleSelectorChange,
@@ -130,12 +132,22 @@ function TelemetrySelectorWizard({
<Typography as="label" weight="medium">
Key
</Typography>
<Input
value={SUPPORTED_GRANT_KEY}
readOnly
disabled
testId={`wizard-key-input-${testId}`}
/>
<Select value={grantKey} onChange={handleKeyChange}>
<SelectTrigger data-testid={`wizard-key-select-${testId}`}>
<SelectValue>{grantKey}</SelectValue>
</SelectTrigger>
<SelectContent withPortal={false} className={styles.selectContent}>
{SUPPORTED_GRANT_KEYS.map((supportedKey) => (
<SelectItem
key={supportedKey}
value={supportedKey}
testId={`wizard-key-option-${supportedKey}-${testId}`}
>
{supportedKey}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}

View File

@@ -2,13 +2,14 @@ import { AuthZResource } from 'lib/authz/hooks/useAuthZ/types';
import {
ANY_RESOURCE_VALUE,
DEFAULT_GRANT_KEY,
ParsedSelector,
QUERY_TYPES,
QueryTypeId,
QueryTypeOption,
SelectorDraft,
SelectorValidation,
SUPPORTED_GRANT_KEY,
SUPPORTED_GRANT_KEYS,
} from './TelemetrySelectorWizard.constants';
const METRIC_RESOURCES: ReadonlySet<AuthZResource> = new Set<AuthZResource>([
@@ -37,6 +38,20 @@ export function isAnyResourceValue(value: string): boolean {
return value.trim() === ANY_RESOURCE_VALUE;
}
export function isSupportedGrantKey(key: string): boolean {
return SUPPORTED_GRANT_KEYS.includes(key);
}
const RESOURCE_PREFIX = 'resource.';
// the backend folds resource.<key> to <key> when <key> is itself supported
function foldGrantKey(key: string): string {
const stripped = key.startsWith(RESOURCE_PREFIX)
? key.slice(RESOURCE_PREFIX.length)
: key;
return isSupportedGrantKey(stripped) ? stripped : key;
}
function splitSelector(selector: string): string[] {
const parts = selector.split('/');
@@ -47,14 +62,18 @@ function splitSelector(selector: string): string[] {
return [parts[0], parts[1], parts.slice(2).join('/')];
}
export function buildSelector({ queryType, value }: SelectorDraft): string {
export function buildSelector({
queryType,
key,
value,
}: SelectorDraft): string {
const trimmedValue = value.trim();
if (!supportsKeyScoping(queryType) || !trimmedValue) {
return `${queryType}/${ANY_RESOURCE_VALUE}`;
}
return `${queryType}/${SUPPORTED_GRANT_KEY}/${trimmedValue}`;
return `${queryType}/${key}/${trimmedValue}`;
}
export function parseSelector(selector: string): ParsedSelector {
@@ -62,6 +81,7 @@ export function parseSelector(selector: string): ParsedSelector {
return {
queryType: getQueryTypeOption(parts[0])?.id,
key: parts.length >= 3 ? foldGrantKey(parts[1]) : undefined,
value: parts.length >= 3 ? parts[2] : '',
};
}
@@ -101,7 +121,9 @@ export function validateSelector(selector: string): SelectorValidation {
}
return {
message: `Use <query-type>/${ANY_RESOURCE_VALUE} or <query-type>/${SUPPORTED_GRANT_KEY}/<value>.`,
message: `Use <query-type>/${ANY_RESOURCE_VALUE} or <query-type>/<key>/<value> with one of: ${SUPPORTED_GRANT_KEYS.join(
', ',
)}.`,
isError: false, // intentionally not an error
};
}
@@ -112,7 +134,8 @@ export function validateSelector(selector: string): SelectorValidation {
};
}
const [, key, value] = parts;
const [, rawKey, value] = parts;
const key = foldGrantKey(rawKey);
if (value === ANY_RESOURCE_VALUE) {
return {
@@ -128,6 +151,15 @@ export function validateSelector(selector: string): SelectorValidation {
};
}
if (!isSupportedGrantKey(key)) {
return {
message: `"${key}" is not a supported key. Use one of: ${SUPPORTED_GRANT_KEYS.join(
', ',
)}.`,
isError: false, // intentionally not an error
};
}
return {
message: `Allow ${key}=${value} for ${option.label} queries.`,
isError: false,
@@ -135,5 +167,5 @@ export function validateSelector(selector: string): SelectorValidation {
}
export function getDefaultSelector(queryType: QueryTypeId): string {
return buildSelector({ queryType, value: '' });
return buildSelector({ queryType, key: DEFAULT_GRANT_KEY, value: '' });
}

View File

@@ -2,6 +2,7 @@ import { useCallback, useMemo, useState } from 'react';
import {
ANY_RESOURCE_VALUE,
DEFAULT_GRANT_KEY,
DEFAULT_QUERY_TYPE,
QueryTypeId,
QueryTypeOption,
@@ -12,6 +13,7 @@ import {
getDefaultSelector,
getQueryTypeOption,
isAnyResourceValue,
isSupportedGrantKey,
parseSelector,
validateSelector,
} from './TelemetrySelectorWizard.utils';
@@ -24,6 +26,7 @@ interface UseTelemetrySelectorWizardResult {
open: boolean;
queryType: QueryTypeId;
selectedQueryType: QueryTypeOption | undefined;
grantKey: string;
value: string;
selector: string;
isAnyResource: boolean;
@@ -32,6 +35,7 @@ interface UseTelemetrySelectorWizardResult {
canAdd: boolean;
handleOpenChange: (nextOpen: boolean) => void;
handleQueryTypeChange: (value: string | string[]) => void;
handleKeyChange: (value: string | string[]) => void;
handleValueChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
handleAnyResourceChange: (checked: boolean) => void;
handleSelectorChange: (event: React.ChangeEvent<HTMLInputElement>) => void;
@@ -44,6 +48,7 @@ function useTelemetrySelectorWizard({
}: UseTelemetrySelectorWizardParams): UseTelemetrySelectorWizardResult {
const [open, setOpen] = useState(false);
const [queryType, setQueryType] = useState<QueryTypeId>(DEFAULT_QUERY_TYPE);
const [grantKey, setGrantKey] = useState(DEFAULT_GRANT_KEY);
const [value, setValue] = useState('');
const [selector, setSelector] = useState(() =>
getDefaultSelector(DEFAULT_QUERY_TYPE),
@@ -58,10 +63,17 @@ function useTelemetrySelectorWizard({
const validation = useMemo(() => validateSelector(selector), [selector]);
const applyDraft = useCallback(
(nextQueryType: QueryTypeId, nextValue: string): void => {
(nextQueryType: QueryTypeId, nextKey: string, nextValue: string): void => {
setQueryType(nextQueryType);
setGrantKey(nextKey);
setValue(nextValue);
setSelector(buildSelector({ queryType: nextQueryType, value: nextValue }));
setSelector(
buildSelector({
queryType: nextQueryType,
key: nextKey,
value: nextValue,
}),
);
},
[],
);
@@ -71,23 +83,30 @@ function useTelemetrySelectorWizard({
const selected = (Array.isArray(next) ? next[0] : next) as QueryTypeId;
const keepsValue = getQueryTypeOption(selected)?.supportsKeyScoping ?? false;
applyDraft(selected, keepsValue ? value : '');
applyDraft(selected, grantKey, keepsValue ? value : '');
},
[applyDraft, value],
[applyDraft, grantKey, value],
);
const handleKeyChange = useCallback(
(next: string | string[]): void => {
applyDraft(queryType, Array.isArray(next) ? next[0] : next, value);
},
[applyDraft, queryType, value],
);
const handleValueChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>): void => {
applyDraft(queryType, event.target.value);
applyDraft(queryType, grantKey, event.target.value);
},
[applyDraft, queryType],
[applyDraft, queryType, grantKey],
);
const handleAnyResourceChange = useCallback(
(checked: boolean): void => {
applyDraft(queryType, checked ? ANY_RESOURCE_VALUE : '');
applyDraft(queryType, grantKey, checked ? ANY_RESOURCE_VALUE : '');
},
[applyDraft, queryType],
[applyDraft, queryType, grantKey],
);
const handleSelectorChange = useCallback(
@@ -99,6 +118,9 @@ function useTelemetrySelectorWizard({
if (parsed.queryType) {
setQueryType(parsed.queryType);
}
if (parsed.key && isSupportedGrantKey(parsed.key)) {
setGrantKey(parsed.key);
}
setValue(parsed.value);
},
[],
@@ -109,6 +131,7 @@ function useTelemetrySelectorWizard({
if (!nextOpen) {
setQueryType(DEFAULT_QUERY_TYPE);
setGrantKey(DEFAULT_GRANT_KEY);
setValue('');
setSelector(getDefaultSelector(DEFAULT_QUERY_TYPE));
}
@@ -138,6 +161,7 @@ function useTelemetrySelectorWizard({
open,
queryType,
selectedQueryType,
grantKey,
value,
selector,
isAnyResource: isAnyResourceValue(value),
@@ -146,6 +170,7 @@ function useTelemetrySelectorWizard({
canAdd: !validation.isError,
handleOpenChange,
handleQueryTypeChange,
handleKeyChange,
handleValueChange,
handleAnyResourceChange,
handleSelectorChange,

View File

@@ -1,10 +1,9 @@
import { useState } from 'react';
import { act, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Button } from '@signozhq/ui/button';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import ROUTES from 'constants/routes';
import { usePreferenceContext } from 'providers/preferences/context/PreferenceContextProvider';
import { act, screen, userEvent, waitFor } from 'tests/test-utils';
import SavedViewsHeader from '../SavedViewsHeader';
import { mockSavedViewsApi } from './savedViewsApiMock';
@@ -28,7 +27,7 @@ const errors = {
};
function Harness(): JSX.Element {
const { traces } = usePreferenceContext();
const traces = usePreferenceContext()[SavedviewtypesSourceDTO.traces];
const [headerKey, setHeaderKey] = useState(0);
const columns = traces.preferences?.columns ?? [];
@@ -37,7 +36,7 @@ function Harness(): JSX.Element {
<SavedViewsHeader
key={headerKey}
source={SavedviewtypesSourceDTO.traces}
onOpenViews={jest.fn()}
onOpenViews={jest.fn() as jest.MockedFunction<() => void>}
/>
<span data-testid="columns">
{columns.map((column) => column.name).join(',')}
@@ -84,13 +83,14 @@ describe('SavedViewsHeader columns', () => {
});
it('keeps edited columns when the view is fetched again', async () => {
const user = userEvent.setup();
mockSavedViewsApi([errors]);
await openView();
await userEvent.click(screen.getByTestId('add-column'));
await user.click(screen.getByTestId('add-column'));
await waitFor(() => expect(isDirty()).toBe(true));
await userEvent.click(screen.getByTestId('remount-header'));
await user.click(screen.getByTestId('remount-header'));
await act(() => new Promise((resolve) => setTimeout(resolve, 200)));
expect(columnsText()).toBe('service.name,name,extra-2');
@@ -98,35 +98,37 @@ describe('SavedViewsHeader columns', () => {
});
it('discard puts the saved columns back', async () => {
const user = userEvent.setup();
mockSavedViewsApi([errors]);
await openView();
await userEvent.click(screen.getByTestId('add-column'));
await user.click(screen.getByTestId('add-column'));
await waitFor(() => expect(isDirty()).toBe(true));
await userEvent.click(screen.getByTestId('saved-views-discard'));
await user.click(screen.getByTestId('saved-views-discard'));
await waitFor(() => expect(columnsText()).toBe('service.name,name'));
expect(isDirty()).toBe(false);
});
it('discard after an update goes back to the updated columns', async () => {
const user = userEvent.setup();
mockSavedViewsApi([errors]);
await openView();
await userEvent.click(screen.getByTestId('add-column'));
await user.click(screen.getByTestId('add-column'));
await waitFor(() => expect(isDirty()).toBe(true));
await userEvent.click(screen.getByTestId('saved-views-save-changes'));
await user.click(screen.getByTestId('saved-views-save-changes'));
const update = await screen.findByTestId(
'saved-views-save-changes-item-update',
);
await waitFor(() => expectRowUsable(update), MENU_CHECK_TIMEOUT);
await userEvent.click(update);
await user.click(update);
await waitFor(() => expect(isDirty()).toBe(false));
await userEvent.click(screen.getByTestId('add-column'));
await user.click(screen.getByTestId('add-column'));
await waitFor(() => expect(isDirty()).toBe(true));
await userEvent.click(screen.getByTestId('saved-views-discard'));
await user.click(screen.getByTestId('saved-views-discard'));
await waitFor(() => expect(columnsText()).toBe('service.name,name,extra-2'));
expect(isDirty()).toBe(false);

View File

@@ -1,28 +1,16 @@
import { Copy, Link, PenLine, Trash2 } from '@signozhq/icons';
import {
SavedviewtypesSchemaVersionDTO,
SavedviewtypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { SavedviewtypesSchemaVersionDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import {
buildSavedViewDeletePermission,
buildSavedViewUpdatePermission,
} from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
import { DataSource } from 'types/common/queryBuilder';
import { SavedViewRowActionConfig, SaveViewModalMode } from './types';
export const SAVED_VIEW_SCHEMA_VERSION = SavedviewtypesSchemaVersionDTO.v2;
// Only the logs and traces explorers have columns and formatting to save.
export const SAVED_VIEW_OPTIONS_DATA_SOURCE: Partial<
Record<SavedviewtypesSourceDTO, DataSource>
> = {
[SavedviewtypesSourceDTO.logs]: DataSource.LOGS,
[SavedviewtypesSourceDTO.traces]: DataSource.TRACES,
};
// What selecting a view writes to the url, so clearing it removes exactly these.
export const SAVED_VIEW_URL_PARAMS = [
QueryParams.viewKey,

View File

@@ -8,10 +8,9 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
import { buildSavedViewReadPermission } from 'lib/authz/hooks/useAuthZ/permissions/saved-view.permissions';
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
import { DataSource } from 'types/common/queryBuilder';
import { isPreferenceSourcePage } from 'providers/preferences/utils';
import { toAPIError } from 'utils/errorUtils';
import { SAVED_VIEW_OPTIONS_DATA_SOURCE } from '../constants';
import { UseActiveSavedViewResult } from '../types';
import { hasUnsavedViewChanges } from '../utils/hasUnsavedViewChanges';
@@ -21,9 +20,9 @@ export function useActiveSavedView(
const { viewKey } = useGetSavedViewParams();
const { stagedQuery, panelType } = useQueryBuilder();
const optionsDataSource = SAVED_VIEW_OPTIONS_DATA_SOURCE[source];
const hasOptions = isPreferenceSourcePage(source);
const { options } = useOptionsMenu({
dataSource: optionsDataSource ?? DataSource.LOGS,
sourcePage: hasOptions ? source : SavedviewtypesSourceDTO.logs,
});
const { isLoading: isCheckingRead, deniedPermissions } = useAuthZ(
@@ -63,7 +62,7 @@ export function useActiveSavedView(
view,
stagedQuery,
panelType: panelType ?? PANEL_TYPES.LIST,
options: optionsDataSource ? options : undefined,
options: hasOptions ? options : undefined,
});
}, [
view,
@@ -71,7 +70,7 @@ export function useActiveSavedView(
isStagedQueryCurrent,
stagedQuery,
panelType,
optionsDataSource,
hasOptions,
options,
]);

View File

@@ -20,13 +20,12 @@ import { useGetSavedViewParams } from 'hooks/saveViews/useGetSavedViewParams';
import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { useAppLocation } from 'lib/router/useAppLocation';
import { usePreferenceContext } from 'providers/preferences/context/PreferenceContextProvider';
import { DataSource } from 'types/common/queryBuilder';
import { usePreferenceSlice } from 'providers/preferences/context/usePreferenceSlice';
import { isPreferenceSourcePage } from 'providers/preferences/utils';
import { toAPIError } from 'utils/errorUtils';
import { getUnstableCurrentSearchParams } from 'utils/getUnstableCurrentSearchParams';
import {
SAVED_VIEW_OPTIONS_DATA_SOURCE,
SAVED_VIEW_SCHEMA_VERSION,
SAVED_VIEW_TOAST_POSITION,
SAVED_VIEW_URL_PARAMS,
@@ -60,15 +59,11 @@ export function useSavedViewActions(
const { viewKey } = useGetSavedViewParams();
const logSavedViewEvent = useSavedViewLogEvent(source);
const optionsDataSource = SAVED_VIEW_OPTIONS_DATA_SOURCE[source];
const hasOptions = isPreferenceSourcePage(source);
const { options } = useOptionsMenu({
dataSource: optionsDataSource ?? DataSource.LOGS,
sourcePage: hasOptions ? source : SavedviewtypesSourceDTO.logs,
});
const preferences = usePreferenceContext();
const preferencesSlice =
optionsDataSource === DataSource.TRACES
? preferences.traces
: preferences.logs;
const preferenceSlice = usePreferenceSlice(source);
const { mutateAsync: createSavedView, isLoading: isCreating } =
useCreateSavedView();
@@ -86,10 +81,10 @@ export function useSavedViewActions(
query: stagedQuery ?? currentQuery,
panelType: panelType ?? PANEL_TYPES.LIST,
displayName,
options: optionsDataSource ? options : undefined,
options: hasOptions ? options : undefined,
color,
}),
[stagedQuery, currentQuery, panelType, optionsDataSource, options],
[stagedQuery, currentQuery, panelType, hasOptions, options],
);
const selectView = useCallback(
@@ -105,14 +100,14 @@ export function useSavedViewActions(
const revertView = useCallback(
(view: SavedviewtypesSavedViewDTO): void => {
if (optionsDataSource) {
if (preferenceSlice) {
const { columns, formatting } = getViewColumnsAndFormatting(
view.spec,
optionsDataSource,
source,
);
preferencesSlice.updateColumns(columns);
preferenceSlice.updateColumns(columns);
if (formatting) {
preferencesSlice.updateFormatting(formatting);
preferenceSlice.updateFormatting(formatting);
}
}
selectView(view);
@@ -120,7 +115,7 @@ export function useSavedViewActions(
[SavedViewEventKeys.ViewId]: view.id,
});
},
[optionsDataSource, preferencesSlice, selectView, logSavedViewEvent],
[source, preferenceSlice, selectView, logSavedViewEvent],
);
// The query builder redirects only ever add params, so the url is rebuilt

View File

@@ -1,4 +1,7 @@
import { SavedviewtypesSavedViewSpecDTO } from 'api/generated/services/sigNoz.schemas';
import {
SavedviewtypesSavedViewSpecDTO,
SavedviewtypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { TelemetryFieldKey } from 'api/v5/v5';
import {
defaultLogsSelectedColumns,
@@ -7,7 +10,6 @@ import {
} from 'container/OptionsMenu/constants';
import { FontSize, LogViewMode } from 'container/OptionsMenu/types';
import { Preferences } from 'providers/preferences/types';
import { DataSource } from 'types/common/queryBuilder';
function withColumnNames(
columns: TelemetryFieldKey[] | undefined,
@@ -25,11 +27,11 @@ function withColumnNames(
// defaults filled in where the view has none.
export function getViewColumnsAndFormatting(
spec: SavedviewtypesSavedViewSpecDTO | undefined,
dataSource: DataSource,
sourcePage: SavedviewtypesSourceDTO,
): Preferences {
const selectedFields = spec?.selectedFields as TelemetryFieldKey[] | undefined;
if (dataSource === DataSource.LOGS) {
if (sourcePage === SavedviewtypesSourceDTO.logs) {
return {
columns: ensureLogsRequiredColumns(
withColumnNames(selectedFields) || defaultLogsSelectedColumns,
@@ -42,7 +44,7 @@ export function getViewColumnsAndFormatting(
},
};
}
if (dataSource === DataSource.TRACES) {
if (sourcePage === SavedviewtypesSourceDTO.traces) {
return { columns: selectedFields || defaultTraceSelectedColumns };
}
return { columns: [] };

View File

@@ -7,6 +7,7 @@ import { HasUnsavedViewChangesArgs } from '../types';
import { getSavedViewQuery } from './getSavedViewQuery';
import { toSavedViewPanelType } from './toSavedViewSpec';
import { getViewColumnsAndFormatting } from './getViewColumnsAndFormatting';
import { toSavedViewSource } from './toSavedViewSource';
// Legacy context names the server stores under their v5 name.
const FIELD_CONTEXT_ALIASES: Record<string, TelemetrytypesFieldContextDTO> = {
@@ -79,7 +80,7 @@ export function hasUnsavedViewChanges({
const dataSource = stagedQuery.builder.queryData[0]?.dataSource;
const { columns, formatting } = getViewColumnsAndFormatting(
view.spec,
dataSource,
toSavedViewSource(dataSource),
);
const toNames = (fields: { name?: string; key?: string }[]): unknown[] =>
fields.map((field) => field.name ?? field.key);

View File

@@ -3,11 +3,9 @@ import { DataSource } from 'types/common/queryBuilder';
import { SavedViewSourcePage } from '../types';
// Explorers and the preferences module are keyed by DataSource (the signal),
// the api keys views by source page. Same values today, so this is the one
// place they meet. AI observability views will come with their own source and
// DataSource cannot tell them apart from traces, so preferences should move to
// source page at that point and this map goes with it.
// The views API files each view under a source page, but callers only know the
// signal they query: a builder query's dataSource, or the AI assistant's hint.
// TODO: remove in a follow-up once callers pass the source page directly.
const SAVED_VIEW_SOURCE: Record<SavedViewSourcePage, SavedviewtypesSourceDTO> =
{
[DataSource.LOGS]: SavedviewtypesSourceDTO.logs,

View File

@@ -1,9 +1,15 @@
import { ENVIRONMENT } from 'constants/env';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import {
initialQueriesMap,
initialQueryBuilderFormTraceOperatorValues,
PANEL_TYPES,
} from 'constants/queryBuilder';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { VirtuosoMockContext } from 'react-virtuoso';
import { render, screen } from 'tests/test-utils';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
import ListView from './index';
@@ -24,7 +30,7 @@ jest.mock('react-redux', () => {
});
// List columns come from the options menu (server-synced preferences). Pin them
// so the query fires and the expected columns render, independent of that API.
// so the expected columns render, independent of that API.
jest.mock('container/OptionsMenu/useOptionsMenu', () => ({
__esModule: true,
default: (): unknown => ({
@@ -35,6 +41,8 @@ jest.mock('container/OptionsMenu/useOptionsMenu', () => ({
{ name: 'duration_nano', fieldContext: 'span' },
{ name: 'http_method', fieldContext: 'span' },
{ name: 'response_status_code', fieldContext: 'span' },
{ name: 'http.route', fieldContext: 'attribute' },
{ name: 'cache.hit', fieldContext: 'attribute' },
],
},
config: { addColumn: { onRemove: jest.fn() } },
@@ -48,19 +56,19 @@ const listRows = [
{
timestamp: '2024-07-19T08:39:58.735245Z',
data: {
'service.name': 'frontend',
name: 'HTTP GET',
duration_nano: 55306000,
http_method: 'GET',
response_status_code: '200',
span_id: '772c4d29dd9076ac',
trace_id: '0000000000000000344ded1387b08a7e',
attributes: { 'http.route': '/api/cart', 'cache.hit': false },
resource: { 'service.name': 'frontend' },
},
},
{
timestamp: '2024-07-19T08:39:59.949129915Z',
data: {
'service.name': 'demo-app',
name: 'authenticate_check_db',
duration_nano: 790949390,
// empty status fields to assert the "-" cell
@@ -68,6 +76,8 @@ const listRows = [
response_status_code: '',
span_id: '5704353737b6778e',
trace_id: 'a364a8e15af3e9a8c866e0528db8b637',
attributes: {},
resource: { 'service.name': 'demo-app' },
},
},
];
@@ -84,7 +94,9 @@ const mockSuccess = (rows: unknown[] = listRows): void => {
);
};
const renderListView = (): ReturnType<typeof render> =>
const renderListView = (
stagedQuery: Query = initialQueriesMap.traces,
): ReturnType<typeof render> =>
render(
<VirtuosoMockContext.Provider value={{ viewportHeight: 500, itemHeight: 54 }}>
<ListView
@@ -98,8 +110,8 @@ const renderListView = (): ReturnType<typeof render> =>
initialRoute: '/traces-explorer',
queryBuilderOverrides: {
panelType: PANEL_TYPES.LIST,
stagedQuery: initialQueriesMap.traces,
currentQuery: initialQueriesMap.traces,
stagedQuery,
currentQuery: stagedQuery,
redirectWithQueryBuilderData: jest.fn(),
} as any,
},
@@ -118,6 +130,10 @@ describe('Traces ListView - Data Loaded', () => {
await expect(screen.findByText('frontend')).resolves.toBeInTheDocument();
expect(screen.getByText('authenticate_check_db')).toBeInTheDocument();
// attribute column reads from the attributes map
expect(screen.getByText('/api/cart')).toBeInTheDocument();
expect(screen.getByText('false')).toBeInTheDocument();
// duration_nano renders in milliseconds
expect(screen.getAllByTestId('duration_nano')[0]).toHaveTextContent(/ms$/);
@@ -130,4 +146,37 @@ describe('Traces ListView - Data Loaded', () => {
// empty status fields render "-"
expect(screen.getAllByText('-').length).toBeGreaterThanOrEqual(1);
});
it('requests every span field instead of the selected columns', async () => {
let requestBody: QueryRangeRequestV5 | undefined;
server.use(
rest.post(QUERY_RANGE_URL, async (req, res, ctx) => {
requestBody = await req.json<QueryRangeRequestV5>();
return res(ctx.status(200), ctx.json(listResponse(listRows)));
}),
);
const selectColumns = [{ name: 'http.route', fieldContext: 'attribute' }];
const { builder } = initialQueriesMap.traces;
renderListView({
...initialQueriesMap.traces,
builder: {
...builder,
queryData: [{ ...builder.queryData[0], selectColumns }],
queryTraceOperator: [
{
...initialQueryBuilderFormTraceOperatorValues,
expression: 'A',
selectColumns,
},
],
},
} as Query);
await expect(screen.findByText('frontend')).resolves.toBeInTheDocument();
const queries = requestBody?.compositeQuery.queries ?? [];
expect(queries).toHaveLength(2);
queries.forEach(({ spec }) => {
expect(spec).not.toHaveProperty('selectFields');
});
});
});

View File

@@ -11,6 +11,7 @@ import {
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import logEvent from 'api/common/logEvent';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
import type { TableColumnDef } from 'components/TanStackTableView/types';
@@ -86,7 +87,7 @@ function ListView({
const { options, config } = useOptionsMenu({
storageKey: LOCALSTORAGE.TRACES_LIST_OPTIONS,
dataSource: DataSource.TRACES,
sourcePage: SavedviewtypesSourceDTO.traces,
aggregateOperator: 'count',
initialOptions: {
selectColumns: defaultSelectedColumns,
@@ -99,23 +100,20 @@ function ListView({
const paginationConfig =
paginationQueryData ?? getDefaultPaginationConfig(PER_PAGE_OPTIONS);
const requestQuery = useMemo(
() => getListViewQuery(stagedQuery || initialQueriesMap.traces, orderBy),
[stagedQuery, orderBy],
);
// Stable sorted-name signature for the queryKey.
// - Drag updates selectColumns; raw queryKey would churn on reorder.
// - Trace API fetches only listed columns → add/remove must refetch.
// - Sorted-name signature: stable on reorder, changes on add/remove.
const selectColumnsSignature = useMemo(
() =>
(options?.selectColumns ?? [])
.map((c) => c.name)
.sort()
.join(','),
[options?.selectColumns],
);
// Empty selectFields makes the API return every span field.
const requestQuery = useMemo(() => {
const query = getListViewQuery(
stagedQuery || initialQueriesMap.traces,
orderBy,
);
query.builder.queryData.forEach((queryData) => {
queryData.selectColumns = [];
});
query.builder.queryTraceOperator?.forEach((queryTraceOperator) => {
queryTraceOperator.selectColumns = [];
});
return query;
}, [stagedQuery, orderBy]);
const queryKey = useMemo(
() => [
@@ -126,14 +124,12 @@ function ListView({
stagedQuery,
panelType,
paginationConfig,
selectColumnsSignature,
],
[
stagedQuery,
panelType,
globalSelectedTime,
paginationConfig,
selectColumnsSignature,
maxTime,
minTime,
],
@@ -154,7 +150,6 @@ function ListView({
},
tableParams: {
pagination: paginationConfig,
selectColumns: options?.selectColumns,
},
},
// ENTITY_VERSION_V4,
@@ -163,10 +158,7 @@ function ListView({
queryKey,
enabled:
// don't make api call while the time range state in redux is loading
!timeRangeUpdateLoading &&
!!stagedQuery &&
panelType === PANEL_TYPES.LIST &&
!!options?.selectColumns?.length,
!timeRangeUpdateLoading && !!stagedQuery && panelType === PANEL_TYPES.LIST,
},
);

View File

@@ -1,6 +1,7 @@
import { TelemetryFieldKey } from 'api/v5/v5';
import type { TableColumnDef } from 'components/TanStackTableView/types';
import { buildCompositeKey } from 'container/OptionsMenu/utils';
import { getSpanAttribute } from 'pages/TraceDetailsV3/utils';
import { TIMESTAMP_FIELD_NAMES } from './constants';
import FieldCell from './FieldCell';
@@ -16,7 +17,10 @@ export function getFieldColumn(
return {
id: buildCompositeKey(name, fieldContext, fieldDataType),
header: name,
accessorFn: (row): unknown => row[name],
// Span rows nest attribute/resource fields in maps; trace rows are flat.
accessorFn: (row): unknown =>
row[name] ??
getSpanAttribute(row as Parameters<typeof getSpanAttribute>[0], name),
enableMove: !isTimestamp,
enableRemove: !isTimestamp,
canBeHidden: !isTimestamp,

View File

@@ -1,10 +0,0 @@
import { FeatureKeys } from 'constants/features';
import { useAppContext } from 'providers/App/App';
export function useIsBodyJsonEnabled(): boolean {
const { featureFlags } = useAppContext();
return (
featureFlags?.find((flag) => flag.name === FeatureKeys.USE_JSON_BODY)
?.active ?? false
);
}

View File

@@ -25,7 +25,7 @@ function getBodyFieldValue(body: ILogBody, key: string): unknown {
}, body);
}
// Resolve one field for a log list format. A JSON body is checked first (use_json_body
// Resolve one field for the logs table. A JSON body is checked first (use_json_body
// only), splitting the key on `.`; otherwise fall back to FlatLogData
// (attributes/resources/scope/top-level).
export function getLogFieldValue(

View File

@@ -1,72 +0,0 @@
import { getBodyDisplay, getBodyMessage } from './logBody';
describe('getBodyDisplay', () => {
it('returns a string body as is', () => {
expect(getBodyDisplay('plain text')).toBe('plain text');
});
it('returns the message when it is the only key', () => {
expect(getBodyDisplay({ message: 'only a message' })).toBe('only a message');
});
it('stringifies a non-string message when it is the only key', () => {
expect(getBodyDisplay({ message: { event: 'login' } as never })).toBe(
'{"event":"login"}',
);
});
it('stringifies an object with more than the message key', () => {
expect(getBodyDisplay({ message: 'with fields', level: 'warn' })).toBe(
'{"message":"with fields","level":"warn"}',
);
});
it('stringifies an object without a message key', () => {
expect(getBodyDisplay({ event: 'cache_evict' })).toBe(
'{"event":"cache_evict"}',
);
});
it('returns an empty string for a missing body', () => {
expect(getBodyDisplay(undefined)).toBe('');
expect(getBodyDisplay(null)).toBe('');
});
it('stringifies a body that is neither a string nor an object', () => {
expect(getBodyDisplay(42)).toBe('42');
expect(getBodyDisplay(['a', 1])).toBe('["a",1]');
});
});
describe('getBodyMessage', () => {
it('returns a string body as is', () => {
expect(getBodyMessage('plain text')).toBe('plain text');
});
it('prefers the message over the other keys', () => {
expect(getBodyMessage({ message: 'with fields', level: 'warn' })).toBe(
'with fields',
);
});
it('stringifies the object when the message is missing or empty', () => {
expect(getBodyMessage({ event: 'cache_evict' })).toBe(
'{"event":"cache_evict"}',
);
expect(getBodyMessage({ message: '', level: 'warn' })).toBe(
'{"message":"","level":"warn"}',
);
});
it('collapses a message-only body the same way as getBodyDisplay', () => {
expect(getBodyMessage({ message: { event: 'login' } as never })).toBe(
'{"event":"login"}',
);
expect(getBodyMessage({ message: '' })).toBe('');
});
it('returns an empty string for a missing body', () => {
expect(getBodyMessage(undefined)).toBe('');
expect(getBodyMessage(null)).toBe('');
});
});

View File

@@ -1,33 +0,0 @@
import { ILogBody } from 'types/api/logs/log';
import { coerceToString } from 'utils/stringUtils';
function isBodyObject(body: unknown): body is ILogBody {
return typeof body === 'object' && body !== null && !Array.isArray(body);
}
export function extractOnlyMessageBody(body: unknown): unknown {
if (!isBodyObject(body)) {
return body;
}
const keys = Object.keys(body);
if (keys.length === 1 && keys[0] === 'message') {
const { message } = body;
return typeof message === 'string' ? message : JSON.stringify(message);
}
return body;
}
export function getBodyDisplay(body: unknown): string {
return coerceToString(extractOnlyMessageBody(body));
}
export function getBodyMessage(body: unknown): string {
const collapsed = extractOnlyMessageBody(body);
if (!isBodyObject(collapsed)) {
return coerceToString(collapsed);
}
if (typeof collapsed.message === 'string' && collapsed.message !== '') {
return collapsed.message;
}
return JSON.stringify(collapsed);
}

View File

@@ -5,7 +5,7 @@ import { DrawerWrapper } from '@signozhq/ui/drawer';
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import type { EQueryType } from 'types/common/dashboard';
import PanelTypeBrowser from '../../../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/PanelTypeBrowser';
import PanelTypeBrowser from '../../../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/components/PanelTypeBrowser/PanelTypeBrowser';
import { getPanelDefinition } from '../../../Panels/registry';
import type { PanelKind } from '../../../Panels/types/panelKind';
import { JS_DRIVEN_SLIDE } from '../../../utils/drawerSlide';

View File

@@ -9,7 +9,6 @@ import type {
FormatTimezoneAdjustedTimestamp,
TimestampInput,
} from 'hooks/useTimezoneFormatter/useTimezoneFormatter';
import { getBodyDisplay } from 'lib/logs/logBody';
import LineClampedText from 'periscope/components/LineClampedText/LineClampedText';
import { coerceToString } from 'utils/stringUtils';
@@ -37,10 +36,6 @@ function renderCell(value: unknown): ReactNode {
return <Typography.Text>{coerceToString(value)}</Typography.Text>;
}
function renderBodyCell(value: unknown): ReactNode {
return <Typography.Text>{getBodyDisplay(value)}</Typography.Text>;
}
/**
* Scale an epoch integer to milliseconds (what dayjs expects) by its magnitude:
* logs/traces carry nanoseconds, but be tolerant of µs/ms/s too.
@@ -183,7 +178,7 @@ export function buildListColumns({
dataIndex: name,
key: name,
ellipsis: true,
render: renderBodyCell,
render: renderCell,
};
}
return {

View File

@@ -7,11 +7,11 @@ import { releasePanelPickerTarget } from '../../../store/usePanelPickerTargetSto
import type { NewPanelTarget } from '../../../patchOps';
import type { PanelKind } from '../../../Panels/types/panelKind';
import { JS_DRIVEN_SLIDE } from '../../../utils/drawerSlide';
import AddPanelSplitButton from './AddPanelSplitButton';
import NewSectionNameInput from './NewSectionNameInput';
import PanelTypeBrowser from './PanelTypeBrowser';
import { usePanelPickerDraftSection } from './usePanelPickerDraftSection';
import { usePanelPickerTarget } from './usePanelPickerTarget';
import AddPanelSplitButton from './components/AddPanelSplitButton/AddPanelSplitButton';
import NewSectionNameInput from './components/NewSectionNameInput/NewSectionNameInput';
import PanelTypeBrowser from './components/PanelTypeBrowser/PanelTypeBrowser';
import { usePanelPickerDraftSection } from './hooks/usePanelPickerDraftSection';
import { usePanelPickerTarget } from './hooks/usePanelPickerTarget';
import { buildSectionOptions, resolveDefaultSectionValue } from './utils';
import styles from './PanelTypeSelectionModal.module.scss';

View File

@@ -3,7 +3,7 @@ import { ChevronUp, Plus } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Dropdown, type DropdownItemType } from '@signozhq/ui/dropdown';
import type { SectionOption } from './types';
import type { SectionOption } from '../../types';
import styles from './AddPanelSplitButton.module.scss';

View File

@@ -88,76 +88,6 @@
gap: 12px;
}
.tile {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px;
border: 1px solid var(--l1-border);
border-radius: 6px;
background: var(--l2-background);
font: inherit;
text-align: left;
cursor: pointer;
transition:
background-color 200ms cubic-bezier(0.08, 0.52, 0.52, 1),
border-color 200ms cubic-bezier(0.08, 0.52, 0.52, 1);
&:hover {
background: var(--l3-background);
}
}
.tileSelected {
border-color: var(--bg-robin-500);
box-shadow: inset 0 0 0 1px var(--bg-robin-500);
background: var(--l3-background);
}
.tileDisabled {
opacity: 0.45;
cursor: not-allowed;
&:hover {
background: var(--l2-background);
}
}
.tileText {
display: flex;
flex-direction: column;
gap: 3px;
}
.tileTitle {
display: flex;
align-items: center;
gap: 7px;
}
.tileName {
color: var(--l1-foreground);
font-size: 13.5px;
font-weight: 500;
}
.newBadge {
padding: 2px 6px;
border: 1px solid var(--bg-sienna-400);
border-radius: 3px;
color: var(--bg-sienna-400);
font-size: 9.5px;
font-weight: 500;
letter-spacing: 0.6px;
text-transform: uppercase;
}
.tileDescription {
color: var(--l2-foreground);
font-size: 12px;
line-height: 1.45;
}
.empty {
display: flex;
flex-direction: column;

View File

@@ -4,12 +4,12 @@ import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import cx from 'classnames';
import type { PanelKind } from '../../../Panels/types/panelKind';
import type { PanelKind } from '../../../../../Panels/types/panelKind';
import {
filterPanelTypeGroups,
type PanelTypeGroupId,
} from './panelTypeCatalog';
import PanelTypeTile from './PanelTypeTile';
import PanelTypeTile from '../PanelTypeTile/PanelTypeTile';
import styles from './PanelTypeBrowser.module.scss';

View File

@@ -1,5 +1,8 @@
import { PANEL_OPTIONS, type PanelOption } from '../../../Panels/registry';
import type { PanelKind } from '../../../Panels/types/panelKind';
import {
PANEL_OPTIONS,
type PanelOption,
} from '../../../../../Panels/registry';
import type { PanelKind } from '../../../../../Panels/types/panelKind';
export type PanelTypeGroupId =
| 'trends'

View File

@@ -1,4 +1,4 @@
import type { PanelKind } from '../../../Panels/types/panelKind';
import type { PanelKind } from '../../../../../Panels/types/panelKind';
import { PANEL_TYPE_PREVIEWS } from './panelTypePreviews';
import styles from './PanelTypePreview.module.scss';

View File

@@ -1,4 +1,4 @@
import type { PanelKind } from '../../../Panels/types/panelKind';
import type { PanelKind } from '../../../../../Panels/types/panelKind';
import PreviewBars from './PreviewBars';
import PreviewRows, { type PreviewCell } from './PreviewRows';

View File

@@ -0,0 +1,69 @@
.tile {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px;
border: 1px solid var(--l1-border);
border-radius: 6px;
background: var(--l2-background);
font: inherit;
text-align: left;
cursor: pointer;
transition:
background-color 200ms cubic-bezier(0.08, 0.52, 0.52, 1),
border-color 200ms cubic-bezier(0.08, 0.52, 0.52, 1);
&:hover {
background: var(--l3-background);
}
}
.tileSelected {
border-color: var(--bg-robin-500);
box-shadow: inset 0 0 0 1px var(--bg-robin-500);
background: var(--l3-background);
}
.tileDisabled {
opacity: 0.45;
cursor: not-allowed;
&:hover {
background: var(--l2-background);
}
}
.tileText {
display: flex;
flex-direction: column;
gap: 3px;
}
.tileTitle {
display: flex;
align-items: center;
gap: 7px;
}
.tileName {
color: var(--l1-foreground);
font-size: 13.5px;
font-weight: 500;
}
.newBadge {
padding: 2px 6px;
border: 1px solid var(--bg-sienna-400);
border-radius: 3px;
color: var(--bg-sienna-400);
font-size: 9.5px;
font-weight: 500;
letter-spacing: 0.6px;
text-transform: uppercase;
}
.tileDescription {
color: var(--l2-foreground);
font-size: 12px;
line-height: 1.45;
}

View File

@@ -1,10 +1,10 @@
import { Tooltip } from '@signozhq/ui/tooltip';
import cx from 'classnames';
import type { PanelTypeItem } from './panelTypeCatalog';
import PanelTypePreview from './PanelTypePreview';
import type { PanelTypeItem } from '../PanelTypeBrowser/panelTypeCatalog';
import PanelTypePreview from '../PanelTypePreview/PanelTypePreview';
import styles from './PanelTypeBrowser.module.scss';
import styles from './PanelTypeTile.module.scss';
interface PanelTypeTileProps {
item: PanelTypeItem;

View File

@@ -1,7 +1,7 @@
import { useEffect } from 'react';
import type { PanelKind } from '../../../Panels/types/panelKind';
import { usePanelPickerTargetStore } from '../../../store/usePanelPickerTargetStore';
import type { PanelKind } from '../../../../Panels/types/panelKind';
import { usePanelPickerTargetStore } from '../../../../store/usePanelPickerTargetStore';
export function usePanelPickerDraftSection(
title: string | null,

View File

@@ -1,7 +1,7 @@
import { useEffect } from 'react';
import type { PanelKind } from '../../../Panels/types/panelKind';
import { usePanelPickerTargetStore } from '../../../store/usePanelPickerTargetStore';
import type { PanelKind } from '../../../../Panels/types/panelKind';
import { usePanelPickerTargetStore } from '../../../../store/usePanelPickerTargetStore';
interface UsePanelPickerTargetArgs {
open: boolean;

View File

@@ -213,12 +213,6 @@ export const panelsMocks = defineStoryMocks({
value: 20,
max: 80,
}),
listJsonBody: toggleControl('List JSON body', {
group: DATA,
description:
'`use_json_body`: the List panel’s log bodies arrive as objects, every third one with keys beside `message`.',
value: false,
}),
},
handlers: (values, response) => {
const args: PanelArgs = values;
@@ -265,7 +259,6 @@ export const panelsMocks = defineStoryMocks({
result: values.result,
series: values.series,
listRows: values.listRows,
listJsonBody: values.listJsonBody,
gaps: values.gaps,
});
}),

View File

@@ -1,6 +1,5 @@
import type { ComponentType } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { expect, within } from 'storybook/test';
import {
DashboardtypesHeatmapYScaleDTO as HeatmapYScale,
DashboardtypesLegendPositionDTO as LegendPosition,
@@ -167,24 +166,6 @@ export const List: Story = {
play,
};
/**
* The List panel on a `use_json_body` tenant: a body holding only `message`
* reads as its text, one with keys beside it as JSON.
*/
export const ListJsonBody: Story = {
args: { kind: 'List', listJsonBody: true },
play: async ({ canvasElement, args }): Promise<void> => {
await expectPanel(canvasElement, args);
const [first, , third] = within(canvasElement)
.getByTestId('list-panel-renderer')
.querySelectorAll<HTMLElement>('tbody tr.ant-table-row');
await expect(within(first).getByText(/^log line 1:/)).toBeInTheDocument();
await expect(
within(third).getByText(/^\{"message":"log line 3:/),
).toBeInTheDocument();
},
};
/**
* Logs spanning three pages, 25 a request: Next walks to the second and then
* the last, shorter page, where Next turns off.

View File

@@ -144,31 +144,18 @@ const heatmapResponse = (
});
};
/**
* On a `use_json_body` tenant every body is an object: a plain line arrives as
* `{ message }` alone, a structured one with its own keys beside it.
*/
const logBodyAt = (index: number, line: string): Record<string, unknown> =>
index % 3 === 2
? { message: line, service: serviceAt(index % MAX_SERIES), attempt: 1 }
: { message: line };
/** Spaced by the window, newest first, as the `timestamp desc` order asks for. */
const logRows = (
{ start, end }: PanelWindow,
total: number,
offset: number,
limit: number,
jsonBody: boolean,
): RawRow[] =>
Array.from(
{ length: Math.max(0, Math.min(limit, total - offset)) },
(_unused, position) => {
const index = offset + position;
const service = serviceAt(index % MAX_SERIES);
const line = `log line ${index + 1}: ${service} served the request in ${
8 + (index % 9) * 3
}ms`;
return {
timestamp: new Date(
@@ -176,7 +163,9 @@ const logRows = (
).toISOString(),
data: {
id: `storybook-log-${index + 1}`,
body: jsonBody ? logBodyAt(index, line) : line,
body: `log line ${index + 1}: ${service} served the request in ${
8 + (index % 9) * 3
}ms`,
severity_text: index % 5 === 0 ? 'ERROR' : 'INFO',
resources_string: { 'service.name': service },
},
@@ -190,7 +179,6 @@ export const panelQueryResponse = ({
result,
series,
listRows,
listJsonBody,
gaps,
window,
offset,
@@ -229,12 +217,9 @@ export const panelQueryResponse = ({
case 'Number':
return queryRangeV5ScalarResponse(NUMBER_VALUE, QUERY_NAME, { warning });
case 'List':
return queryRangeV5RawResponse(
logRows(window, listRows, offset, limit, listJsonBody),
{
warning,
},
);
return queryRangeV5RawResponse(logRows(window, listRows, offset, limit), {
warning,
});
default:
return latencySeries(window, series, gaps, warning);
}

View File

@@ -77,6 +77,5 @@ export interface PanelResponseArgs extends PanelRequest {
result: PanelResult;
series: number;
listRows: number;
listJsonBody: boolean;
gaps: Gaps;
}

View File

@@ -311,7 +311,6 @@ export type InfraControls = {
drawer: MockControl<boolean>;
drawerTab: MockControl<VIEWS>;
telemetryRows: MockControl<number>;
eventJsonBody: MockControl<boolean>;
};
const infraControls = (entity: InfraEntity): InfraControls => ({
@@ -362,12 +361,6 @@ const infraControls = (entity: InfraEntity): InfraControls => ({
value: 12,
max: 20,
}),
eventJsonBody: toggleControl('Event JSON body', {
group: DRAWER,
description:
'`use_json_body`: the events tab’s bodies arrive as objects, a warning with its reason and object beside `message`, an info line with `message` alone.',
value: false,
}),
});
/**
@@ -442,12 +435,7 @@ export const infraStoryMocks = (
}
return isEventsQuery(body)
? entityEventRows(
start,
end,
values.telemetryRows,
values.eventJsonBody,
)
? entityEventRows(start, end, values.telemetryRows)
: entityLogRows(start, end, values.telemetryRows);
})();

View File

@@ -46,14 +46,6 @@ export const PodDetailsEvents: StoryObj<PodsArgs> = {
args: { drawer: true, drawerTab: VIEWS.EVENTS },
};
/**
* The events tab on a `use_json_body` tenant: a warning's event object as JSON
* in the Body column, an info line's `message`-only body as its text.
*/
export const PodDetailsEventsJsonBody: StoryObj<PodsArgs> = {
args: { drawer: true, drawerTab: VIEWS.EVENTS, eventJsonBody: true },
};
/** The selected pod's details drawer, switched to its logs tab. */
export const DetailsDrawerLogsTab: StoryObj<PodsArgs> = {
args: { drawer: true },

View File

@@ -1068,28 +1068,10 @@ const K8S_EVENT_BODIES = [
{ severity: 'INFO', body: 'Pulled image "signoz/checkout:1.4.2" in 812ms' },
];
/**
* What the k8sobjects receiver sends on a `use_json_body` tenant: the event
* object for a warning, and a `message`-only object for a plain info line.
*/
const eventJsonBody = (
event: (typeof K8S_EVENT_BODIES)[number],
index: number,
): Record<string, unknown> =>
event.severity === 'WARN'
? {
message: event.body,
type: 'Warning',
reason: 'BackOff',
involvedObject: { kind: 'Pod', name: pick(WORKLOADS, index) },
}
: { message: event.body };
export const entityEventRows = (
start: number,
end: number,
count: number,
jsonBody = false,
): RawRow[] =>
Array.from({ length: count }, (_unused, index) => {
const event = pick(K8S_EVENT_BODIES, index);
@@ -1100,7 +1082,7 @@ export const entityEventRows = (
).toISOString(),
data: {
id: `event-${index + 1}`,
body: jsonBody ? eventJsonBody(event, index) : event.body,
body: event.body,
severity_text: event.severity,
severity_number: event.severity === 'WARN' ? 13 : 9,
attributes_string: {

View File

@@ -4,14 +4,12 @@
*/
import setLocalStorage from 'api/browser/localstorage/set';
import { FeatureKeys } from 'constants/features';
import { LOCALSTORAGE } from 'constants/localStorage';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { FontSize, type LogViewMode } from 'container/OptionsMenu/types';
import { rest } from 'msw';
import { defaultFeatureFlags } from 'tests/fixtures/appContextMock';
import type {
BaseBuilderQuery,
QueryRangeRequestV5,
@@ -21,7 +19,6 @@ import {
choiceControl,
countControl,
multiChoiceControl,
toggleControl,
} from '@/storybook/controls/controls';
import { defineStoryMocks } from '@/storybook/controls/defineStoryMocks';
@@ -97,12 +94,6 @@ export const liveLogsMocks = defineStoryMocks({
options: LIST_FORMATS,
value: 'table',
}),
jsonBody: toggleControl('JSON body', {
group: LIST,
description:
'`use_json_body`: the streamed lines carry the body as an object, every fourth one holding only `message`, which the list shows as its text.',
value: false,
}),
},
handlers: (values, response) => [
// The frequency chart above the list stays empty here: it only queries
@@ -126,14 +117,7 @@ export const liveLogsMocks = defineStoryMocks({
// `Authorization` header, so without these the browser refuses the
// response the same way it would refuse the backend's.
ctx.set('Access-Control-Allow-Origin', '*'),
ctx.body(
liveLogsStream(
values.liveLogs,
values.severities,
Date.now(),
values.jsonBody,
),
),
ctx.body(liveLogsStream(values.liveLogs, values.severities, Date.now())),
),
),
@@ -154,9 +138,7 @@ export const liveLogsMocks = defineStoryMocks({
const timeWindow = { start: body.start, end: body.end };
if (body.requestType === 'raw') {
return logRowsResponse(values.liveLogs, values.severities, body.end, {
jsonBody: values.jsonBody,
});
return logRowsResponse(values.liveLogs, values.severities, body.end);
}
const spec = body.compositeQuery?.queries?.[0]?.spec as
@@ -214,23 +196,9 @@ export const liveLogsMocks = defineStoryMocks({
response.json(dashboardsResponse),
),
],
config: ({ jsonBody }) => ({
config: () => ({
route: liveRoute(),
reduxState: timeRangeState(),
appContext: jsonBody
? {
featureFlags: [
...defaultFeatureFlags,
{
name: FeatureKeys.USE_JSON_BODY,
active: true,
usage: 0,
usage_limit: -1,
route: '',
},
],
}
: {},
}),
effect: ({ format }) => {
setLocalStorage(LOCALSTORAGE.SHOW_LOGS_QUICK_FILTERS, 'true');

View File

@@ -85,14 +85,6 @@ export const Waiting: Story = {
args: { liveLogs: 0 },
};
/**
* A JSON-logging service tailed live: object bodies as JSON, and a body holding
* only `message` as its text, the same as the explorer shows it.
*/
export const JsonBody: Story = {
args: { jsonBody: true },
};
/** Row actions render on hover, so a streamed line is hovered before its copy link is pressed. */
export const LogLinkCopiedToast: Story = {
play: async ({ mount, canvasElement }): Promise<void> => {

View File

@@ -68,9 +68,8 @@ export const liveLogsStream = (
count: number,
severities: readonly LogSeverity[],
endMilli: number,
jsonBody = false,
): string =>
`retry: ${NO_RECONNECT_MS}\n\n${logRows(count, severities, endMilli, jsonBody)
`retry: ${NO_RECONNECT_MS}\n\n${logRows(count, severities, endMilli, false)
.map(
(row) =>
`data: ${JSON.stringify({

View File

@@ -153,12 +153,6 @@ export const logsMocks = defineStoryMocks({
'`use_json_body`: the service logs JSON and the backend hands the body over parsed, so the body is a tree and its keys are queryable as `body.x`.',
value: false,
}),
bodyColumns: toggleControl('Body field columns', {
group: LIST,
description:
'Keys of the JSON body (`order`, `order.coupons`, `http.status_code`) selected as columns beside body and timestamp. Only shows something with the JSON body on.',
value: false,
}),
filtersPanel: toggleControl('Filters panel', {
group: FILTERS,
description: 'The quick filters panel down the left of the explorer.',
@@ -297,7 +291,6 @@ export const logsMocks = defineStoryMocks({
format,
maxLines,
fontSize,
bodyColumns,
recentFilters,
}) => {
setLocalStorage(LOCALSTORAGE.SHOW_FREQUENCY_CHART, String(frequencyChart));
@@ -308,7 +301,7 @@ export const logsMocks = defineStoryMocks({
// what the options menu opens on.
setLocalStorage(
LOCALSTORAGE.LOGS_LIST_OPTIONS,
JSON.stringify(logsListOptions({ format, maxLines, fontSize, bodyColumns })),
JSON.stringify(logsListOptions({ format, maxLines, fontSize })),
);
},
});

View File

@@ -124,28 +124,6 @@ export const JsonBody: Story = {
args: { jsonBody: true },
};
/**
* Keys of the JSON body as columns in the Column format: the nested `order`
* object and the `order.coupons` array render as JSON, `http.status_code` as
* its number.
*/
export const JsonBodyFieldsColumn: Story = {
args: { jsonBody: true, bodyColumns: true },
};
/**
* The same body keys in the Default format: each selected field under the
* body line, object and array values as JSON with no add-to-query on them.
*/
export const JsonBodyFieldsDefault: Story = {
args: { jsonBody: true, bodyColumns: true, format: 'list' },
};
/** The same body keys joined into the raw line ahead of the body. */
export const JsonBodyFieldsRaw: Story = {
args: { jsonBody: true, bodyColumns: true, format: 'raw' },
};
/** One log opened up: its own fields, then the resource that sent it. */
export const LogDetails: Story = {
play: async ({ canvasElement }): Promise<void> => {

View File

@@ -27,11 +27,7 @@ import type { MetricRangePayloadV3 } from 'types/api/metrics/getQueryRange';
import type { IGetAttributeSuggestionsSuccessResponse } from 'types/api/queryBuilder/getAttributeSuggestions';
import type { IAttributeValuesResponse } from 'types/api/queryBuilder/getAttributesValues';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import type {
MetricRangePayloadV5,
TelemetryFieldKey,
TimeSeries,
} from 'types/api/v5/queryRange';
import type { MetricRangePayloadV5, TimeSeries } from 'types/api/v5/queryRange';
import type { ListItem } from 'types/api/widgets/getQuery';
import { dashboardsForUserResponse } from '@/storybook/msw/__story_mockdata__/dashboards';
@@ -142,19 +138,6 @@ const jsonBodyFor = (
http: { method: 'POST', route: '/api/v1/checkout', status_code: 200 },
});
/**
* Every fourth JSON-body log is what the collector makes of a plain line on a
* `use_json_body` tenant: an object holding only `message`.
*/
const jsonBodyAt = (
severity: LogSeverity,
service: string,
index: number,
): ILogBody =>
index % 4 === 3
? { message: MESSAGES[severity] }
: jsonBodyFor(severity, service, index);
export interface LogsResponseOptions {
/** `use_json_body`: the body arrives as an object instead of a line. */
jsonBody?: boolean;
@@ -186,7 +169,7 @@ export const logRows = (
data: {
id: `01JBQ${hex(index, 8).toUpperCase()}`,
body: jsonBody
? jsonBodyAt(severity, service, index)
? jsonBodyFor(severity, service, index)
: `${timestamp} ${severity} ${service} ${MESSAGES[severity]}`,
severity_text: severity,
severity_number: SEVERITY_NUMBERS[severity],
@@ -473,36 +456,14 @@ export const filterSuggestionsResponse = (
* this is what decides the format, and the columns have to be in it or the list
* loses `body` and `timestamp`.
*/
export const logsListOptions = ({
bodyColumns = false,
...options
}: Pick<OptionsQuery, 'format' | 'maxLines' | 'fontSize'> & {
bodyColumns?: boolean;
}): OptionsQuery => ({
selectColumns: bodyColumns
? [...defaultLogsSelectedColumns, ...BODY_FIELD_COLUMNS]
: defaultLogsSelectedColumns,
export const logsListOptions = (
options: Pick<OptionsQuery, 'format' | 'maxLines' | 'fontSize'>,
): OptionsQuery => ({
selectColumns: defaultLogsSelectedColumns,
version: 1,
...options,
});
/**
* Keys of the JSON body picked as columns: a nested object, an array and a
* leaf, which is what tells a list format apart on how it renders a non-string
* field value.
*/
const BODY_FIELD_COLUMNS: TelemetryFieldKey[] = [
'order',
'order.coupons',
'http.status_code',
].map((name) => ({
name,
signal: 'logs',
fieldContext: 'log',
fieldDataType: '',
isIndexed: false,
}));
const DASHBOARD_NAMES = [
'Logs overview',
'Checkout health',

View File

@@ -5,7 +5,7 @@
import ROUTES from 'constants/routes';
import { rest } from 'msw';
import { expect, userEvent, waitFor, within } from 'storybook/test';
import { expect, screen, userEvent, waitFor, within } from 'storybook/test';
import { RoleType } from 'types/roles';
import { choiceControl } from '@/storybook/controls/controls';
@@ -116,6 +116,21 @@ export const openLogsCard = async (
return header.parentElement as HTMLElement;
};
/** Opens the telemetry selector wizard from the Logs read verb and returns its dialog. */
export const openSelectorWizard = async (
canvasElement: HTMLElement,
): Promise<HTMLElement> => {
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' }),
);
return screen.findByRole('dialog', { name: 'Selector Wizard' });
};
/** Clicks save once the permission check behind the button has answered. */
export const save = async (canvasElement: HTMLElement): Promise<void> => {
const button = await waitFor(

View File

@@ -6,6 +6,7 @@ import type { PageStoryArgs } from '@/storybook/runtime/resolveStory';
import {
openLogsCard,
openSelectorWizard,
roleEditorMocks,
save,
} from './RoleEditor.stories.mocks';
@@ -93,14 +94,46 @@ export const PermissionEditorScope: Story = {
/** 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 openSelectorWizard(canvasElement);
},
};
await userEvent.click(onlySelected);
await userEvent.click(
await within(card).findByRole('button', { name: 'Wizard' }),
/** The Key dropdown open, showing the three supported telemetry grant keys. */
export const TelemetryKeyDropdown: Story = {
play: async ({ canvasElement }): Promise<void> => {
await openSelectorWizard(canvasElement);
await userEvent.click(screen.getByTestId('wizard-key-select-logs-read'));
await screen.findByTestId(
'wizard-key-option-signoz.workspace.key.id-logs-read',
);
await screen.findByRole('dialog', { name: 'Selector Wizard' });
await screen.findByTestId(
'wizard-key-option-resource.service.name-logs-read',
);
await screen.findByTestId(
'wizard-key-option-resource.deployment.environment.name-logs-read',
);
},
};
/** A service-name key picked with a value: the selector rebuilds to match. */
export const TelemetryKeyScoped: Story = {
play: async ({ canvasElement }): Promise<void> => {
await openSelectorWizard(canvasElement);
await userEvent.click(screen.getByTestId('wizard-key-select-logs-read'));
await userEvent.click(
await screen.findByTestId(
'wizard-key-option-resource.service.name-logs-read',
),
);
await userEvent.type(
screen.getByTestId('wizard-value-input-logs-read'),
'checkout',
);
await expect(
screen.getByTestId('wizard-selector-input-logs-read'),
).toHaveValue('builder_query/resource.service.name/checkout');
},
};

View File

@@ -190,10 +190,12 @@ function SpanDetailsContent({
return null;
}
return {
clusterName: getSpanAttribute(selectedSpan, 'k8s.cluster.name') || '',
podName: getSpanAttribute(selectedSpan, 'k8s.pod.name') || '',
nodeName: getSpanAttribute(selectedSpan, 'k8s.node.name') || '',
hostName: getSpanAttribute(selectedSpan, 'host.name') || '',
clusterName: String(
getSpanAttribute(selectedSpan, 'k8s.cluster.name') ?? '',
),
podName: String(getSpanAttribute(selectedSpan, 'k8s.pod.name') ?? ''),
nodeName: String(getSpanAttribute(selectedSpan, 'k8s.node.name') ?? ''),
hostName: String(getSpanAttribute(selectedSpan, 'host.name') ?? ''),
spanTimestamp: dayjs(selectedSpan.timestamp).format(),
};
}, [selectedSpan]);

View File

@@ -20,6 +20,7 @@ function makeSpan(
event: [],
resource: {},
attributes: {},
scope: {},
...overrides,
};
}

View File

@@ -12,6 +12,7 @@ export const MOCK_SPAN: FlamegraphSpan = {
event: [],
resource: {},
attributes: {},
scope: {},
};
/** Nested spans structure for findSpanById tests */

View File

@@ -88,5 +88,17 @@ describe('Presentation / Styling Utils', () => {
);
expect(value).toBe('host-1');
});
it('groups 0 and false under their own value, not "unknown"', () => {
expect(
getFlamegraphSpanGroupValue({ attributes: { 'host.name': 0 } }, HOST_FIELD),
).toBe('0');
expect(
getFlamegraphSpanGroupValue(
{ attributes: { 'host.name': false } },
HOST_FIELD,
),
).toBe('false');
});
});
});

View File

@@ -80,7 +80,7 @@ export function getFlamegraphRowMetrics(
export function getFlamegraphServiceName(
span: Partial<Pick<FlamegraphSpan, 'resource' | 'attributes'>>,
): string {
return getSpanAttribute(span, 'service.name') || '';
return String(getSpanAttribute(span, 'service.name') ?? '');
}
/**
@@ -92,7 +92,7 @@ export function getFlamegraphSpanGroupValue(
span: Partial<Pick<FlamegraphSpan, 'resource' | 'attributes'>>,
field: TelemetryFieldKey,
): string {
return getSpanAttribute(span, field.name) || 'unknown';
return String(getSpanAttribute(span, field.name) ?? 'unknown');
}
interface GetSpanColorArgs {

View File

@@ -27,6 +27,14 @@ describe('getAvailableColorByFieldNames', () => {
expect(getAvailableColorByFieldNames(spans)).toStrictEqual(['host.name']);
});
it('offers a field whose only values are 0 or false', () => {
const spans = [
span({ attributes: { 'host.name': 0 } }),
span({ attributes: { 'host.name': false } }),
];
expect(getAvailableColorByFieldNames(spans)).toStrictEqual(['host.name']);
});
it('does not offer fields no span carries', () => {
const spans = [span({ resource: { 'service.name': 'api' } })];
expect(getAvailableColorByFieldNames(spans)).toStrictEqual(['service.name']);

View File

@@ -518,6 +518,7 @@ const flamegraphSpan = (span: BuiltSpan): SpantypesFlamegraphSpanDTO => ({
level: span.level,
attributes: span.template.attributes,
resource: resourceOf(span),
scope: {},
event: [],
});

View File

@@ -9,14 +9,14 @@ import {
RESERVED_ERROR,
} from './utils/generateColorPair';
/** Like `||`, but keeps `0`. */
/** Like `||`, but keeps `0` and `false`. */
const valueOrUndefined = <T>(value: T): T | undefined =>
value || value === 0 ? value : undefined;
value || value === 0 || value === false ? value : undefined;
/**
* Look up an attribute from both `resource` and `attributes` on a span.
* Resources are checked first (service.name, k8s.* etc. live there).
* Falsy values count as missing, except `0`.
* Falsy values count as missing, except `0` and `false`.
*
* Accepts both `SpanV3` (waterfall) and `FlamegraphSpan` (flamegraph) by typing
* structurally — the only fields touched are `resource` and `attributes`.
@@ -30,7 +30,7 @@ export function getSpanAttribute(
tagMap?: Record<string, any>;
},
key: string,
): string | undefined {
): string | number | boolean | undefined {
return (
valueOrUndefined(span.resource?.[key]) ??
valueOrUndefined(span.attributes?.[key]) ??
@@ -117,7 +117,7 @@ export function getSpanGroupValue(
span: SpanV3,
colorByFieldName: string,
): string {
return getSpanAttribute(span, colorByFieldName) || 'unknown';
return String(getSpanAttribute(span, colorByFieldName) ?? 'unknown');
}
/**
@@ -143,6 +143,6 @@ export function resolveSpanColor(
*/
export function getAvailableColorByFieldNames(spans: SpanV3[]): string[] {
return COLOR_BY_OPTIONS.filter((opt) =>
spans.some((s) => getSpanAttribute(s, opt.field.name)),
spans.some((s) => getSpanAttribute(s, opt.field.name) !== undefined),
).map((opt) => opt.field.name);
}

View File

@@ -73,7 +73,7 @@ function TracesExplorer(): JSX.Element {
const { options } = useOptionsMenu({
storageKey: LOCALSTORAGE.TRACES_LIST_OPTIONS,
dataSource: DataSource.TRACES,
sourcePage: SavedviewtypesSourceDTO.traces,
aggregateOperator: 'noop',
initialOptions: {
selectColumns: defaultSelectedColumns,

View File

@@ -145,7 +145,7 @@ const timestampAt = (index: number, start: number, end: number): string => {
return new Date(end - index * step).toISOString();
};
export const traceSpanRows = ({
const flatSpanRows = ({
count,
offset,
limit,
@@ -173,9 +173,22 @@ export const traceSpanRows = ({
};
});
/** The List view returns every span field, with resource and attribute fields nested in maps. */
export const traceSpanRows = (options: SpanRowsOptions): RawRow[] =>
flatSpanRows(options).map(
({ timestamp, data: { 'service.name': serviceName, ...data } }) => ({
timestamp,
data: {
...data,
attributes: {},
resource: { 'service.name': serviceName },
},
}),
);
/** The Trace view lists root spans, with the span count of the whole trace. */
export const traceRootSpanRows = (options: SpanRowsOptions): RawRow[] =>
traceSpanRows(options).map((row, index) => ({
flatSpanRows(options).map((row, index) => ({
...row,
data: {
...row.data,

View File

@@ -1,15 +1,15 @@
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { TelemetryFieldKey } from 'api/v5/v5';
import { LOCALSTORAGE } from 'constants/localStorage';
import { LogViewMode } from 'container/OptionsMenu/types';
import { LogViewMode, FontSize } from 'container/OptionsMenu/types';
import {
defaultLogsSelectedColumns,
defaultTraceSelectedColumns,
} from 'container/OptionsMenu/constants';
import { FontSize } from 'container/OptionsMenu/types';
import { render, screen, userEvent } from 'tests/test-utils';
import { DataSource } from 'types/common/queryBuilder';
import { usePreferenceContext } from '../context/PreferenceContextProvider';
import { PreferenceSourcePage } from '../types';
const ROUTE_LOGS = '/logs';
const ROUTE_TRACES = '/traces';
@@ -38,14 +38,13 @@ function getLocalStorageJSON<T>(key: string): T | null {
}
function Consumer({
dataSource,
sourcePage,
testIdPrefix,
}: {
dataSource: DataSource;
sourcePage: PreferenceSourcePage;
testIdPrefix: string;
}): JSX.Element {
const ctx = usePreferenceContext();
const slice = dataSource === DataSource.TRACES ? ctx.traces : ctx.logs;
const slice = usePreferenceContext()[sourcePage];
return (
<div>
@@ -58,7 +57,7 @@ function Consumer({
type="button"
onClick={(): void => {
const newCols: TelemetryFieldKey[] =
dataSource === DataSource.TRACES
sourcePage === SavedviewtypesSourceDTO.traces
? (defaultTraceSelectedColumns.slice(0, 1) as TelemetryFieldKey[])
: (defaultLogsSelectedColumns.slice(0, 1) as TelemetryFieldKey[]);
slice.updateColumns(newCols);
@@ -78,7 +77,10 @@ describe('PreferencesProvider integration', () => {
describe('Logs', () => {
it('loads defaults when no localStorage or url provided', () => {
render(
<Consumer dataSource={DataSource.LOGS} testIdPrefix={TESTID_LOGS} />,
<Consumer
sourcePage={SavedviewtypesSourceDTO.logs}
testIdPrefix={TESTID_LOGS}
/>,
undefined,
{
initialRoute: ROUTE_LOGS,
@@ -101,7 +103,10 @@ describe('PreferencesProvider integration', () => {
});
render(
<Consumer dataSource={DataSource.LOGS} testIdPrefix={TESTID_LOGS} />,
<Consumer
sourcePage={SavedviewtypesSourceDTO.logs}
testIdPrefix={TESTID_LOGS}
/>,
undefined,
{
initialRoute: ROUTE_LOGS,
@@ -116,7 +121,10 @@ describe('PreferencesProvider integration', () => {
it('direct mode updateColumns persists to localStorage', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<Consumer dataSource={DataSource.LOGS} testIdPrefix={TESTID_LOGS} />,
<Consumer
sourcePage={SavedviewtypesSourceDTO.logs}
testIdPrefix={TESTID_LOGS}
/>,
undefined,
{
initialRoute: ROUTE_LOGS,
@@ -142,7 +150,7 @@ describe('PreferencesProvider integration', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<Consumer dataSource={DataSource.LOGS} testIdPrefix="logs" />,
<Consumer sourcePage={SavedviewtypesSourceDTO.logs} testIdPrefix="logs" />,
undefined,
{
initialRoute: initialEntry,
@@ -175,7 +183,10 @@ describe('PreferencesProvider integration', () => {
});
render(
<Consumer dataSource={DataSource.LOGS} testIdPrefix={TESTID_LOGS} />,
<Consumer
sourcePage={SavedviewtypesSourceDTO.logs}
testIdPrefix={TESTID_LOGS}
/>,
undefined,
{
initialRoute: ROUTE_LOGS,
@@ -197,7 +208,7 @@ describe('PreferencesProvider integration', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
function FormattingConsumer(): JSX.Element {
const { logs } = usePreferenceContext();
const logs = usePreferenceContext()[SavedviewtypesSourceDTO.logs];
return (
<button
data-testid="logs-update-formatting"
@@ -235,7 +246,10 @@ describe('PreferencesProvider integration', () => {
const initialEntry = `/logs?viewKey=${encodeURIComponent(viewKey)}`;
render(
<Consumer dataSource={DataSource.LOGS} testIdPrefix={TESTID_LOGS} />,
<Consumer
sourcePage={SavedviewtypesSourceDTO.logs}
testIdPrefix={TESTID_LOGS}
/>,
undefined,
{ initialRoute: initialEntry },
);
@@ -254,7 +268,10 @@ describe('PreferencesProvider integration', () => {
describe('Traces', () => {
it('loads defaults when no localStorage or url provided', () => {
render(
<Consumer dataSource={DataSource.TRACES} testIdPrefix={TESTID_TRACES} />,
<Consumer
sourcePage={SavedviewtypesSourceDTO.traces}
testIdPrefix={TESTID_TRACES}
/>,
undefined,
{
initialRoute: ROUTE_TRACES,
@@ -273,7 +290,10 @@ describe('PreferencesProvider integration', () => {
});
render(
<Consumer dataSource={DataSource.TRACES} testIdPrefix={TESTID_TRACES} />,
<Consumer
sourcePage={SavedviewtypesSourceDTO.traces}
testIdPrefix={TESTID_TRACES}
/>,
undefined,
{
initialRoute: ROUTE_TRACES,
@@ -286,7 +306,10 @@ describe('PreferencesProvider integration', () => {
it('direct mode updateColumns persists to localStorage', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<Consumer dataSource={DataSource.TRACES} testIdPrefix={TESTID_TRACES} />,
<Consumer
sourcePage={SavedviewtypesSourceDTO.traces}
testIdPrefix={TESTID_TRACES}
/>,
undefined,
{
initialRoute: ROUTE_TRACES,
@@ -309,7 +332,10 @@ describe('PreferencesProvider integration', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<Consumer dataSource={DataSource.TRACES} testIdPrefix="traces" />,
<Consumer
sourcePage={SavedviewtypesSourceDTO.traces}
testIdPrefix="traces"
/>,
undefined,
{
initialRoute: initialEntry,
@@ -338,7 +364,10 @@ describe('PreferencesProvider integration', () => {
});
render(
<Consumer dataSource={DataSource.TRACES} testIdPrefix={TESTID_TRACES} />,
<Consumer
sourcePage={SavedviewtypesSourceDTO.traces}
testIdPrefix={TESTID_TRACES}
/>,
undefined,
{ initialRoute: ROUTE_TRACES },
);
@@ -355,7 +384,7 @@ describe('PreferencesProvider integration', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
function TracesFormattingConsumer(): JSX.Element {
const { traces } = usePreferenceContext();
const traces = usePreferenceContext()[SavedviewtypesSourceDTO.traces];
return (
<button
data-testid="traces-update-formatting"
@@ -390,7 +419,10 @@ describe('PreferencesProvider integration', () => {
const initialEntry = `/traces?viewKey=${encodeURIComponent(viewKey)}`;
render(
<Consumer dataSource={DataSource.TRACES} testIdPrefix={TESTID_TRACES} />,
<Consumer
sourcePage={SavedviewtypesSourceDTO.traces}
testIdPrefix={TESTID_TRACES}
/>,
undefined,
{ initialRoute: initialEntry },
);

View File

@@ -1,6 +1,9 @@
import { renderHook, waitFor } from '@testing-library/react';
import { Dispatch, SetStateAction } from 'react';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { defaultLogsSelectedColumns } from 'container/OptionsMenu/constants';
import { DataSource } from 'types/common/queryBuilder';
import { FontSize } from 'container/OptionsMenu/types';
import { renderHook, waitFor } from 'tests/test-utils';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import logsLoaderConfig from '../configs/logsLoaderConfig';
import { usePreferenceLoader } from '../loader/usePreferenceLoader';
@@ -42,15 +45,18 @@ jest.mock('../configs/tracesLoaderConfig', () => ({
}));
describe('usePreferenceLoader', () => {
const setReSync = jest.fn() as jest.MockedFunction<
Dispatch<SetStateAction<boolean>>
>;
beforeEach(() => {
jest.clearAllMocks();
});
it('should load logs preferences based on priority order', async () => {
const setReSync = jest.fn();
const { result } = renderHook(() =>
usePreferenceLoader({
dataSource: DataSource.LOGS,
sourcePage: SavedviewtypesSourceDTO.logs,
reSync: false,
setReSync,
}),
@@ -71,10 +77,9 @@ describe('usePreferenceLoader', () => {
});
it('should load traces preferences', async () => {
const setReSync = jest.fn();
const { result } = renderHook(() =>
usePreferenceLoader({
dataSource: DataSource.TRACES,
sourcePage: SavedviewtypesSourceDTO.traces,
reSync: false,
setReSync,
}),
@@ -93,13 +98,11 @@ describe('usePreferenceLoader', () => {
});
it('should call setReSync when reSync is true', async () => {
const setReSync = jest.fn();
// Test that the hook calls setReSync(false) when reSync is true
// We'll unmount quickly to avoid the infinite loop
const { unmount } = renderHook(() =>
usePreferenceLoader({
dataSource: DataSource.LOGS,
sourcePage: SavedviewtypesSourceDTO.logs,
reSync: true,
setReSync,
}),
@@ -118,19 +121,25 @@ describe('usePreferenceLoader', () => {
it('should handle errors during loading', async () => {
// Make first call succeed (initial state), second call throw in reSync effect
const localSpy: jest.SpyInstance = jest.spyOn(logsLoaderConfig, 'local');
const localSpy = jest.spyOn(logsLoaderConfig, 'local');
// The loader reads stored columns by name, though `local()` types them as
// the older key-based shape.
localSpy.mockImplementationOnce(() => ({
columns: [{ name: 'local-column' }],
formatting: { maxLines: 5, format: 'table', fontSize: 'medium', version: 1 },
columns: [{ name: 'local-column' }] as unknown as BaseAutocompleteData[],
formatting: {
maxLines: 5,
format: 'table',
fontSize: FontSize.MEDIUM,
version: 1,
},
}));
localSpy.mockImplementationOnce(() => {
throw new Error('Loading failed');
});
const setReSync = jest.fn();
const { result } = renderHook(() =>
usePreferenceLoader({
dataSource: DataSource.LOGS,
sourcePage: SavedviewtypesSourceDTO.logs,
reSync: true,
setReSync,
}),

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