mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-15 22:50:48 +01:00
Compare commits
12 Commits
ns/trace-a
...
feat/scatt
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
639e091892 | ||
|
|
2bb9496b35 | ||
|
|
251855873d | ||
|
|
69d94733ce | ||
|
|
a83b78d401 | ||
|
|
214d8e4d58 | ||
|
|
74f1a1be42 | ||
|
|
658b2a654b | ||
|
|
088d55cca0 | ||
|
|
d28cd7cf2e | ||
|
|
268c67d34b | ||
|
|
206349b75c |
1
.gitattributes
vendored
1
.gitattributes
vendored
@@ -1,3 +1,4 @@
|
||||
*.css linguist-detectable=false
|
||||
*.stories.mocks.tsx linguist-generated=true
|
||||
**/__story_mockdata__/** linguist-generated=true
|
||||
tests/e2e/drivers/** linguist-generated=true
|
||||
|
||||
47
.github/workflows/e2eci.yaml
vendored
47
.github/workflows/e2eci.yaml
vendored
@@ -3,16 +3,44 @@ name: e2eci
|
||||
on:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
- labeled
|
||||
pull_request_target:
|
||||
types:
|
||||
- labeled
|
||||
merge_group:
|
||||
types:
|
||||
- checks_requested
|
||||
|
||||
concurrency:
|
||||
group: e2eci-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
if: github.event_name != 'merge_group'
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
e2e: ${{ steps.changes.outputs.e2e }}
|
||||
steps:
|
||||
- name: changes
|
||||
id: changes
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
LABELED: ${{ contains(github.event.pull_request.labels.*.name, 'safe-to-e2e') }}
|
||||
run: |
|
||||
files=$(gh api "repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" --paginate --jq '.[].filename')
|
||||
if [ "$LABELED" = true ] || grep -qE '^(frontend|tests/e2e)/' <<< "$files"; then
|
||||
echo "e2e=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
fmtlint:
|
||||
needs: changes
|
||||
if: |
|
||||
((github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
|
||||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))) && contains(github.event.pull_request.labels.*.name, 'safe-to-e2e')
|
||||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))) && needs.changes.outputs.e2e == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: checkout
|
||||
@@ -41,9 +69,10 @@ jobs:
|
||||
project:
|
||||
- chromium
|
||||
shard: [1, 2, 3, 4]
|
||||
needs: changes
|
||||
if: |
|
||||
((github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
|
||||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))) && contains(github.event.pull_request.labels.*.name, 'safe-to-e2e')
|
||||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))) && needs.changes.outputs.e2e == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
@@ -152,3 +181,17 @@ jobs:
|
||||
name: playwright-artifacts-${{ matrix.project }}-${{ matrix.shard }}
|
||||
path: tests/e2e/artifacts/
|
||||
retention-days: 5
|
||||
# Required status check. Fails when e2e was needed but did not pass,
|
||||
# including fork and dependabot PRs still waiting on safe-to-test.
|
||||
e2e:
|
||||
if: always()
|
||||
needs: [changes, fmtlint, test]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: verdict
|
||||
if: |
|
||||
github.event_name != 'merge_group' && (needs.changes.result != 'success' ||
|
||||
(needs.changes.outputs.e2e == 'true' && (needs.fmtlint.result != 'success' || needs.test.result != 'success')))
|
||||
run: |
|
||||
echo "::error::e2e did not pass. Fork and dependabot PRs need the safe-to-test label."
|
||||
exit 1
|
||||
|
||||
@@ -3702,6 +3702,13 @@ components:
|
||||
nullable: true
|
||||
type: number
|
||||
type: object
|
||||
DashboardtypesAxisScale:
|
||||
enum:
|
||||
- auto
|
||||
- linear
|
||||
- log
|
||||
- symlog
|
||||
type: string
|
||||
DashboardtypesBarChartPanelSpec:
|
||||
properties:
|
||||
axes:
|
||||
@@ -4444,6 +4451,7 @@ components:
|
||||
signoz/ListPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
|
||||
signoz/NumberPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec'
|
||||
signoz/PieChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec'
|
||||
signoz/ScatterPlotPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
|
||||
signoz/TablePanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec'
|
||||
signoz/TextPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
|
||||
signoz/TimeSeriesPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec'
|
||||
@@ -4459,6 +4467,7 @@ components:
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
|
||||
type: object
|
||||
DashboardtypesPanelPluginKind:
|
||||
enum:
|
||||
@@ -4472,6 +4481,7 @@ components:
|
||||
- signoz/ListPanel
|
||||
- signoz/TextPanel
|
||||
- signoz/HeatmapPanel
|
||||
- signoz/ScatterPlotPanel
|
||||
type: string
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec:
|
||||
properties:
|
||||
@@ -4557,6 +4567,18 @@ components:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- signoz/ScatterPlotPanel
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotPanelSpec'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec:
|
||||
properties:
|
||||
kind:
|
||||
@@ -4636,6 +4658,16 @@ components:
|
||||
visualization:
|
||||
$ref: '#/components/schemas/DashboardtypesBasicVisualization'
|
||||
type: object
|
||||
DashboardtypesPointDiameter:
|
||||
maximum: 40
|
||||
minimum: 2
|
||||
nullable: true
|
||||
type: integer
|
||||
DashboardtypesPointOpacity:
|
||||
maximum: 1
|
||||
minimum: 0.1
|
||||
nullable: true
|
||||
type: number
|
||||
DashboardtypesPostableDashboardV2:
|
||||
properties:
|
||||
generateName:
|
||||
@@ -4824,6 +4856,104 @@ components:
|
||||
required:
|
||||
- queryValue
|
||||
type: object
|
||||
DashboardtypesScatterPlotAxes:
|
||||
properties:
|
||||
x:
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotAxis'
|
||||
"y":
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotAxis'
|
||||
type: object
|
||||
DashboardtypesScatterPlotAxis:
|
||||
properties:
|
||||
label:
|
||||
description: Axis title. Empty draws none.
|
||||
type: string
|
||||
scale:
|
||||
$ref: '#/components/schemas/DashboardtypesAxisScale'
|
||||
softMax:
|
||||
nullable: true
|
||||
type: number
|
||||
softMin:
|
||||
nullable: true
|
||||
type: number
|
||||
type: object
|
||||
DashboardtypesScatterPlotChartAppearance:
|
||||
properties:
|
||||
points:
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotPoints'
|
||||
type: object
|
||||
DashboardtypesScatterPlotDimensions:
|
||||
properties:
|
||||
colorBy:
|
||||
description: Group-by label names (e.g. k8s.namespace.name) whose combined
|
||||
values colour dots and drive the legend. Empty colours by every group-by
|
||||
label.
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
sizeBy:
|
||||
description: Value column key that scales dot size. Empty draws every dot
|
||||
at the default size.
|
||||
type: string
|
||||
x:
|
||||
description: Value column key (queryName, or queryName.expression for a
|
||||
multi-aggregation query) plotted on the x axis. Empty uses the first value
|
||||
column.
|
||||
type: string
|
||||
"y":
|
||||
description: Value column key plotted on the y axis. Empty uses the second
|
||||
value column.
|
||||
type: string
|
||||
type: object
|
||||
DashboardtypesScatterPlotPanelSpec:
|
||||
properties:
|
||||
axes:
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotAxes'
|
||||
chartAppearance:
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotChartAppearance'
|
||||
dimensions:
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotDimensions'
|
||||
formatting:
|
||||
$ref: '#/components/schemas/DashboardtypesTableFormatting'
|
||||
legend:
|
||||
$ref: '#/components/schemas/DashboardtypesLegend'
|
||||
thresholds:
|
||||
items:
|
||||
$ref: '#/components/schemas/DashboardtypesScatterPlotThreshold'
|
||||
nullable: true
|
||||
type: array
|
||||
visualization:
|
||||
$ref: '#/components/schemas/DashboardtypesBasicVisualization'
|
||||
type: object
|
||||
DashboardtypesScatterPlotPoints:
|
||||
properties:
|
||||
maxSize:
|
||||
$ref: '#/components/schemas/DashboardtypesPointDiameter'
|
||||
minSize:
|
||||
$ref: '#/components/schemas/DashboardtypesPointDiameter'
|
||||
opacity:
|
||||
$ref: '#/components/schemas/DashboardtypesPointOpacity'
|
||||
size:
|
||||
$ref: '#/components/schemas/DashboardtypesPointDiameter'
|
||||
type: object
|
||||
DashboardtypesScatterPlotThreshold:
|
||||
properties:
|
||||
axis:
|
||||
$ref: '#/components/schemas/DashboardtypesThresholdAxis'
|
||||
color:
|
||||
type: string
|
||||
label:
|
||||
type: string
|
||||
unit:
|
||||
type: string
|
||||
value:
|
||||
format: double
|
||||
type: number
|
||||
required:
|
||||
- value
|
||||
- color
|
||||
type: object
|
||||
DashboardtypesSource:
|
||||
enum:
|
||||
- user
|
||||
@@ -4937,6 +5067,11 @@ components:
|
||||
- value
|
||||
- name
|
||||
type: object
|
||||
DashboardtypesThresholdAxis:
|
||||
enum:
|
||||
- x
|
||||
- "y"
|
||||
type: string
|
||||
DashboardtypesThresholdFormat:
|
||||
enum:
|
||||
- text
|
||||
|
||||
@@ -86,6 +86,9 @@ tests/
|
||||
├── .env.local # generated by bootstrap/setup.py (gitignored)
|
||||
├── bootstrap/
|
||||
│ └── setup.py # test_setup / test_teardown — pytest lifecycle
|
||||
├── drivers/ # the driver kit and one driver per feature
|
||||
│ ├── kit.ts
|
||||
│ └── routing.ts
|
||||
├── fixtures/ # Playwright test fixtures (test.extend) only
|
||||
│ └── auth.ts
|
||||
├── helpers/ # function helpers + the constants they share with tests
|
||||
@@ -112,6 +115,24 @@ These two folders look similar but mean different things:
|
||||
|
||||
Rule of thumb: if it's a `test.extend` fixture, put it in `fixtures/`. If it's a function you call explicitly (or a constant the function uses), put it in `helpers/`. If it's a static file the helpers read, put it in `testdata/`.
|
||||
|
||||
### Drivers
|
||||
|
||||
New suites use a driver rather than loose helpers. A driver is a function that closes over the page and returns the feature's actions and locators. `drivers/kit.ts` turns it into a fixture, so a spec imports one module and never passes a `page` around:
|
||||
|
||||
```ts
|
||||
import { expect, PATH, test } from '../../drivers/routing';
|
||||
|
||||
test('TC-09 RouteTab switches the url', async ({ routing }) => {
|
||||
await routing.goto(PATH.metricsSummary);
|
||||
await expect(routing.activeRouteTab(PATH.metricsSummary)).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
- `driver(name, build)` adds a test-scoped fixture built on `authedPage`.
|
||||
- `seeded(name, open, close)` adds a worker-scoped seed. `open` and `close` run on a throwaway admin page.
|
||||
- A driver reuses helpers by calling them, never by reaching for another fixture.
|
||||
- `drivers/` is AI-owned: coding agents generate and maintain it with the specs, and `.gitattributes` collapses it in diffs.
|
||||
|
||||
### Extended fixtures
|
||||
|
||||
For features needing complex setup (API-seeded data, ruler evaluation waits, cleanup), create domain-specific fixtures that extend `auth`. Group them in `fixtures/<domain>/`.
|
||||
|
||||
@@ -72,6 +72,8 @@ interface CustomTimePickerProps {
|
||||
maxTime: number;
|
||||
/** When true, zoom-out button is hidden (e.g. in drawer/modal time selection) */
|
||||
isModalTimeSelection?: boolean;
|
||||
/** Lands on the trigger input. Not spread — the component takes no rest props. */
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
function CustomTimePicker({
|
||||
@@ -95,6 +97,7 @@ function CustomTimePicker({
|
||||
minTime,
|
||||
maxTime,
|
||||
isModalTimeSelection = false,
|
||||
'data-testid': dataTestId,
|
||||
}: CustomTimePickerProps): JSX.Element {
|
||||
const [selectedTimePlaceholderValue, setSelectedTimePlaceholderValue] =
|
||||
useState('Select / Enter Time Range');
|
||||
@@ -596,6 +599,7 @@ function CustomTimePicker({
|
||||
>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
data-testid={dataTestId}
|
||||
autoComplete="off"
|
||||
className={cx(
|
||||
'timeSelection-input',
|
||||
@@ -682,4 +686,5 @@ CustomTimePicker.defaultProps = {
|
||||
onExitLiveLogs: noop,
|
||||
showLiveLogs: false,
|
||||
showRecentlyUsed: true,
|
||||
'data-testid': undefined,
|
||||
};
|
||||
|
||||
@@ -180,6 +180,7 @@ function CustomTimePickerPopoverContent({
|
||||
type="text"
|
||||
className="time-btns"
|
||||
key={option.label + option.value}
|
||||
data-testid={`time-chip-${option.value}`}
|
||||
onClick={(): void => {
|
||||
handleExitLiveLogs();
|
||||
onSelectHandler(option.label, option.value);
|
||||
@@ -259,6 +260,7 @@ function CustomTimePickerPopoverContent({
|
||||
<Button
|
||||
type="text"
|
||||
key={option.label + option.value}
|
||||
data-testid={`time-option-${option.value}`}
|
||||
onClick={(e: React.MouseEvent<HTMLButtonElement>): void => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
@@ -98,6 +98,7 @@ exports[`Not Found page test should render Not Found page without errors 1`] = `
|
||||
|
||||
<div
|
||||
class="c0"
|
||||
data-testid="not-found"
|
||||
>
|
||||
<img
|
||||
alt="not-found"
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Button, Container, Text, TextContainer } from './styles';
|
||||
|
||||
function NotFound({ text = defaultText }: Props): JSX.Element {
|
||||
return (
|
||||
<Container>
|
||||
<Container data-testid="not-found">
|
||||
<NotFoundImage />
|
||||
|
||||
<TextContainer>
|
||||
|
||||
@@ -35,7 +35,8 @@ function TanStackRowCellInner<TData>({
|
||||
<td
|
||||
className={cx(tableStyles.tableCell, 'tanstack-cell-' + cell.column.id)}
|
||||
data-single-column={hasSingleColumn || undefined}
|
||||
onClick={onClick}
|
||||
// While loading, rowData may be a placeholder ({ id: 'skeleton-N' }).
|
||||
onClick={showSkeleton ? undefined : onClick}
|
||||
>
|
||||
{showSkeleton ? (
|
||||
<Skeleton.Input
|
||||
|
||||
@@ -91,6 +91,18 @@ describe('TanStackTableView Integration', () => {
|
||||
expect(screen.getByRole('table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not call onRowClick for skeleton rows', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRowClick = jest.fn();
|
||||
renderTanStackTable({
|
||||
props: { data: [], isLoading: true, onRowClick },
|
||||
});
|
||||
|
||||
await user.click(screen.getAllByRole('cell')[0]);
|
||||
|
||||
expect(onRowClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows loading spinner for infinite scroll when loading', () => {
|
||||
renderTanStackTable({
|
||||
props: { isLoading: true, onEndReached: jest.fn() },
|
||||
|
||||
@@ -314,7 +314,7 @@ function CreateEditRolePageContent(): JSX.Element {
|
||||
cancelText="Keep editing"
|
||||
onConfirm={confirmNavigation}
|
||||
onCancel={cancelNavigation}
|
||||
data-testid="discard-changes-dialog"
|
||||
testId="discard-changes-dialog"
|
||||
>
|
||||
<Typography>
|
||||
{isCreateMode
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { Flame } from '@signozhq/icons';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { getQueryMode } from 'lib/query/queryMode';
|
||||
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
|
||||
import { useCreateAlertFromPanel } from 'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/useCreateAlertFromPanel';
|
||||
import { QueryMode } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import ConfigActionRow from './ConfigActionRow';
|
||||
import styles from './ConfigActions.module.scss';
|
||||
@@ -22,11 +25,16 @@ function ConfigActions({
|
||||
panelId,
|
||||
}: ConfigActionsProps): JSX.Element | null {
|
||||
const createAlert = useCreateAlertFromPanel();
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
const { actions } = getPanelDefinition(panel.spec.plugin.kind);
|
||||
|
||||
// Only kinds whose query can seed an alert offer this today; mirror the panel
|
||||
// menu's create-alert capability.
|
||||
if (!actions.createAlert) {
|
||||
//TODO: AI query check should be removed after we support alerts on ai query @tewarig
|
||||
if (
|
||||
!actions.createAlert ||
|
||||
getQueryMode(currentQuery) === QueryMode.AI_QUERY_BUILDER
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -188,10 +188,7 @@ func (s *traceStore) genAISpanColumns(ctx context.Context, orgID valuer.UUID, bo
|
||||
return nil, err
|
||||
}
|
||||
|
||||
q, err := querybuilder.LoadColumnEvolutions(ctx, querybuilder.NewQueryInfo(ctx, orgID, s.flagger, telemetrytypes.SignalTraces, nil, uint64(bounds.Start.UnixNano()), uint64(bounds.End.UnixNano())), s.metadataStore)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q := querybuilder.NewQueryInfo(ctx, orgID, s.flagger, telemetrytypes.SignalTraces, nil, uint64(bounds.Start.UnixNano()), uint64(bounds.End.UnixNano()))
|
||||
isGenAI, err := s.anyExistsCondition(ctx, q, aiobservabilitytypes.GenAISpanGateKeys, keys, sb)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -291,10 +288,7 @@ func (s *traceStore) selectStorableSpans(ctx context.Context, orgID valuer.UUID)
|
||||
}
|
||||
|
||||
func (s *traceStore) GetThreadSpans(ctx context.Context, orgID valuer.UUID, traceID string, bounds *spantypes.TraceBounds, page spantypes.ThreadPage) ([]spantypes.StorableSpan, error) {
|
||||
q, err := querybuilder.LoadColumnEvolutions(ctx, querybuilder.NewQueryInfo(ctx, orgID, s.flagger, telemetrytypes.SignalTraces, nil, uint64(bounds.Start.UnixNano()), uint64(bounds.End.UnixNano())), s.metadataStore)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q := querybuilder.NewQueryInfo(ctx, orgID, s.flagger, telemetrytypes.SignalTraces, nil, uint64(bounds.Start.UnixNano()), uint64(bounds.End.UnixNano()))
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
sb.Select(
|
||||
"DISTINCT ON (span_id) timestamp", "duration_nano", "span_id", "parent_span_id", "has_error", "name", "kind_string",
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
chparser "github.com/AfterShip/clickhouse-sql-parser/parser"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/flagger"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
@@ -19,6 +20,8 @@ type aggExprRewriter struct {
|
||||
logger *slog.Logger
|
||||
fullTextColumn *telemetrytypes.TelemetryFieldKey
|
||||
storage qbtypes.Storage
|
||||
flagger flagger.Flagger
|
||||
signal telemetrytypes.Signal
|
||||
}
|
||||
|
||||
var _ qbtypes.AggExprRewriter = (*aggExprRewriter)(nil)
|
||||
@@ -27,6 +30,8 @@ func NewAggExprRewriter(
|
||||
settings factory.ProviderSettings,
|
||||
fullTextColumn *telemetrytypes.TelemetryFieldKey,
|
||||
storage qbtypes.Storage,
|
||||
fl flagger.Flagger,
|
||||
signal telemetrytypes.Signal,
|
||||
) *aggExprRewriter {
|
||||
set := factory.NewScopedProviderSettings(settings, "github.com/SigNoz/signoz/pkg/querybuilder/agg_rewrite")
|
||||
|
||||
@@ -34,6 +39,8 @@ func NewAggExprRewriter(
|
||||
logger: set.Logger(),
|
||||
fullTextColumn: fullTextColumn,
|
||||
storage: storage,
|
||||
flagger: fl,
|
||||
signal: signal,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +49,9 @@ func NewAggExprRewriter(
|
||||
// and the args if the parametric aggregation function is used.
|
||||
func (r *aggExprRewriter) Rewrite(
|
||||
ctx context.Context,
|
||||
q qbtypes.QueryInfo,
|
||||
orgID valuer.UUID,
|
||||
startNs uint64,
|
||||
endNs uint64,
|
||||
expr string,
|
||||
rateInterval uint64,
|
||||
keys map[string][]*telemetrytypes.TelemetryFieldKey,
|
||||
@@ -71,7 +80,7 @@ func (r *aggExprRewriter) Rewrite(
|
||||
|
||||
visitor := &exprVisitor{
|
||||
ctx: ctx,
|
||||
query: q,
|
||||
query: NewQueryInfo(ctx, orgID, r.flagger, r.signal, nil, startNs, endNs),
|
||||
logger: r.logger,
|
||||
fieldKeys: keys,
|
||||
fullTextColumn: r.fullTextColumn,
|
||||
@@ -91,7 +100,9 @@ func (r *aggExprRewriter) Rewrite(
|
||||
// RewriteMulti rewrites a slice of expressions.
|
||||
func (r *aggExprRewriter) RewriteMulti(
|
||||
ctx context.Context,
|
||||
q qbtypes.QueryInfo,
|
||||
orgID valuer.UUID,
|
||||
startNs uint64,
|
||||
endNs uint64,
|
||||
exprs []string,
|
||||
rateInterval uint64,
|
||||
keys map[string][]*telemetrytypes.TelemetryFieldKey,
|
||||
@@ -100,7 +111,7 @@ func (r *aggExprRewriter) RewriteMulti(
|
||||
var errs []error
|
||||
var chArgsList [][]any
|
||||
for i, e := range exprs {
|
||||
w, chArgs, err := r.Rewrite(ctx, q, e, rateInterval, keys)
|
||||
w, chArgs, err := r.Rewrite(ctx, orgID, startNs, endNs, e, rateInterval, keys)
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
out[i] = e
|
||||
|
||||
@@ -31,21 +31,6 @@ func NewQueryInfo(ctx context.Context, orgID valuer.UUID, fl flagger.Flagger, si
|
||||
return q
|
||||
}
|
||||
|
||||
// LoadColumnEvolutions loads the column-wide evolution entries a key metadata
|
||||
// does not report still reads through: the JSON span attributes column, when
|
||||
// the flag puts it on the query path. Any other query keeps q as it is.
|
||||
func LoadColumnEvolutions(ctx context.Context, q qbtypes.QueryInfo, store telemetrytypes.MetadataStore) (qbtypes.QueryInfo, error) {
|
||||
if store == nil || q.Signal != telemetrytypes.SignalTraces || !q.TraceAttrsJSONOn {
|
||||
return q, nil
|
||||
}
|
||||
evolutions, err := store.GetColumnEvolutions(ctx, q.Signal, telemetrytypes.FieldContextAttribute)
|
||||
if err != nil {
|
||||
return q, err
|
||||
}
|
||||
q.Evolutions = evolutions
|
||||
return q, nil
|
||||
}
|
||||
|
||||
// Resolve turns one requested key into its meanings, one time for each
|
||||
// use. The order is the same for every storage, in the filter and in every
|
||||
// select field, group by, order by, and aggregation:
|
||||
|
||||
@@ -42,7 +42,7 @@ func NewFactory(
|
||||
factory.MustNewName("audit"),
|
||||
func(_ context.Context, settings factory.ProviderSettings, _ statementbuilder.Config) (qbtypes.StatementBuilder[qbtypes.LogAggregation], error) {
|
||||
storage := audittelemetryschema.NewStorage()
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(settings, audittelemetryschema.DefaultFullTextColumn, storage)
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(settings, audittelemetryschema.DefaultFullTextColumn, storage, fl, telemetrytypes.SignalLogs)
|
||||
return NewAuditQueryStatementBuilder(
|
||||
settings, metadataStore, storage, aggExprRewriter, audittelemetryschema.DefaultFullTextColumn, fl,
|
||||
), nil
|
||||
@@ -354,7 +354,7 @@ func (b *auditQueryStatementBuilder) buildTimeSeriesQuery(
|
||||
|
||||
allAggChArgs := make([]any, 0)
|
||||
for i, agg := range query.Aggregations {
|
||||
rewritten, chArgs, err := b.aggExprRewriter.Rewrite(ctx, info, agg.Expression, uint64(query.StepInterval.Seconds()), keys)
|
||||
rewritten, chArgs, err := b.aggExprRewriter.Rewrite(ctx, orgID, start, end, agg.Expression, uint64(query.StepInterval.Seconds()), keys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -492,7 +492,7 @@ func (b *auditQueryStatementBuilder) buildScalarQuery(
|
||||
if len(query.Aggregations) > 0 {
|
||||
for idx := range query.Aggregations {
|
||||
aggExpr := query.Aggregations[idx]
|
||||
rewritten, chArgs, err := b.aggExprRewriter.Rewrite(ctx, info, aggExpr.Expression, rateInterval, keys)
|
||||
rewritten, chArgs, err := b.aggExprRewriter.Rewrite(ctx, orgID, start, end, aggExpr.Expression, rateInterval, keys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ func newTestAuditStatementBuilder(t *testing.T) *auditQueryStatementBuilder {
|
||||
mockMetadataStore.KeysMap = auditFieldKeyMap()
|
||||
|
||||
storage := audittelemetryschema.NewStorage()
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage)
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage, fl, telemetrytypes.SignalLogs)
|
||||
|
||||
return NewAuditQueryStatementBuilder(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
|
||||
@@ -58,7 +58,7 @@ func TestStatementBuilderResolvesLogFamilies(t *testing.T) {
|
||||
}
|
||||
mockMetadataStore.KeysMap = keys
|
||||
storage := logstelemetryschema.NewStorage()
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage)
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage, fl, telemetrytypes.SignalLogs)
|
||||
statementBuilder := NewLogQueryStatementBuilder(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
mockMetadataStore, storage, aggExprRewriter,
|
||||
@@ -114,7 +114,7 @@ func TestStatementBuilderResolvesLogFamilyFilteredAggregation(t *testing.T) {
|
||||
}
|
||||
mockMetadataStore.KeysMap = keys
|
||||
storage := logstelemetryschema.NewStorage()
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage)
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage, fl, telemetrytypes.SignalLogs)
|
||||
statementBuilder := NewLogQueryStatementBuilder(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
mockMetadataStore, storage, aggExprRewriter,
|
||||
@@ -169,7 +169,7 @@ func TestStatementBuilderResolvesSingleSpellingAcrossNames(t *testing.T) {
|
||||
}}
|
||||
mockMetadataStore.KeysMap = keys
|
||||
storage := logstelemetryschema.NewStorage()
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage)
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage, fl, telemetrytypes.SignalLogs)
|
||||
statementBuilder := NewLogQueryStatementBuilder(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
mockMetadataStore, storage, aggExprRewriter,
|
||||
@@ -229,7 +229,7 @@ func TestStatementBuilderResolvesLogFamilyGroupBy(t *testing.T) {
|
||||
}
|
||||
mockMetadataStore.KeysMap = keys
|
||||
storage := logstelemetryschema.NewStorage()
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage)
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage, fl, telemetrytypes.SignalLogs)
|
||||
statementBuilder := NewLogQueryStatementBuilder(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
mockMetadataStore, storage, aggExprRewriter,
|
||||
|
||||
@@ -47,7 +47,7 @@ func TestStatementBuilderGroupByUnknownKey(t *testing.T) {
|
||||
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
|
||||
mockMetadataStore.KeysMap = logstelemetryschema.BuildCompleteFieldKeyMap(releaseTime)
|
||||
storage := logstelemetryschema.NewStorage()
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage)
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage, fl, telemetrytypes.SignalLogs)
|
||||
statementBuilder := NewLogQueryStatementBuilder(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
mockMetadataStore, storage, aggExprRewriter,
|
||||
|
||||
@@ -1335,7 +1335,7 @@ func buildJSONTestStatementBuilder(t *testing.T, addIndexes bool) (*logQueryStat
|
||||
fl := flaggertest.WithUseJSONBody(t, true)
|
||||
storage := logstelemetryschema.NewStorage()
|
||||
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage)
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage, fl, telemetrytypes.SignalLogs)
|
||||
|
||||
statementBuilder := NewLogQueryStatementBuilder(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
|
||||
@@ -42,7 +42,7 @@ func TestStatementBuilderResourceBodyConflict(t *testing.T) {
|
||||
|
||||
fl := flaggertest.WithUseJSONBody(t, true)
|
||||
storage := logstelemetryschema.NewStorage()
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage)
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage, fl, telemetrytypes.SignalLogs)
|
||||
statementBuilder := NewLogQueryStatementBuilder(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
store,
|
||||
|
||||
@@ -29,7 +29,7 @@ func TestSearchCostGuard(t *testing.T) {
|
||||
storage := logstelemetryschema.NewStorage()
|
||||
store := telemetrytypestest.NewMockMetadataStore()
|
||||
store.KeysMap = logstelemetryschema.BuildCompleteFieldKeyMap(releaseTime)
|
||||
rewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage)
|
||||
rewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage, fl, telemetrytypes.SignalLogs)
|
||||
sb := NewLogQueryStatementBuilder(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
store, storage, rewriter, logstelemetryschema.DefaultFullTextColumn, fl, nil,
|
||||
@@ -59,7 +59,7 @@ func TestSearchCostGuardJSONBody(t *testing.T) {
|
||||
storage := logstelemetryschema.NewStorage()
|
||||
store := telemetrytypestest.NewMockMetadataStore()
|
||||
store.KeysMap = logstelemetryschema.BuildCompleteFieldKeyMap(releaseTime)
|
||||
rewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage)
|
||||
rewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage, fl, telemetrytypes.SignalLogs)
|
||||
sb := NewLogQueryStatementBuilder(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
store, storage, rewriter, logstelemetryschema.DefaultFullTextColumn, fl, nil,
|
||||
|
||||
@@ -60,7 +60,7 @@ func NewFactory(
|
||||
factory.MustNewName("logs"),
|
||||
func(_ context.Context, settings factory.ProviderSettings, cfg statementbuilder.Config) (qbtypes.StatementBuilder[qbtypes.LogAggregation], error) {
|
||||
storage := logstelemetryschema.NewStorage()
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(settings, logstelemetryschema.DefaultFullTextColumn, storage)
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(settings, logstelemetryschema.DefaultFullTextColumn, storage, fl, telemetrytypes.SignalLogs)
|
||||
return NewLogQueryStatementBuilder(
|
||||
settings, metadataStore, storage, aggExprRewriter, logstelemetryschema.DefaultFullTextColumn,
|
||||
fl, telemetryStore, cfg,
|
||||
@@ -480,7 +480,7 @@ func (b *logQueryStatementBuilder) buildTimeSeriesQuery(
|
||||
allAggChArgs := make([]any, 0)
|
||||
for i, agg := range query.Aggregations {
|
||||
rewritten, chArgs, err := b.aggExprRewriter.Rewrite(
|
||||
ctx, info, agg.Expression,
|
||||
ctx, orgID, start, end, agg.Expression,
|
||||
uint64(query.StepInterval.Seconds()),
|
||||
keys,
|
||||
)
|
||||
@@ -649,7 +649,7 @@ func (b *logQueryStatementBuilder) buildScalarQuery(
|
||||
for idx := range query.Aggregations {
|
||||
aggExpr := query.Aggregations[idx]
|
||||
rewritten, chArgs, err := b.aggExprRewriter.Rewrite(
|
||||
ctx, info, aggExpr.Expression,
|
||||
ctx, orgID, start, end, aggExpr.Expression,
|
||||
rateInterval,
|
||||
keys,
|
||||
)
|
||||
|
||||
@@ -220,7 +220,7 @@ func TestStatementBuilderTimeSeries(t *testing.T) {
|
||||
|
||||
storage := logstelemetryschema.NewStorage()
|
||||
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage)
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage, fl, telemetrytypes.SignalLogs)
|
||||
|
||||
statementBuilder := NewLogQueryStatementBuilder(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
@@ -360,7 +360,7 @@ func TestStatementBuilderListQuery(t *testing.T) {
|
||||
releaseTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)
|
||||
mockMetadataStore.KeysMap = logstelemetryschema.BuildCompleteFieldKeyMap(releaseTime)
|
||||
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage)
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage, fl, telemetrytypes.SignalLogs)
|
||||
|
||||
statementBuilder := NewLogQueryStatementBuilder(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
@@ -510,7 +510,7 @@ func TestStatementBuilderListQueryResourceTests(t *testing.T) {
|
||||
releaseTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)
|
||||
mockMetadataStore.KeysMap = logstelemetryschema.BuildCompleteFieldKeyMap(releaseTime)
|
||||
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage)
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage, fl, telemetrytypes.SignalLogs)
|
||||
|
||||
statementBuilder := NewLogQueryStatementBuilder(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
@@ -585,7 +585,7 @@ func TestStatementBuilderTimeSeriesBodyGroupBy(t *testing.T) {
|
||||
releaseTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)
|
||||
mockMetadataStore.KeysMap = logstelemetryschema.BuildCompleteFieldKeyMap(releaseTime)
|
||||
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage)
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage, fl, telemetrytypes.SignalLogs)
|
||||
|
||||
statementBuilder := NewLogQueryStatementBuilder(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
@@ -680,7 +680,7 @@ func TestStatementBuilderListQueryServiceCollision(t *testing.T) {
|
||||
storage := logstelemetryschema.NewStorage()
|
||||
mockMetadataStore.KeysMap = logstelemetryschema.BuildCompleteFieldKeyMapCollision()
|
||||
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage)
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage, fl, telemetrytypes.SignalLogs)
|
||||
|
||||
statementBuilder := NewLogQueryStatementBuilder(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
@@ -903,7 +903,7 @@ func TestAdjustKey(t *testing.T) {
|
||||
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
|
||||
mockMetadataStore.KeysMap = logstelemetryschema.BuildCompleteFieldKeyMapCollision()
|
||||
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage)
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage, fl, telemetrytypes.SignalLogs)
|
||||
|
||||
statementBuilder := NewLogQueryStatementBuilder(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
@@ -1048,7 +1048,7 @@ func TestStmtBuilderBodyField(t *testing.T) {
|
||||
f := field
|
||||
mockMetadataStore.KeysMap[field.Name] = append(mockMetadataStore.KeysMap[field.Name], &f)
|
||||
}
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage)
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage, fl, telemetrytypes.SignalLogs)
|
||||
statementBuilder := NewLogQueryStatementBuilder(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
mockMetadataStore,
|
||||
@@ -1146,7 +1146,7 @@ func TestStmtBuilderBodyFullTextSearch(t *testing.T) {
|
||||
f := field
|
||||
mockMetadataStore.KeysMap[field.Name] = append(mockMetadataStore.KeysMap[field.Name], &f)
|
||||
}
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage)
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage, fl, telemetrytypes.SignalLogs)
|
||||
statementBuilder := NewLogQueryStatementBuilder(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
mockMetadataStore,
|
||||
@@ -1258,7 +1258,7 @@ func newSkipResourceFingerprintLogsBuilder(
|
||||
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
|
||||
mockMetadataStore.KeysMap = logstelemetryschema.BuildCompleteFieldKeyMap(time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC))
|
||||
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), logstelemetryschema.DefaultFullTextColumn, storage)
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), logstelemetryschema.DefaultFullTextColumn, storage, fl, telemetrytypes.SignalLogs)
|
||||
|
||||
return NewLogQueryStatementBuilder(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
|
||||
@@ -226,10 +226,7 @@ func (b *scopedTraceStatementBuilder) buildTraceListQuery(
|
||||
return nil, err
|
||||
}
|
||||
matchedSB := sqlbuilder.NewSelectBuilder()
|
||||
q, err := querybuilder.LoadColumnEvolutions(ctx, querybuilder.NewQueryInfo(ctx, orgID, b.fl, telemetrytypes.SignalTraces, nil, start, end), b.metadataStore)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q := querybuilder.NewQueryInfo(ctx, orgID, b.fl, telemetrytypes.SignalTraces, nil, start, end)
|
||||
maskExpr, resolved, err := b.resolveFor(ctx, q, keys, matchedSB)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -248,7 +245,7 @@ func (b *scopedTraceStatementBuilder) buildTraceListQuery(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fp, err := b.splitFilter(ctx, q, query, b.aggregateAliasSet(), keys, variables, matchedSB)
|
||||
fp, err := b.splitFilter(ctx, orgID, query, b.aggregateAliasSet(), keys, start, end, variables, matchedSB)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -448,7 +445,7 @@ type filterParts struct {
|
||||
// splitFilter splits query.Filter into a span-level predicate and a trace-level
|
||||
// HAVING (explicit query.Having ANDed on before resolution); args bind into sb.
|
||||
// keys must cover the filter's span-level selectors.
|
||||
func (b *scopedTraceStatementBuilder) splitFilter(ctx context.Context, q qbtypes.QueryInfo, query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation], classifySet map[string]struct{}, keys map[string][]*telemetrytypes.TelemetryFieldKey, variables map[string]qbtypes.VariableItem, sb *sqlbuilder.SelectBuilder) (filterParts, error) {
|
||||
func (b *scopedTraceStatementBuilder) splitFilter(ctx context.Context, orgID valuer.UUID, query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation], classifySet map[string]struct{}, keys map[string][]*telemetrytypes.TelemetryFieldKey, start, end uint64, variables map[string]qbtypes.VariableItem, sb *sqlbuilder.SelectBuilder) (filterParts, error) {
|
||||
var fp filterParts
|
||||
havingExpr := ""
|
||||
if query.Filter != nil && strings.TrimSpace(query.Filter.Expression) != "" {
|
||||
@@ -458,7 +455,7 @@ func (b *scopedTraceStatementBuilder) splitFilter(ctx context.Context, q qbtypes
|
||||
}
|
||||
havingExpr = traceExpr
|
||||
if strings.TrimSpace(spanExpr) != "" {
|
||||
pred, warnings, url, err := b.resolveSpanPredicate(ctx, q, spanExpr, keys, variables, sb)
|
||||
pred, warnings, url, err := b.resolveSpanPredicate(ctx, querybuilder.NewQueryInfo(ctx, orgID, b.fl, telemetrytypes.SignalTraces, nil, start, end), spanExpr, keys, variables, sb)
|
||||
if err != nil {
|
||||
return fp, err
|
||||
}
|
||||
|
||||
@@ -340,12 +340,8 @@ func (b *scopedTraceStatementBuilder) buildQualifiedStatement(
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
q, err := querybuilder.LoadColumnEvolutions(ctx, querybuilder.NewQueryInfo(ctx, orgID, b.fl, telemetrytypes.SignalTraces, nil, start, end), b.metadataStore)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
maskExpr, resolved, err := b.resolveFor(ctx, q, keys, sb)
|
||||
maskExpr, resolved, err := b.resolveFor(ctx, querybuilder.NewQueryInfo(ctx, orgID, b.fl, telemetrytypes.SignalTraces, nil, start, end), keys, sb)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
@@ -515,13 +511,15 @@ type scanContext struct {
|
||||
|
||||
func (b *scopedTraceStatementBuilder) newScanContext(
|
||||
ctx context.Context,
|
||||
q qbtypes.QueryInfo,
|
||||
orgID valuer.UUID,
|
||||
start, end uint64,
|
||||
keys map[string][]*telemetrytypes.TelemetryFieldKey,
|
||||
spanExpr, traceExpr string,
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
) (*scanContext, error) {
|
||||
sc := &scanContext{sb: sqlbuilder.NewSelectBuilder()}
|
||||
var err error
|
||||
q := querybuilder.NewQueryInfo(ctx, orgID, b.fl, telemetrytypes.SignalTraces, nil, start, end)
|
||||
sc.maskExpr, sc.resolved, err = b.resolveFor(ctx, q, keys, sc.sb)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -568,10 +566,6 @@ func (b *scopedTraceStatementBuilder) buildTraceAggregationQuery(
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q, err := querybuilder.LoadColumnEvolutions(ctx, querybuilder.NewQueryInfo(ctx, orgID, b.fl, telemetrytypes.SignalTraces, nil, start, end), b.metadataStore)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resourceFrag, resourceArgs, resourcePred, err := b.maybeAttachResourceFilter(ctx, orgID, query, start, end, variables)
|
||||
if err != nil {
|
||||
@@ -588,7 +582,7 @@ func (b *scopedTraceStatementBuilder) buildTraceAggregationQuery(
|
||||
// __qualified: its own scan resolution, HAVING = the trace-level filter part
|
||||
qualified := false
|
||||
if strings.TrimSpace(traceExpr) != "" {
|
||||
qsc, err := b.newScanContext(ctx, q, keys, "", traceExpr, variables)
|
||||
qsc, err := b.newScanContext(ctx, orgID, start, end, keys, "", traceExpr, variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -604,7 +598,7 @@ func (b *scopedTraceStatementBuilder) buildTraceAggregationQuery(
|
||||
}
|
||||
}
|
||||
|
||||
groupCols, err := b.resolveGroupColumns(ctx, q, query.GroupBy, keys)
|
||||
groupCols, err := b.resolveGroupColumns(ctx, querybuilder.NewQueryInfo(ctx, orgID, b.fl, telemetrytypes.SignalTraces, nil, start, end), query.GroupBy, keys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -646,7 +640,7 @@ func (b *scopedTraceStatementBuilder) buildTraceAggregationQuery(
|
||||
// (exact for non-composable aggregates) and prune the main scan to the top-N.
|
||||
limitPred := ""
|
||||
if requestType == qbtypes.RequestTypeTimeSeries && query.Limit > 0 && len(groupCols) > 0 {
|
||||
tsc, err := b.newScanContext(ctx, q, keys, spanExpr, "", variables)
|
||||
tsc, err := b.newScanContext(ctx, orgID, start, end, keys, spanExpr, "", variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -672,7 +666,7 @@ func (b *scopedTraceStatementBuilder) buildTraceAggregationQuery(
|
||||
strings.Join(exprs, ", "), strings.Join(groupNames, ", "))
|
||||
}
|
||||
|
||||
msc, err := b.newScanContext(ctx, q, keys, spanExpr, "", variables)
|
||||
msc, err := b.newScanContext(ctx, orgID, start, end, keys, spanExpr, "", variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ func newBulkTestBuilder(t *testing.T, releaseTime time.Time) *traceQueryStatemen
|
||||
}}
|
||||
store.ColumnEvolutionMetadataMap["traces:attribute:__all__"] = tracestelemetryschema.MockAttributeEvolutionData(releaseTime)
|
||||
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage)
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage, fl, telemetrytypes.SignalTraces)
|
||||
return NewTraceQueryStatementBuilder(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
store, storage, aggExprRewriter, nil, fl, false, 100000,
|
||||
@@ -148,71 +148,3 @@ func TestGroupByAttributeHomeAcrossRollout(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnknownAttributeFollowsRollout: a filter or group-by key metadata does not report reads the
|
||||
// home the window selects, the same as a reported key.
|
||||
func TestUnknownAttributeFollowsRollout(t *testing.T) {
|
||||
releaseTime := time.Date(2025, 5, 22, 22, 0, 0, 0, time.UTC)
|
||||
rel := releaseTime.UnixMilli()
|
||||
day := int64(24 * time.Hour / time.Millisecond)
|
||||
|
||||
b := newBulkTestBuilder(t, releaseTime)
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
startMs uint64
|
||||
endMs uint64
|
||||
wantContains []string
|
||||
wantNotContains []string
|
||||
}{
|
||||
{
|
||||
name: "BeforeRollout_ReadsLegacyMap",
|
||||
startMs: uint64(rel - 2*day),
|
||||
endMs: uint64(rel - day),
|
||||
wantContains: []string{"attributes_string['unknown.attr'] = ?", "mapContains(attributes_string, 'unknown.attr')"},
|
||||
wantNotContains: []string{"attributes.`unknown.attr`"},
|
||||
},
|
||||
{
|
||||
name: "StraddlingRollout_JSONThenLegacyFallback",
|
||||
startMs: uint64(rel - day),
|
||||
endMs: uint64(rel + day),
|
||||
wantContains: []string{"attributes.`unknown.attr` IS NOT NULL", "attributes.`unknown.attr`::String", "attributes_string['unknown.attr']"},
|
||||
wantNotContains: nil,
|
||||
},
|
||||
{
|
||||
name: "AfterRollout_ReadsJSONOnly",
|
||||
startMs: uint64(rel + day),
|
||||
endMs: uint64(rel + 2*day),
|
||||
wantContains: []string{"attributes.`unknown.attr`::String = ?"},
|
||||
wantNotContains: []string{"attributes_string"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
stmt, err := b.Build(
|
||||
context.Background(), valuer.UUID{}, testCase.startMs, testCase.endMs,
|
||||
qbtypes.RequestTypeTimeSeries,
|
||||
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
|
||||
Aggregations: []qbtypes.TraceAggregation{{Expression: "count()"}},
|
||||
Filter: &qbtypes.Filter{Expression: "unknown.attr = 'x'"},
|
||||
GroupBy: []qbtypes.GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "unknown.attr",
|
||||
}}},
|
||||
Limit: 10,
|
||||
},
|
||||
nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
for _, want := range testCase.wantContains {
|
||||
assert.Contains(t, stmt.Query, want)
|
||||
}
|
||||
for _, unwanted := range testCase.wantNotContains {
|
||||
assert.NotContains(t, stmt.Query, unwanted)
|
||||
}
|
||||
assert.Contains(t, strings.Join(stmt.Warnings, "\n"), "not found in metadata")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ func NewFactory(
|
||||
factory.MustNewName("traces"),
|
||||
func(_ context.Context, settings factory.ProviderSettings, cfg statementbuilder.Config) (qbtypes.StatementBuilder[qbtypes.TraceAggregation], error) {
|
||||
storage := tracestelemetryschema.NewStorage()
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(settings, nil, storage)
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(settings, nil, storage, fl, telemetrytypes.SignalTraces)
|
||||
return NewTraceQueryStatementBuilder(
|
||||
settings, metadataStore, storage, aggExprRewriter, telemetryStore, fl,
|
||||
cfg.SkipResourceFingerprint.Enabled, cfg.SkipResourceFingerprint.Threshold,
|
||||
@@ -169,22 +169,18 @@ func (b *traceQueryStatementBuilder) Build(
|
||||
for _, action := range adjustTraceKeys(keys, &query, requestType) {
|
||||
b.logger.DebugContext(ctx, "key adjustment action", slog.String("action", action))
|
||||
}
|
||||
info, err := querybuilder.LoadColumnEvolutions(ctx, querybuilder.NewQueryInfo(ctx, orgID, b.fl, telemetrytypes.SignalTraces, nil, start, end), b.metadataStore)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Create SQL builder
|
||||
q := sqlbuilder.NewSelectBuilder()
|
||||
|
||||
switch requestType {
|
||||
case qbtypes.RequestTypeRaw:
|
||||
return b.buildListQuery(ctx, orgID, q, query, start, end, info, keys, variables, isSelectFieldsEmpty)
|
||||
return b.buildListQuery(ctx, orgID, q, query, start, end, keys, variables, isSelectFieldsEmpty)
|
||||
case qbtypes.RequestTypeTimeSeries:
|
||||
return b.buildTimeSeriesQuery(ctx, orgID, q, query, start, end, info, keys, variables)
|
||||
return b.buildTimeSeriesQuery(ctx, orgID, q, query, start, end, keys, variables)
|
||||
case qbtypes.RequestTypeScalar:
|
||||
return b.buildScalarQuery(ctx, orgID, q, query, start, end, info, keys, variables, false, false)
|
||||
return b.buildScalarQuery(ctx, orgID, q, query, start, end, keys, variables, false, false)
|
||||
case qbtypes.RequestTypeTrace:
|
||||
return b.buildTraceQuery(ctx, orgID, q, query, start, end, info, keys, variables)
|
||||
return b.buildTraceQuery(ctx, orgID, q, query, start, end, keys, variables)
|
||||
}
|
||||
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported request type: %s", requestType)
|
||||
@@ -359,7 +355,6 @@ func (b *traceQueryStatementBuilder) buildListQuery(
|
||||
sb *sqlbuilder.SelectBuilder,
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
|
||||
start, end uint64,
|
||||
info qbtypes.QueryInfo,
|
||||
keys map[string][]*telemetrytypes.TelemetryFieldKey,
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
isSelectFieldsEmpty bool,
|
||||
@@ -384,6 +379,7 @@ func (b *traceQueryStatementBuilder) buildListQuery(
|
||||
cteArgs = append(cteArgs, scopeArgs...)
|
||||
}
|
||||
|
||||
info := querybuilder.NewQueryInfo(ctx, orgID, b.fl, telemetrytypes.SignalTraces, nil, start, end)
|
||||
for i, field := range query.SelectFields {
|
||||
expr, err := querybuilder.ResolveColumn(ctx, info, b.storage, &field, telemetrytypes.FieldDataTypeUnspecified, keys)
|
||||
if err != nil {
|
||||
@@ -402,7 +398,7 @@ func (b *traceQueryStatementBuilder) buildListQuery(
|
||||
sb.From(fmt.Sprintf("%s.%s", tracestelemetryschema.DBName, tracestelemetryschema.SpanIndexV3TableName))
|
||||
|
||||
// Add filter conditions
|
||||
preparedWhereClause, err := b.addFilterCondition(ctx, sb, start, end, info, query, keys, variables, skipResourceFilter)
|
||||
preparedWhereClause, err := b.addFilterCondition(ctx, orgID, sb, start, end, query, keys, variables, skipResourceFilter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -448,7 +444,6 @@ func (b *traceQueryStatementBuilder) buildTraceQuery(
|
||||
_ *sqlbuilder.SelectBuilder,
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
|
||||
start, end uint64,
|
||||
info qbtypes.QueryInfo,
|
||||
keys map[string][]*telemetrytypes.TelemetryFieldKey,
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
) (*qbtypes.Statement, error) {
|
||||
@@ -475,7 +470,7 @@ func (b *traceQueryStatementBuilder) buildTraceQuery(
|
||||
}
|
||||
|
||||
// Add filter conditions
|
||||
preparedWhereClause, err := b.addFilterCondition(ctx, distSB, start, end, info, query, keys, variables, skipResourceFilter)
|
||||
preparedWhereClause, err := b.addFilterCondition(ctx, orgID, distSB, start, end, query, keys, variables, skipResourceFilter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -568,7 +563,6 @@ func (b *traceQueryStatementBuilder) buildTimeSeriesQuery(
|
||||
sb *sqlbuilder.SelectBuilder,
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
|
||||
start, end uint64,
|
||||
info qbtypes.QueryInfo,
|
||||
keys map[string][]*telemetrytypes.TelemetryFieldKey,
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
) (*qbtypes.Statement, error) {
|
||||
@@ -599,6 +593,7 @@ func (b *traceQueryStatementBuilder) buildTimeSeriesQuery(
|
||||
|
||||
// Keep original column expressions so we can build the tuple
|
||||
fieldNames := make([]string, 0, len(query.GroupBy))
|
||||
info := querybuilder.NewQueryInfo(ctx, orgID, b.fl, telemetrytypes.SignalTraces, nil, start, end)
|
||||
for i, gb := range query.GroupBy {
|
||||
expr, err := querybuilder.ResolveColumn(ctx, info, b.storage, &gb.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
|
||||
if err != nil {
|
||||
@@ -613,7 +608,7 @@ func (b *traceQueryStatementBuilder) buildTimeSeriesQuery(
|
||||
allAggChArgs := make([]any, 0)
|
||||
for i, agg := range query.Aggregations {
|
||||
rewritten, chArgs, err := b.aggExprRewriter.Rewrite(
|
||||
ctx, info, agg.Expression,
|
||||
ctx, orgID, start, end, agg.Expression,
|
||||
uint64(query.StepInterval.Seconds()),
|
||||
keys,
|
||||
)
|
||||
@@ -625,7 +620,7 @@ func (b *traceQueryStatementBuilder) buildTimeSeriesQuery(
|
||||
}
|
||||
|
||||
sb.From(fmt.Sprintf("%s.%s", tracestelemetryschema.DBName, tracestelemetryschema.SpanIndexV3TableName))
|
||||
preparedWhereClause, err := b.addFilterCondition(ctx, sb, start, end, info, query, keys, variables, skipResourceFilter)
|
||||
preparedWhereClause, err := b.addFilterCondition(ctx, orgID, sb, start, end, query, keys, variables, skipResourceFilter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -636,7 +631,7 @@ func (b *traceQueryStatementBuilder) buildTimeSeriesQuery(
|
||||
if query.Limit > 0 && len(query.GroupBy) > 0 {
|
||||
// build the scalar “top/bottom-N” query in its own builder.
|
||||
cteSB := sqlbuilder.NewSelectBuilder()
|
||||
cteStmt, err := b.buildScalarQuery(ctx, orgID, cteSB, query, start, end, info, keys, variables, true, true)
|
||||
cteStmt, err := b.buildScalarQuery(ctx, orgID, cteSB, query, start, end, keys, variables, true, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -732,7 +727,6 @@ func (b *traceQueryStatementBuilder) buildScalarQuery(
|
||||
sb *sqlbuilder.SelectBuilder,
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
|
||||
start, end uint64,
|
||||
info qbtypes.QueryInfo,
|
||||
keys map[string][]*telemetrytypes.TelemetryFieldKey,
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
skipResourceCTE bool,
|
||||
@@ -763,6 +757,7 @@ func (b *traceQueryStatementBuilder) buildScalarQuery(
|
||||
allAggChArgs := []any{}
|
||||
|
||||
fieldNames := make([]string, 0, len(query.GroupBy))
|
||||
info := querybuilder.NewQueryInfo(ctx, orgID, b.fl, telemetrytypes.SignalTraces, nil, start, end)
|
||||
for i, gb := range query.GroupBy {
|
||||
expr, err := querybuilder.ResolveColumn(ctx, info, b.storage, &gb.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
|
||||
if err != nil {
|
||||
@@ -781,7 +776,7 @@ func (b *traceQueryStatementBuilder) buildScalarQuery(
|
||||
for idx := range query.Aggregations {
|
||||
aggExpr := query.Aggregations[idx]
|
||||
rewritten, chArgs, err := b.aggExprRewriter.Rewrite(
|
||||
ctx, info, aggExpr.Expression,
|
||||
ctx, orgID, start, end, aggExpr.Expression,
|
||||
rateInterval,
|
||||
keys,
|
||||
)
|
||||
@@ -797,7 +792,7 @@ func (b *traceQueryStatementBuilder) buildScalarQuery(
|
||||
sb.From(fmt.Sprintf("%s.%s", tracestelemetryschema.DBName, tracestelemetryschema.SpanIndexV3TableName))
|
||||
|
||||
// Add filter conditions
|
||||
preparedWhereClause, err := b.addFilterCondition(ctx, sb, start, end, info, query, keys, variables, skipResourceFilter)
|
||||
preparedWhereClause, err := b.addFilterCondition(ctx, orgID, sb, start, end, query, keys, variables, skipResourceFilter)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -859,9 +854,9 @@ func (b *traceQueryStatementBuilder) buildScalarQuery(
|
||||
// buildFilterCondition builds SQL condition from filter expression.
|
||||
func (b *traceQueryStatementBuilder) addFilterCondition(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
sb *sqlbuilder.SelectBuilder,
|
||||
start, end uint64,
|
||||
info qbtypes.QueryInfo,
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
|
||||
keys map[string][]*telemetrytypes.TelemetryFieldKey,
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
@@ -875,7 +870,7 @@ func (b *traceQueryStatementBuilder) addFilterCondition(
|
||||
// add filter expression
|
||||
preparedWhereClause, err = querybuilder.PrepareWhereClause(query.Filter.Expression, querybuilder.FilterExprVisitorOpts{
|
||||
Context: ctx,
|
||||
Query: info,
|
||||
Query: querybuilder.NewQueryInfo(ctx, orgID, b.fl, telemetrytypes.SignalTraces, nil, start, end),
|
||||
Storage: b.storage,
|
||||
Logger: b.logger,
|
||||
FieldKeys: keys,
|
||||
|
||||
@@ -468,7 +468,7 @@ func TestStatementBuilder(t *testing.T) {
|
||||
storage := tracestelemetryschema.NewStorage()
|
||||
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
|
||||
mockMetadataStore.KeysMap = tracestelemetryschema.BuildCompleteFieldKeyMap(releaseTime)
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage)
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage, fl, telemetrytypes.SignalTraces)
|
||||
|
||||
statementBuilder := NewTraceQueryStatementBuilder(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
@@ -767,7 +767,7 @@ func TestStatementBuilderListQuery(t *testing.T) {
|
||||
storage := tracestelemetryschema.NewStorage()
|
||||
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
|
||||
mockMetadataStore.KeysMap = tracestelemetryschema.BuildCompleteFieldKeyMap(releaseTime)
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage)
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage, fl, telemetrytypes.SignalTraces)
|
||||
|
||||
statementBuilder := NewTraceQueryStatementBuilder(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
@@ -1032,7 +1032,7 @@ func TestStatementBuilderListQueryWithCorruptData(t *testing.T) {
|
||||
if mockMetadataStore.KeysMap == nil {
|
||||
mockMetadataStore.KeysMap = tracestelemetryschema.BuildCompleteFieldKeyMap(releaseTime)
|
||||
}
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage)
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage, fl, telemetrytypes.SignalTraces)
|
||||
|
||||
statementBuilder := NewTraceQueryStatementBuilder(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
@@ -1102,7 +1102,7 @@ func TestStatementBuilderGroupByResourceEvolution(t *testing.T) {
|
||||
storage := tracestelemetryschema.NewStorage()
|
||||
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
|
||||
mockMetadataStore.KeysMap = tracestelemetryschema.BuildCompleteFieldKeyMap(releaseTime)
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage)
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage, fl, telemetrytypes.SignalTraces)
|
||||
|
||||
statementBuilder := NewTraceQueryStatementBuilder(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
@@ -1267,7 +1267,7 @@ func TestStatementBuilderTraceQuery(t *testing.T) {
|
||||
storage := tracestelemetryschema.NewStorage()
|
||||
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
|
||||
mockMetadataStore.KeysMap = tracestelemetryschema.BuildCompleteFieldKeyMap(releaseTime)
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage)
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage, fl, telemetrytypes.SignalTraces)
|
||||
|
||||
statementBuilder := NewTraceQueryStatementBuilder(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
@@ -1905,7 +1905,7 @@ func newSkipResourceFingerprintBuilder(
|
||||
releaseTime := time.Date(2025, 5, 22, 22, 0, 0, 0, time.UTC)
|
||||
mockMetadataStore.KeysMap = tracestelemetryschema.BuildCompleteFieldKeyMap(releaseTime)
|
||||
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage)
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage, fl, telemetrytypes.SignalTraces)
|
||||
|
||||
return NewTraceQueryStatementBuilder(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
@@ -1929,7 +1929,7 @@ func TestStatementBuilderGroupByUnseenKey(t *testing.T) {
|
||||
storage := tracestelemetryschema.NewStorage()
|
||||
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
|
||||
mockMetadataStore.KeysMap = tracestelemetryschema.BuildCompleteFieldKeyMap(releaseTime)
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage)
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage, fl, telemetrytypes.SignalTraces)
|
||||
|
||||
statementBuilder := NewTraceQueryStatementBuilder(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
@@ -1969,7 +1969,7 @@ func TestStatementBuilderAggregationUnseenKey(t *testing.T) {
|
||||
storage := tracestelemetryschema.NewStorage()
|
||||
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
|
||||
mockMetadataStore.KeysMap = tracestelemetryschema.BuildCompleteFieldKeyMap(releaseTime)
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage)
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage, fl, telemetrytypes.SignalTraces)
|
||||
|
||||
statementBuilder := NewTraceQueryStatementBuilder(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
@@ -2042,7 +2042,7 @@ func TestStatementBuilderSemconvFamilies(t *testing.T) {
|
||||
storage := tracestelemetryschema.NewStorage()
|
||||
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
|
||||
mockMetadataStore.KeysMap = tracestelemetryschema.BuildCompleteFieldKeyMap(releaseTime)
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage)
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage, fl, telemetrytypes.SignalTraces)
|
||||
|
||||
statementBuilder := NewTraceQueryStatementBuilder(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
@@ -2081,7 +2081,7 @@ func TestStatementBuilderSemconvSingleSpelling(t *testing.T) {
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
}},
|
||||
}
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage)
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage, fl, telemetrytypes.SignalTraces)
|
||||
|
||||
statementBuilder := NewTraceQueryStatementBuilder(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
|
||||
@@ -28,7 +28,6 @@ type traceOperatorCTEBuilder struct {
|
||||
start uint64
|
||||
end uint64
|
||||
orgID valuer.UUID
|
||||
queryInfo qbtypes.QueryInfo
|
||||
operator *qbtypes.QueryBuilderTraceOperator
|
||||
stmtBuilder *traceOperatorStatementBuilder
|
||||
queries map[string]*qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]
|
||||
@@ -266,7 +265,7 @@ func (b *traceOperatorCTEBuilder) buildQueryCTE(ctx context.Context, queryName s
|
||||
query.Filter.Expression,
|
||||
querybuilder.FilterExprVisitorOpts{
|
||||
Context: ctx,
|
||||
Query: b.queryInfo,
|
||||
Query: querybuilder.NewQueryInfo(ctx, b.orgID, b.stmtBuilder.fl, telemetrytypes.SignalTraces, nil, b.start, b.end),
|
||||
Storage: b.stmtBuilder.storage,
|
||||
Logger: b.stmtBuilder.logger,
|
||||
FieldKeys: keys,
|
||||
@@ -486,7 +485,7 @@ func (b *traceOperatorCTEBuilder) buildListQuery(ctx context.Context, selectFrom
|
||||
}
|
||||
|
||||
// Add selectFields since we now have all base table columns
|
||||
info := b.queryInfo
|
||||
info := querybuilder.NewQueryInfo(ctx, b.orgID, b.stmtBuilder.fl, telemetrytypes.SignalTraces, nil, b.start, b.end)
|
||||
for i, field := range b.operator.SelectFields {
|
||||
if selectedFields[field.Name] {
|
||||
continue
|
||||
@@ -630,7 +629,7 @@ func (b *traceOperatorCTEBuilder) buildTimeSeriesQuery(ctx context.Context, sele
|
||||
int64(b.operator.StepInterval.Seconds()),
|
||||
))
|
||||
|
||||
info := b.queryInfo
|
||||
info := querybuilder.NewQueryInfo(ctx, b.orgID, b.stmtBuilder.fl, telemetrytypes.SignalTraces, nil, b.start, b.end)
|
||||
for _, gb := range b.operator.GroupBy {
|
||||
expr, err := querybuilder.ResolveColumn(ctx, info, b.stmtBuilder.storage, &gb.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
|
||||
if err != nil {
|
||||
@@ -648,7 +647,9 @@ func (b *traceOperatorCTEBuilder) buildTimeSeriesQuery(ctx context.Context, sele
|
||||
for i, agg := range b.operator.Aggregations {
|
||||
rewritten, chArgs, err := b.stmtBuilder.aggExprRewriter.Rewrite(
|
||||
ctx,
|
||||
b.queryInfo,
|
||||
b.orgID,
|
||||
b.start,
|
||||
b.end,
|
||||
agg.Expression,
|
||||
uint64(b.operator.StepInterval.Seconds()),
|
||||
keys,
|
||||
@@ -725,7 +726,7 @@ func (b *traceOperatorCTEBuilder) buildTraceQuery(ctx context.Context, selectFro
|
||||
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
|
||||
info := b.queryInfo
|
||||
info := querybuilder.NewQueryInfo(ctx, b.orgID, b.stmtBuilder.fl, telemetrytypes.SignalTraces, nil, b.start, b.end)
|
||||
for _, gb := range b.operator.GroupBy {
|
||||
expr, err := querybuilder.ResolveColumn(ctx, info, b.stmtBuilder.storage, &gb.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
|
||||
if err != nil {
|
||||
@@ -745,7 +746,9 @@ func (b *traceOperatorCTEBuilder) buildTraceQuery(ctx context.Context, selectFro
|
||||
for i, agg := range b.operator.Aggregations {
|
||||
rewritten, chArgs, err := b.stmtBuilder.aggExprRewriter.Rewrite(
|
||||
ctx,
|
||||
b.queryInfo,
|
||||
b.orgID,
|
||||
b.start,
|
||||
b.end,
|
||||
agg.Expression,
|
||||
rateInterval,
|
||||
keys,
|
||||
@@ -850,7 +853,7 @@ func (b *traceOperatorCTEBuilder) buildTraceQuery(ctx context.Context, selectFro
|
||||
func (b *traceOperatorCTEBuilder) buildScalarQuery(ctx context.Context, selectFromCTE string, keys map[string][]*telemetrytypes.TelemetryFieldKey) (*qbtypes.Statement, error) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
|
||||
info := b.queryInfo
|
||||
info := querybuilder.NewQueryInfo(ctx, b.orgID, b.stmtBuilder.fl, telemetrytypes.SignalTraces, nil, b.start, b.end)
|
||||
for _, gb := range b.operator.GroupBy {
|
||||
expr, err := querybuilder.ResolveColumn(ctx, info, b.stmtBuilder.storage, &gb.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
|
||||
if err != nil {
|
||||
@@ -868,7 +871,9 @@ func (b *traceOperatorCTEBuilder) buildScalarQuery(ctx context.Context, selectFr
|
||||
for i, agg := range b.operator.Aggregations {
|
||||
rewritten, chArgs, err := b.stmtBuilder.aggExprRewriter.Rewrite(
|
||||
ctx,
|
||||
b.queryInfo,
|
||||
b.orgID,
|
||||
b.start,
|
||||
b.end,
|
||||
agg.Expression,
|
||||
uint64((b.end-b.start)/querybuilder.NsToSeconds),
|
||||
keys,
|
||||
|
||||
@@ -23,7 +23,7 @@ func newTestTraceOperatorStatementBuilder(t *testing.T) *traceOperatorStatementB
|
||||
storage := tracestelemetryschema.NewStorage()
|
||||
mockMetadataStore := telemetrytypestest.NewMockMetadataStore()
|
||||
mockMetadataStore.KeysMap = tracestelemetryschema.BuildCompleteFieldKeyMap(releaseTime)
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage)
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage, fl, telemetrytypes.SignalTraces)
|
||||
traceStmtBuilder := NewTraceQueryStatementBuilder(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
mockMetadataStore, storage, aggExprRewriter, nil, fl, false, 100000,
|
||||
|
||||
@@ -42,7 +42,7 @@ func NewOperatorFactory(
|
||||
factory.MustNewName("traceoperator"),
|
||||
func(_ context.Context, settings factory.ProviderSettings, cfg statementbuilder.Config) (qbtypes.TraceOperatorStatementBuilder, error) {
|
||||
storage := tracestelemetryschema.NewStorage()
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(settings, nil, storage)
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(settings, nil, storage, fl, telemetrytypes.SignalTraces)
|
||||
traceStmtBuilder := NewTraceQueryStatementBuilder(
|
||||
settings, metadataStore, storage, aggExprRewriter, telemetryStore, fl,
|
||||
cfg.SkipResourceFingerprint.Enabled, cfg.SkipResourceFingerprint.Threshold,
|
||||
@@ -116,17 +116,11 @@ func (b *traceOperatorStatementBuilder) Build(
|
||||
slog.String("expression", query.Expression),
|
||||
slog.Any("request_type", requestType))
|
||||
|
||||
info, err := querybuilder.LoadColumnEvolutions(ctx, querybuilder.NewQueryInfo(ctx, orgID, b.fl, telemetrytypes.SignalTraces, nil, start, end), b.metadataStore)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Build the CTE-based query
|
||||
builder := &traceOperatorCTEBuilder{
|
||||
start: start,
|
||||
end: end,
|
||||
orgID: orgID,
|
||||
queryInfo: info,
|
||||
operator: &query,
|
||||
stmtBuilder: b,
|
||||
queries: make(map[string]*qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]),
|
||||
|
||||
@@ -39,7 +39,7 @@ func TestTraceTimeRangeOptimization(t *testing.T) {
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
}}
|
||||
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage)
|
||||
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage, fl, telemetrytypes.SignalTraces)
|
||||
|
||||
statementBuilder := NewTraceQueryStatementBuilder(
|
||||
instrumentationtest.New().ToProviderSettings(),
|
||||
|
||||
@@ -2430,16 +2430,6 @@ func (k *telemetryMetaStore) fetchEvolutionEntryFromClickHouse(ctx context.Conte
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func (k *telemetryMetaStore) GetColumnEvolutions(ctx context.Context, signal telemetrytypes.Signal, fieldContext telemetrytypes.FieldContext) ([]*telemetrytypes.EvolutionEntry, error) {
|
||||
ctx = withTelemetryContext(ctx, signal, "GetColumnEvolutions")
|
||||
selector := &telemetrytypes.EvolutionSelector{Signal: signal, FieldContext: fieldContext, FieldName: "__all__"}
|
||||
evolutions, err := k.fetchEvolutionEntryFromClickHouse(ctx, []*telemetrytypes.EvolutionSelector{selector})
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, errors.CodeInternal, "failed to fetch column evolutions")
|
||||
}
|
||||
return evolutions, nil
|
||||
}
|
||||
|
||||
// updateColumnEvolutionMetadataForKeys updates the evolution field for keys.
|
||||
func (k *telemetryMetaStore) updateColumnEvolutionMetadataForKeys(ctx context.Context, keysToUpdate []*telemetrytypes.TelemetryFieldKey) error {
|
||||
// an empty selector list would run the evolution query without a filter
|
||||
|
||||
@@ -531,8 +531,7 @@ func foldAbsentJSONReadToTypeDefault(key *telemetrytypes.TelemetryFieldKey, oper
|
||||
// real column is that column. A span or trace context is kept as written,
|
||||
// and corrects to the attribute maps when it names no column. A strict
|
||||
// context synthesizes its type variants under the stripped and the literal
|
||||
// spelling. A synthesized attribute key reads through the column-wide
|
||||
// evolutions of the query, so it follows the JSON rollout like a reported one.
|
||||
// spelling.
|
||||
func (m *storage) Fallback(ctx context.Context, q qbtypes.QueryInfo, key *telemetrytypes.TelemetryFieldKey, _ qbtypes.FilterOperator, value any) ([]*telemetrytypes.LogicalField, error) {
|
||||
var keys []*telemetrytypes.TelemetryFieldKey
|
||||
switch key.FieldContext {
|
||||
@@ -561,29 +560,9 @@ func (m *storage) Fallback(ctx context.Context, q qbtypes.QueryInfo, key *teleme
|
||||
literal := telemetrytypes.NewTelemetryFieldKey(key.FieldContext.StringValue()+"."+key.Name, key.FieldContext, key.FieldDataType)
|
||||
keys = append(querybuilder.SynthesizeKeys(key, value), querybuilder.SynthesizeKeys(literal, value)...)
|
||||
}
|
||||
stampAttributeEvolutions(q, keys)
|
||||
return querybuilder.WrapAsLogicalFields(key.Name, keys), nil
|
||||
}
|
||||
|
||||
// stampAttributeEvolutions gives every synthesized attribute key without
|
||||
// evolutions the query's column-wide attribute entries.
|
||||
func stampAttributeEvolutions(q qbtypes.QueryInfo, keys []*telemetrytypes.TelemetryFieldKey) {
|
||||
var evolutions []*telemetrytypes.EvolutionEntry
|
||||
for _, e := range q.Evolutions {
|
||||
if e != nil && e.FieldContext == telemetrytypes.FieldContextAttribute {
|
||||
evolutions = append(evolutions, e)
|
||||
}
|
||||
}
|
||||
if len(evolutions) == 0 {
|
||||
return
|
||||
}
|
||||
for _, key := range keys {
|
||||
if key.FieldContext == telemetrytypes.FieldContextAttribute && len(key.Evolutions) == 0 {
|
||||
key.Evolutions = evolutions
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// stampColumnType gives an untyped column key the data type its column reads
|
||||
// as, so the intrinsic-column step can compare it with same-named metadata.
|
||||
func stampColumnType(key *telemetrytypes.TelemetryFieldKey, columns []*schema.Column) *telemetrytypes.TelemetryFieldKey {
|
||||
|
||||
@@ -527,81 +527,3 @@ func TestConditionForAttributeJSONStraddleAbsentKeyExclusion(t *testing.T) {
|
||||
assert.Contains(t, sql, ", NULL) IS NULL")
|
||||
})
|
||||
}
|
||||
|
||||
// TestConditionForAttributeJSONUnknownKey: a key metadata does not report follows the rollout like
|
||||
// a reported one, through the query's column-wide evolutions; without them it stays on the Map.
|
||||
func TestConditionForAttributeJSONUnknownKey(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
storage := NewStorage()
|
||||
evo := MockAttributeEvolutionData(attrJSONRelease)
|
||||
|
||||
build := func(t *testing.T, q qbtypes.QueryInfo, key telemetrytypes.TelemetryFieldKey, op qbtypes.FilterOperator, value any) string {
|
||||
t.Helper()
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
conds, _, err := querybuilder.Conditions(ctx, q, storage, &key, op, value, map[string][]*telemetrytypes.TelemetryFieldKey{}, false, sb)
|
||||
require.NoError(t, err)
|
||||
sb.Where(conds...)
|
||||
sql, _ := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
return sql
|
||||
}
|
||||
query := func(window [2]uint64, evolutions []*telemetrytypes.EvolutionEntry) qbtypes.QueryInfo {
|
||||
return qbtypes.QueryInfo{StartNs: window[0], EndNs: window[1], TraceAttrsJSONOn: true, Evolutions: evolutions}
|
||||
}
|
||||
bare := telemetrytypes.TelemetryFieldKey{Name: "unknown.attr"}
|
||||
|
||||
t.Run("before -> map", func(t *testing.T) {
|
||||
sql := build(t, query(attrWindowBefore, evo), bare, qbtypes.FilterOperatorEqual, "x")
|
||||
assert.Contains(t, sql, "attributes_string['unknown.attr'] = ?")
|
||||
assert.NotContains(t, sql, "attributes.`unknown.attr`")
|
||||
})
|
||||
t.Run("after -> json", func(t *testing.T) {
|
||||
sql := build(t, query(attrWindowAfter, evo), bare, qbtypes.FilterOperatorEqual, "x")
|
||||
assert.Contains(t, sql, "attributes.`unknown.attr`::String = ?")
|
||||
assert.NotContains(t, sql, "attributes_string")
|
||||
})
|
||||
t.Run("straddle -> dual", func(t *testing.T) {
|
||||
sql := build(t, query(attrWindowStraddle, evo), bare, qbtypes.FilterOperatorEqual, "x")
|
||||
assert.Contains(t, sql, "multiIf(attributes.`unknown.attr` IS NOT NULL, attributes.`unknown.attr`::String, mapContains(attributes_string, 'unknown.attr'), attributes_string['unknown.attr'], NULL) = ?")
|
||||
})
|
||||
t.Run("after number -> type-gated json", func(t *testing.T) {
|
||||
sql := build(t, query(attrWindowAfter, evo), bare, qbtypes.FilterOperatorEqual, float64(1))
|
||||
assert.Contains(t, sql, "accurateCastOrNull(attributes.`unknown.attr`, 'Float64')")
|
||||
assert.NotContains(t, sql, "attributes_number")
|
||||
})
|
||||
t.Run("after exists fans out over json only", func(t *testing.T) {
|
||||
sql := build(t, query(attrWindowAfter, evo), bare, qbtypes.FilterOperatorExists, nil)
|
||||
assert.Contains(t, sql, "attributes.`unknown.attr` IS NOT NULL")
|
||||
assert.NotContains(t, sql, "mapContains")
|
||||
})
|
||||
t.Run("after qualified attribute spelling -> json for both spellings", func(t *testing.T) {
|
||||
qualified := telemetrytypes.TelemetryFieldKey{Name: "unknown.attr", FieldContext: telemetrytypes.FieldContextAttribute}
|
||||
sql := build(t, query(attrWindowAfter, evo), qualified, qbtypes.FilterOperatorEqual, "x")
|
||||
assert.Contains(t, sql, "attributes.`unknown.attr`::String = ?")
|
||||
assert.Contains(t, sql, "attributes.`attribute.unknown.attr`::String = ?")
|
||||
assert.NotContains(t, sql, "attributes_string")
|
||||
})
|
||||
t.Run("after span spelling corrects to the json attribute", func(t *testing.T) {
|
||||
span := telemetrytypes.TelemetryFieldKey{Name: "unknown.attr", FieldContext: telemetrytypes.FieldContextSpan}
|
||||
sql := build(t, query(attrWindowAfter, evo), span, qbtypes.FilterOperatorEqual, "x")
|
||||
assert.Contains(t, sql, "attributes.`unknown.attr`::String = ?")
|
||||
assert.NotContains(t, sql, "attributes_string")
|
||||
})
|
||||
t.Run("without column-wide evolutions -> map in every window", func(t *testing.T) {
|
||||
sql := build(t, query(attrWindowAfter, nil), bare, qbtypes.FilterOperatorEqual, "x")
|
||||
assert.Contains(t, sql, "attributes_string['unknown.attr'] = ?")
|
||||
assert.NotContains(t, sql, "attributes.`unknown.attr`")
|
||||
})
|
||||
t.Run("flag off -> map in every window", func(t *testing.T) {
|
||||
q := query(attrWindowAfter, evo)
|
||||
q.TraceAttrsJSONOn = false
|
||||
sql := build(t, q, bare, qbtypes.FilterOperatorEqual, "x")
|
||||
assert.Contains(t, sql, "attributes_string['unknown.attr'] = ?")
|
||||
assert.NotContains(t, sql, "attributes.`unknown.attr`")
|
||||
})
|
||||
t.Run("resource spelling is untouched by attribute evolutions", func(t *testing.T) {
|
||||
resource := telemetrytypes.TelemetryFieldKey{Name: "unknown.attr", FieldContext: telemetrytypes.FieldContextResource}
|
||||
sql := build(t, query(attrWindowAfter, evo), resource, qbtypes.FilterOperatorEqual, "x")
|
||||
assert.Contains(t, sql, "resource.`unknown.attr`::String")
|
||||
assert.Contains(t, sql, "resources_string['unknown.attr']")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -123,10 +123,22 @@ func (d *DashboardSpec) validatePanels() error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// validatePanelQueryCount left exactly one query for a spec that reads it.
|
||||
if spec, ok := panel.Spec.Plugin.Spec.(queryBoundPanelSpec); ok {
|
||||
if err := spec.validateAgainstQuery(panel.Spec.Queries[0], path+".spec.plugin.spec"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// queryBoundPanelSpec is a panel spec with fields that depend on the panel's
|
||||
// query. It is checked after the query itself validates.
|
||||
type queryBoundPanelSpec interface {
|
||||
validateAgainstQuery(query Query, path string) error
|
||||
}
|
||||
|
||||
func validatePanelQueryCount(queries []Query, panelKind PanelPluginKind, path string) error {
|
||||
if queries == nil {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s.spec.queries: is required and must not be null; use [] for a panel that renders without a query", path)
|
||||
|
||||
@@ -1870,6 +1870,319 @@ func TestInvalidateAreaChartPanelSpecValues(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestScatterPlotPanelDefaults(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {
|
||||
"kind": "signoz/ScatterPlotPanel",
|
||||
"spec": {}
|
||||
},
|
||||
"queries": [{"kind": "scalar", "spec": {"plugin": {"kind": "signoz/ClickHouseSQL", "spec": {"name": "A", "query": "SELECT 1"}}}}]
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`)
|
||||
d, err := unmarshalDashboard(data)
|
||||
require.NoError(t, err, "unmarshal and validate failed")
|
||||
|
||||
require.IsType(t, &ScatterPlotPanelSpec{}, d.Panels["p1"].Spec.Plugin.Spec)
|
||||
spec := d.Panels["p1"].Spec.Plugin.Spec.(*ScatterPlotPanelSpec)
|
||||
|
||||
assert.Empty(t, spec.Dimensions.X, "an omitted x stays empty so the renderer picks the first value column")
|
||||
assert.Empty(t, spec.Dimensions.Y, "an omitted y stays empty so the renderer picks the second value column")
|
||||
assert.Empty(t, spec.Dimensions.ColorBy, "an omitted colorBy stays empty so the renderer colours by every group-by label")
|
||||
assert.Equal(t, "auto", spec.Axes.X.Scale.ValueOrDefault(), "expected x scale default auto")
|
||||
assert.Equal(t, "auto", spec.Axes.Y.Scale.ValueOrDefault(), "expected y scale default auto")
|
||||
assert.Nil(t, spec.Axes.X.SoftMin, "expected x softMin unset")
|
||||
assert.Equal(t, "2", spec.Formatting.DecimalPrecision.ValueOrDefault(), "expected DecimalPrecision default 2")
|
||||
assert.Equal(t, "global_time", spec.Visualization.TimePreference.ValueOrDefault(), "expected TimePreference default global_time")
|
||||
assert.Equal(t, "bottom", spec.Legend.Position.ValueOrDefault(), "expected LegendPosition default bottom")
|
||||
|
||||
output, err := json.Marshal(d)
|
||||
require.NoError(t, err, "marshal dashboard failed")
|
||||
assert.Contains(t, string(output), `"axes":{"x":{"softMin":null,"softMax":null,"scale":"auto","label":""},"y":{"softMin":null,"softMax":null,"scale":"auto","label":""}}`, "expected default axes in stored/response JSON")
|
||||
assert.Contains(t, string(output), `"dimensions":{"x":"","y":"","sizeBy":"","colorBy":null}`, "expected empty dimensions in stored/response JSON")
|
||||
assert.Contains(t, string(output), `"chartAppearance":{"points":{"size":null,"minSize":null,"maxSize":null,"opacity":null}}`, "omitted point settings stay null so the renderer applies its defaults")
|
||||
}
|
||||
|
||||
func TestValidateScatterPlotDashboard(t *testing.T) {
|
||||
data, err := os.ReadFile("testdata/perses_scatter_plot_panel.json")
|
||||
require.NoError(t, err, "reading example file")
|
||||
|
||||
spec, err := unmarshalDashboard(data)
|
||||
require.NoError(t, err, "unmarshal and validate failed")
|
||||
|
||||
require.IsType(t, &ScatterPlotPanelSpec{}, spec.Panels["p1"].Spec.Plugin.Spec)
|
||||
panelSpec := spec.Panels["p1"].Spec.Plugin.Spec.(*ScatterPlotPanelSpec)
|
||||
softMin, softMax := 0.0, 1000.0
|
||||
size, minSize, maxSize, opacity := PointDiameter(8), PointDiameter(4), PointDiameter(24), PointOpacity(0.1)
|
||||
assert.Equal(t, &ScatterPlotPanelSpec{
|
||||
Visualization: BasicVisualization{TimePreference: TimePreferenceLast1Hr},
|
||||
Dimensions: ScatterPlotDimensions{X: "A.count()", Y: "A.p99(duration_nano)", SizeBy: "A.countIf(has_error = true)", ColorBy: []string{"k8s.namespace.name", "k8s.pod.name"}},
|
||||
Formatting: TableFormatting{ColumnUnits: map[string]string{"A.p99(duration_nano)": "ns"}, DecimalPrecision: PrecisionOption1},
|
||||
Axes: ScatterPlotAxes{
|
||||
X: ScatterPlotAxis{SoftMin: &softMin, Scale: AxisScaleLog, Label: "Throughput"},
|
||||
Y: ScatterPlotAxis{SoftMax: &softMax, Scale: AxisScaleSymlog, Label: "p99 latency"},
|
||||
},
|
||||
ChartAppearance: ScatterPlotChartAppearance{Points: ScatterPlotPoints{Size: &size, MinSize: &minSize, MaxSize: &maxSize, Opacity: &opacity}},
|
||||
Legend: Legend{Position: LegendPositionRight, Mode: LegendModeList, CustomColors: map[string]string{"checkout": "#1f77b4"}},
|
||||
Thresholds: []ScatterPlotThreshold{
|
||||
{ThresholdWithLabel: ThresholdWithLabel{Value: 300, Unit: "ms", Color: "#f00", Label: "p99 SLO"}, Axis: ThresholdAxisY},
|
||||
{ThresholdWithLabel: ThresholdWithLabel{Value: 50, Color: "#0f0"}, Axis: ThresholdAxisX},
|
||||
},
|
||||
}, panelSpec)
|
||||
|
||||
dashboard := &DashboardV2{Spec: *spec}
|
||||
request, err := dashboard.GetPanelQuery(1, 2, "p1")
|
||||
require.NoError(t, err, "building the panel's query failed")
|
||||
assert.Equal(t, qb.RequestTypeScalar, request.RequestType)
|
||||
require.NotNil(t, request.FormatOptions)
|
||||
assert.True(t, request.FormatOptions.FormatTableResultForUI, "scatter rows are joined the way a table's are")
|
||||
|
||||
require.Len(t, request.CompositeQuery.Queries, 1)
|
||||
query, ok := request.CompositeQuery.Queries[0].Spec.(qb.QueryBuilderQuery[qb.TraceAggregation])
|
||||
require.True(t, ok, "expected a traces builder query")
|
||||
assert.Len(t, query.Aggregations, 3)
|
||||
assert.Len(t, query.GroupBy, 2)
|
||||
|
||||
require.NoError(t, request.Validate(), "the request built from the panel is not a valid scalar request")
|
||||
|
||||
// the panel read back out of storage draws the same scatter plot
|
||||
stored, err := json.Marshal(spec)
|
||||
require.NoError(t, err, "marshal dashboard failed")
|
||||
reread, err := unmarshalDashboard(stored)
|
||||
require.NoError(t, err, "the stored dashboard does not validate")
|
||||
assert.Equal(t, panelSpec, reread.Panels["p1"].Spec.Plugin.Spec)
|
||||
rereadRequest, err := (&DashboardV2{Spec: *reread}).GetPanelQuery(1, 2, "p1")
|
||||
require.NoError(t, err, "building the stored panel's query failed")
|
||||
assert.Equal(t, request, rereadRequest)
|
||||
}
|
||||
|
||||
func TestInvalidateScatterPlotPanelSpecValues(t *testing.T) {
|
||||
tests := []struct {
|
||||
scenario string
|
||||
panelKind string
|
||||
panelSpec string
|
||||
expectedErrorSubstring string
|
||||
}{
|
||||
{
|
||||
scenario: "unknown axis scale",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"axes": {"x": {"scale": "sqrt"}}}`,
|
||||
expectedErrorSubstring: "invalid axis scale \"sqrt\": must be `auto`, `linear`, `log`, or `symlog`",
|
||||
},
|
||||
{
|
||||
scenario: "non-string axis scale",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"axes": {"y": {"scale": true}}}`,
|
||||
expectedErrorSubstring: "cannot unmarshal bool",
|
||||
},
|
||||
{
|
||||
scenario: "isLogScale on a scatter plot axis",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"axes": {"x": {"isLogScale": true}}}`,
|
||||
expectedErrorSubstring: "unknown field",
|
||||
},
|
||||
{
|
||||
scenario: "single-axis axes shape on a scatter plot panel",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"axes": {"softMin": 0}}`,
|
||||
expectedErrorSubstring: "unknown field",
|
||||
},
|
||||
{
|
||||
scenario: "unknown threshold axis",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"thresholds": [{"value": 1, "color": "#f00", "axis": "z"}]}`,
|
||||
expectedErrorSubstring: "invalid threshold axis \"z\": must be `x` or `y`",
|
||||
},
|
||||
{
|
||||
scenario: "unknown dimension",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"dimensions": {"z": "A"}}`,
|
||||
expectedErrorSubstring: "unknown field",
|
||||
},
|
||||
{
|
||||
scenario: "point size below the minimum",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"chartAppearance": {"points": {"size": 1}}}`,
|
||||
expectedErrorSubstring: "invalid point diameter 1: must be a whole number between 2 and 40",
|
||||
},
|
||||
{
|
||||
scenario: "max point size above the maximum",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"chartAppearance": {"points": {"maxSize": 41}}}`,
|
||||
expectedErrorSubstring: "invalid point diameter 41",
|
||||
},
|
||||
{
|
||||
scenario: "fractional point size",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"chartAppearance": {"points": {"minSize": 4.5}}}`,
|
||||
expectedErrorSubstring: "cannot unmarshal number 4.5",
|
||||
},
|
||||
{
|
||||
scenario: "min point size above max",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"chartAppearance": {"points": {"minSize": 20, "maxSize": 10}}}`,
|
||||
expectedErrorSubstring: "minSize 20 must not exceed maxSize 10",
|
||||
},
|
||||
{
|
||||
scenario: "point opacity below the floor",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"chartAppearance": {"points": {"opacity": 0.05}}}`,
|
||||
expectedErrorSubstring: "invalid point opacity 0.05: must be between 0.1 and 1",
|
||||
},
|
||||
{
|
||||
scenario: "point opacity as a percentage",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"chartAppearance": {"points": {"opacity": 70}}}`,
|
||||
expectedErrorSubstring: "invalid point opacity 70",
|
||||
},
|
||||
{
|
||||
scenario: "area fill fields on a scatter plot",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"chartAppearance": {"fillOpacity": 0.5}}`,
|
||||
expectedErrorSubstring: "unknown field",
|
||||
},
|
||||
{
|
||||
scenario: "a single colour key as a string",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"dimensions": {"colorBy": "service.name"}}`,
|
||||
expectedErrorSubstring: "cannot unmarshal string",
|
||||
},
|
||||
{
|
||||
scenario: "panel-wide unit on a scatter plot panel",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"formatting": {"unit": "ms"}}`,
|
||||
expectedErrorSubstring: "unknown field",
|
||||
},
|
||||
{
|
||||
scenario: "threshold without a color",
|
||||
panelKind: "signoz/ScatterPlotPanel",
|
||||
panelSpec: `{"thresholds": [{"value": 300}]}`,
|
||||
expectedErrorSubstring: "Color' Error:Field validation for 'Color' failed on the 'required' tag",
|
||||
},
|
||||
{
|
||||
scenario: "scale on a time series panel",
|
||||
panelKind: "signoz/TimeSeriesPanel",
|
||||
panelSpec: `{"axes": {"scale": "log"}}`,
|
||||
expectedErrorSubstring: "unknown field",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.scenario, func(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {"kind": "` + test.panelKind + `", "spec": ` + test.panelSpec + `},
|
||||
"queries": [{"kind": "scalar", "spec": {"plugin": {"kind": "signoz/ClickHouseSQL", "spec": {"name": "A", "query": "SELECT 1"}}}}]
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`)
|
||||
_, err := unmarshalDashboard(data)
|
||||
require.Error(t, err, "expected the spec to be rejected")
|
||||
assert.Contains(t, err.Error(), test.expectedErrorSubstring, "unexpected error message: %s", err.Error())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestScatterPlotDimensionsBindToQuery(t *testing.T) {
|
||||
tests := []struct {
|
||||
scenario string
|
||||
queryPlugin string
|
||||
dimensions string
|
||||
expectedErrorSubstring string
|
||||
}{
|
||||
{
|
||||
scenario: "a single-aggregation query is one value column keyed by its name",
|
||||
queryPlugin: `{"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "traces", "aggregations": [{"expression": "count()"}], "groupBy": [{"name": "service.name"}]}}`,
|
||||
dimensions: `{"x": "A", "colorBy": ["service.name"]}`,
|
||||
},
|
||||
{
|
||||
scenario: "a multi-aggregation query keys each value column by name and expression",
|
||||
queryPlugin: `{"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "traces", "aggregations": [{"expression": "count()"}, {"expression": "p99(duration_nano)"}]}}`,
|
||||
dimensions: `{"x": "A"}`,
|
||||
expectedErrorSubstring: "dimensions.x: \"A\" is not a value column of the panel's query; value columns: `A.count()`, `A.p99(duration_nano)`",
|
||||
},
|
||||
{
|
||||
scenario: "a value column of a query the panel does not have",
|
||||
queryPlugin: `{"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "logs", "aggregations": [{"expression": "count()"}]}}`,
|
||||
dimensions: `{"y": "B"}`,
|
||||
expectedErrorSubstring: "dimensions.y: \"B\" is not a value column of the panel's query; value columns: `A`",
|
||||
},
|
||||
{
|
||||
scenario: "a colour key the query does not group by",
|
||||
queryPlugin: `{"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "traces", "aggregations": [{"expression": "count()"}], "groupBy": [{"name": "service.name"}]}}`,
|
||||
dimensions: `{"colorBy": ["service.name", "k8s.pod.name"]}`,
|
||||
expectedErrorSubstring: "dimensions.colorBy: \"k8s.pod.name\" is not a group-by key of the panel's query; group-by keys: `service.name`",
|
||||
},
|
||||
{
|
||||
scenario: "a formula is a value column and carries the labels of the disabled queries it reads",
|
||||
queryPlugin: `{"kind": "signoz/CompositeQuery", "spec": {"queries": [
|
||||
{"type": "builder_query", "spec": {"name": "A", "signal": "traces", "disabled": true, "aggregations": [{"expression": "count()"}], "groupBy": [{"name": "service.name"}]}},
|
||||
{"type": "builder_query", "spec": {"name": "B", "signal": "traces", "disabled": true, "aggregations": [{"expression": "countIf(has_error = true)"}], "groupBy": [{"name": "service.name"}]}},
|
||||
{"type": "builder_formula", "spec": {"name": "F1", "expression": "B / A"}}
|
||||
]}}`,
|
||||
dimensions: `{"x": "F1", "colorBy": ["service.name"]}`,
|
||||
},
|
||||
{
|
||||
scenario: "a disabled query yields no value column",
|
||||
queryPlugin: `{"kind": "signoz/CompositeQuery", "spec": {"queries": [
|
||||
{"type": "builder_query", "spec": {"name": "A", "signal": "logs", "disabled": true, "aggregations": [{"expression": "count()"}]}},
|
||||
{"type": "builder_formula", "spec": {"name": "F1", "expression": "A * 2"}}
|
||||
]}}`,
|
||||
dimensions: `{"sizeBy": "A"}`,
|
||||
expectedErrorSubstring: "dimensions.sizeBy: \"A\" is not a value column of the panel's query; value columns: `F1`",
|
||||
},
|
||||
{
|
||||
scenario: "clickhouse columns are only known at run time, so the bindings go unchecked",
|
||||
queryPlugin: `{"kind": "signoz/ClickHouseSQL", "spec": {"name": "A", "query": "SELECT 1"}}`,
|
||||
dimensions: `{"x": "throughput", "colorBy": ["pod"]}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.scenario, func(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {"kind": "signoz/ScatterPlotPanel", "spec": {"dimensions": ` + test.dimensions + `}},
|
||||
"queries": [{"kind": "scalar", "spec": {"plugin": ` + test.queryPlugin + `}}]
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`)
|
||||
_, err := unmarshalDashboard(data)
|
||||
if test.expectedErrorSubstring == "" {
|
||||
require.NoError(t, err)
|
||||
return
|
||||
}
|
||||
require.Error(t, err, "expected the binding to be rejected")
|
||||
assert.Contains(t, err.Error(), test.expectedErrorSubstring, "unexpected error message: %s", err.Error())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNumberPanelDefaults(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"variables": [],
|
||||
@@ -2228,7 +2541,7 @@ func TestPanelTypeQueryTypeCompatibility(t *testing.T) {
|
||||
switch panelKind {
|
||||
case "signoz/ListPanel":
|
||||
return "raw"
|
||||
case "signoz/TablePanel", "signoz/NumberPanel", "signoz/PieChartPanel", "signoz/HistogramPanel":
|
||||
case "signoz/TablePanel", "signoz/NumberPanel", "signoz/PieChartPanel", "signoz/HistogramPanel", "signoz/ScatterPlotPanel":
|
||||
return "scalar"
|
||||
default:
|
||||
return "time_series"
|
||||
@@ -2272,14 +2585,17 @@ func TestPanelTypeQueryTypeCompatibility(t *testing.T) {
|
||||
{"List+Builder", mkQuery("signoz/ListPanel", "signoz/BuilderQuery", `{"name":"A","signal":"logs"}`), false},
|
||||
{"TimeSeries+AIBuilder", mkQuery("signoz/TimeSeriesPanel", "signoz/AIBuilderQuery", `{"name":"A","aggregations":[{"expression":"count()"}]}`), false},
|
||||
{"List+AIBuilder", mkQuery("signoz/ListPanel", "signoz/AIBuilderQuery", `{"name":"A"}`), false},
|
||||
{"ScatterPlot+ClickHouse", mkQuery("signoz/ScatterPlotPanel", "signoz/ClickHouseSQL", `{"name":"A","query":"SELECT 1"}`), false},
|
||||
// Top-level: rejected
|
||||
{"Table+PromQL", mkQuery("signoz/TablePanel", "signoz/PromQLQuery", `{"name":"A","query":"up"}`), true},
|
||||
{"ScatterPlot+PromQL", mkQuery("signoz/ScatterPlotPanel", "signoz/PromQLQuery", `{"name":"A","query":"up"}`), true},
|
||||
{"List+ClickHouse", mkQuery("signoz/ListPanel", "signoz/ClickHouseSQL", `{"name":"A","query":"SELECT 1"}`), true},
|
||||
{"List+PromQL", mkQuery("signoz/ListPanel", "signoz/PromQLQuery", `{"name":"A","query":"up"}`), true},
|
||||
{"List+Composite", mkQuery("signoz/ListPanel", "signoz/CompositeQuery", `{"queries":[]}`), true},
|
||||
{"List+Formula", mkQuery("signoz/ListPanel", "signoz/Formula", `{"name":"F1","expression":"A+B"}`), true},
|
||||
// Composite sub-queries
|
||||
{"Table+Composite(promql)", mkComposite("signoz/TablePanel", "promql", `{"name":"A","query":"up"}`), true},
|
||||
{"ScatterPlot+Composite(promql)", mkComposite("signoz/ScatterPlotPanel", "promql", `{"name":"A","query":"up"}`), true},
|
||||
{"Table+Composite(clickhouse)", mkComposite("signoz/TablePanel", "clickhouse_sql", `{"name":"A","query":"SELECT 1"}`), false},
|
||||
{"Table+Composite(builder_ai)", mkComposite("signoz/TablePanel", "builder_ai_query", `{"name":"A","aggregations":[{"expression":"count()"}]}`), false},
|
||||
}
|
||||
|
||||
@@ -28,16 +28,17 @@ type PanelPlugin struct {
|
||||
// (and strips the duplicate parent properties) after reflection.
|
||||
func (PanelPlugin) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
return markDiscriminator(s, "kind", map[string]string{
|
||||
string(PanelKindTimeSeries): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec"),
|
||||
string(PanelKindBarChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec"),
|
||||
string(PanelKindAreaChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec"),
|
||||
string(PanelKindNumber): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec"),
|
||||
string(PanelKindPieChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec"),
|
||||
string(PanelKindTable): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec"),
|
||||
string(PanelKindHistogram): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec"),
|
||||
string(PanelKindList): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec"),
|
||||
string(PanelKindText): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec"),
|
||||
string(PanelKindHeatmap): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec"),
|
||||
string(PanelKindTimeSeries): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec"),
|
||||
string(PanelKindBarChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec"),
|
||||
string(PanelKindAreaChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec"),
|
||||
string(PanelKindNumber): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec"),
|
||||
string(PanelKindPieChart): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec"),
|
||||
string(PanelKindTable): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec"),
|
||||
string(PanelKindHistogram): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec"),
|
||||
string(PanelKindList): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec"),
|
||||
string(PanelKindText): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec"),
|
||||
string(PanelKindHeatmap): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec"),
|
||||
string(PanelKindScatterPlot): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -71,6 +72,7 @@ func (PanelPlugin) JSONSchemaOneOf() []any {
|
||||
PanelPluginVariant[ListPanelSpec]{Kind: string(PanelKindList)},
|
||||
PanelPluginVariant[TextPanelSpec]{Kind: string(PanelKindText)},
|
||||
PanelPluginVariant[HeatmapPanelSpec]{Kind: string(PanelKindHeatmap)},
|
||||
PanelPluginVariant[ScatterPlotPanelSpec]{Kind: string(PanelKindScatterPlot)},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,16 +236,17 @@ func (v VariablePluginVariant[S]) PrepareJSONSchema(s *jsonschema.Schema) error
|
||||
|
||||
var (
|
||||
panelPluginSpecs = map[PanelPluginKind]func() any{
|
||||
PanelKindTimeSeries: func() any { return new(TimeSeriesPanelSpec) },
|
||||
PanelKindBarChart: func() any { return new(BarChartPanelSpec) },
|
||||
PanelKindAreaChart: func() any { return new(AreaChartPanelSpec) },
|
||||
PanelKindNumber: func() any { return new(NumberPanelSpec) },
|
||||
PanelKindPieChart: func() any { return new(PieChartPanelSpec) },
|
||||
PanelKindTable: func() any { return new(TablePanelSpec) },
|
||||
PanelKindHistogram: func() any { return new(HistogramPanelSpec) },
|
||||
PanelKindList: func() any { return new(ListPanelSpec) },
|
||||
PanelKindText: func() any { return new(TextPanelSpec) },
|
||||
PanelKindHeatmap: func() any { return new(HeatmapPanelSpec) },
|
||||
PanelKindTimeSeries: func() any { return new(TimeSeriesPanelSpec) },
|
||||
PanelKindBarChart: func() any { return new(BarChartPanelSpec) },
|
||||
PanelKindAreaChart: func() any { return new(AreaChartPanelSpec) },
|
||||
PanelKindNumber: func() any { return new(NumberPanelSpec) },
|
||||
PanelKindPieChart: func() any { return new(PieChartPanelSpec) },
|
||||
PanelKindTable: func() any { return new(TablePanelSpec) },
|
||||
PanelKindHistogram: func() any { return new(HistogramPanelSpec) },
|
||||
PanelKindList: func() any { return new(ListPanelSpec) },
|
||||
PanelKindText: func() any { return new(TextPanelSpec) },
|
||||
PanelKindHeatmap: func() any { return new(HeatmapPanelSpec) },
|
||||
PanelKindScatterPlot: func() any { return new(ScatterPlotPanelSpec) },
|
||||
}
|
||||
queryPluginSpecs = map[QueryPluginKind]func() any{
|
||||
QueryKindBuilder: func() any { return new(BuilderQuerySpec) },
|
||||
@@ -260,16 +263,17 @@ var (
|
||||
VariableKindCustom: func() any { return new(CustomVariableSpec) },
|
||||
}
|
||||
allowedQueryKinds = map[PanelPluginKind][]QueryPluginKind{
|
||||
PanelKindTimeSeries: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindBarChart: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindAreaChart: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindNumber: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindHistogram: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindPieChart: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
|
||||
PanelKindTable: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
|
||||
PanelKindList: {QueryKindBuilder, QueryKindAIBuilder},
|
||||
PanelKindText: {},
|
||||
PanelKindHeatmap: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindTimeSeries: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindBarChart: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindAreaChart: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindNumber: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindHistogram: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindPieChart: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
|
||||
PanelKindTable: {QueryKindBuilder, QueryKindAIBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
|
||||
PanelKindList: {QueryKindBuilder, QueryKindAIBuilder},
|
||||
PanelKindText: {},
|
||||
PanelKindHeatmap: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindPromQL, QueryKindClickHouseSQL},
|
||||
PanelKindScatterPlot: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -221,7 +221,7 @@ func (d *DashboardV2) GetPanelQuery(startTime, endTime uint64, panelKey string)
|
||||
CompositeQuery: composite,
|
||||
FormatOptions: &qb.FormatOptions{
|
||||
FillGaps: fillGaps,
|
||||
FormatTableResultForUI: panel.Spec.Plugin.Kind == PanelKindTable,
|
||||
FormatTableResultForUI: panel.Spec.Plugin.Kind == PanelKindTable || panel.Spec.Plugin.Kind == PanelKindScatterPlot,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -251,34 +251,38 @@ func TestDashboardV2GetPanelQuery(t *testing.T) {
|
||||
assert.Equal(t, telemetrytypes.SignalTraces, spec.Signal)
|
||||
})
|
||||
|
||||
t.Run("sets FormatTableResultForUI only for table panels", func(t *testing.T) {
|
||||
dashboard := &DashboardV2{
|
||||
Spec: DashboardSpec{
|
||||
Panels: map[string]*Panel{
|
||||
"panel-1": {
|
||||
Spec: PanelSpec{
|
||||
Plugin: PanelPlugin{Kind: PanelKindTable},
|
||||
Queries: []Query{
|
||||
{
|
||||
Kind: qb.RequestTypeScalar,
|
||||
Spec: QuerySpec{
|
||||
Plugin: QueryPlugin{
|
||||
Kind: QueryKindBuilder,
|
||||
Spec: &BuilderQuerySpec{Spec: qb.QueryBuilderQuery[qb.MetricAggregation]{Name: "A"}},
|
||||
t.Run("sets FormatTableResultForUI for panels that join scalar rows", func(t *testing.T) {
|
||||
for _, kind := range []PanelPluginKind{PanelKindTable, PanelKindScatterPlot} {
|
||||
t.Run(string(kind), func(t *testing.T) {
|
||||
dashboard := &DashboardV2{
|
||||
Spec: DashboardSpec{
|
||||
Panels: map[string]*Panel{
|
||||
"panel-1": {
|
||||
Spec: PanelSpec{
|
||||
Plugin: PanelPlugin{Kind: kind},
|
||||
Queries: []Query{
|
||||
{
|
||||
Kind: qb.RequestTypeScalar,
|
||||
Spec: QuerySpec{
|
||||
Plugin: QueryPlugin{
|
||||
Kind: QueryKindBuilder,
|
||||
Spec: &BuilderQuerySpec{Spec: qb.QueryBuilderQuery[qb.MetricAggregation]{Name: "A"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
req, err := dashboard.GetPanelQuery(1, 2, "panel-1")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, req.FormatOptions)
|
||||
assert.True(t, req.FormatOptions.FormatTableResultForUI)
|
||||
req, err := dashboard.GetPanelQuery(1, 2, "panel-1")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, req.FormatOptions)
|
||||
assert.True(t, req.FormatOptions.FormatTableResultForUI)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("sets FillGaps from the panel visualization", func(t *testing.T) {
|
||||
|
||||
@@ -2,6 +2,8 @@ package dashboardtypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"maps"
|
||||
"slices"
|
||||
"strconv"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
@@ -187,20 +189,21 @@ func (AIBuilderQuerySpec) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
type PanelPluginKind string
|
||||
|
||||
const (
|
||||
PanelKindTimeSeries PanelPluginKind = "signoz/TimeSeriesPanel"
|
||||
PanelKindBarChart PanelPluginKind = "signoz/BarChartPanel"
|
||||
PanelKindAreaChart PanelPluginKind = "signoz/AreaChartPanel"
|
||||
PanelKindNumber PanelPluginKind = "signoz/NumberPanel"
|
||||
PanelKindPieChart PanelPluginKind = "signoz/PieChartPanel"
|
||||
PanelKindTable PanelPluginKind = "signoz/TablePanel"
|
||||
PanelKindHistogram PanelPluginKind = "signoz/HistogramPanel"
|
||||
PanelKindList PanelPluginKind = "signoz/ListPanel"
|
||||
PanelKindText PanelPluginKind = "signoz/TextPanel"
|
||||
PanelKindHeatmap PanelPluginKind = "signoz/HeatmapPanel"
|
||||
PanelKindTimeSeries PanelPluginKind = "signoz/TimeSeriesPanel"
|
||||
PanelKindBarChart PanelPluginKind = "signoz/BarChartPanel"
|
||||
PanelKindAreaChart PanelPluginKind = "signoz/AreaChartPanel"
|
||||
PanelKindNumber PanelPluginKind = "signoz/NumberPanel"
|
||||
PanelKindPieChart PanelPluginKind = "signoz/PieChartPanel"
|
||||
PanelKindTable PanelPluginKind = "signoz/TablePanel"
|
||||
PanelKindHistogram PanelPluginKind = "signoz/HistogramPanel"
|
||||
PanelKindList PanelPluginKind = "signoz/ListPanel"
|
||||
PanelKindText PanelPluginKind = "signoz/TextPanel"
|
||||
PanelKindHeatmap PanelPluginKind = "signoz/HeatmapPanel"
|
||||
PanelKindScatterPlot PanelPluginKind = "signoz/ScatterPlotPanel"
|
||||
)
|
||||
|
||||
func (PanelPluginKind) Enum() []any {
|
||||
return []any{PanelKindTimeSeries, PanelKindBarChart, PanelKindAreaChart, PanelKindNumber, PanelKindPieChart, PanelKindTable, PanelKindHistogram, PanelKindList, PanelKindText, PanelKindHeatmap}
|
||||
return []any{PanelKindTimeSeries, PanelKindBarChart, PanelKindAreaChart, PanelKindNumber, PanelKindPieChart, PanelKindTable, PanelKindHistogram, PanelKindList, PanelKindText, PanelKindHeatmap, PanelKindScatterPlot}
|
||||
}
|
||||
|
||||
func (k PanelPluginKind) rendersWithoutQuery() bool {
|
||||
@@ -350,6 +353,95 @@ type TextPanelSpec struct {
|
||||
HeaderOptions HeaderOptions `json:"headerOptions"`
|
||||
}
|
||||
|
||||
type ScatterPlotPanelSpec struct {
|
||||
Visualization BasicVisualization `json:"visualization"`
|
||||
Dimensions ScatterPlotDimensions `json:"dimensions"`
|
||||
Formatting TableFormatting `json:"formatting"`
|
||||
Axes ScatterPlotAxes `json:"axes"`
|
||||
ChartAppearance ScatterPlotChartAppearance `json:"chartAppearance"`
|
||||
Legend Legend `json:"legend"`
|
||||
Thresholds []ScatterPlotThreshold `json:"thresholds" validate:"dive"`
|
||||
}
|
||||
|
||||
func (s *ScatterPlotPanelSpec) validateAgainstQuery(query Query, path string) error {
|
||||
composite, err := query.Spec.Plugin.buildV5CompositeQueryFromPlugin()
|
||||
if err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s.dimensions: %s", path, err.Error())
|
||||
}
|
||||
keys, known := composite.CollectResultColumnKeys()
|
||||
if !known {
|
||||
return nil
|
||||
}
|
||||
for _, binding := range []struct{ field, key string }{{"x", s.Dimensions.X}, {"y", s.Dimensions.Y}, {"sizeBy", s.Dimensions.SizeBy}} {
|
||||
// Empty is unset: the renderer picks a column or a fixed size.
|
||||
if binding.key == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := keys.Aggregations[binding.key]; !ok {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s.dimensions.%s: %q is not a value column of the panel's query; value columns: %s", path, binding.field, binding.key, allowedValuesForKind(slices.Sorted(maps.Keys(keys.Aggregations))))
|
||||
}
|
||||
}
|
||||
for _, label := range s.Dimensions.ColorBy {
|
||||
if _, ok := keys.Groups[label]; !ok {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s.dimensions.colorBy: %q is not a group-by key of the panel's query; group-by keys: %s", path, label, allowedValuesForKind(slices.Sorted(maps.Keys(keys.Groups))))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ScatterPlotDimensions binds result columns to what a dot encodes. Value columns
|
||||
// use the Table column key, so formatting.columnUnits carries over between the
|
||||
// two kinds.
|
||||
type ScatterPlotDimensions struct {
|
||||
X string `json:"x" description:"Value column key (queryName, or queryName.expression for a multi-aggregation query) plotted on the x axis. Empty uses the first value column."`
|
||||
Y string `json:"y" description:"Value column key plotted on the y axis. Empty uses the second value column."`
|
||||
SizeBy string `json:"sizeBy" description:"Value column key that scales dot size. Empty draws every dot at the default size."`
|
||||
ColorBy []string `json:"colorBy" description:"Group-by label names (e.g. k8s.namespace.name) whose combined values colour dots and drive the legend. Empty colours by every group-by label."`
|
||||
}
|
||||
|
||||
type ScatterPlotAxes struct {
|
||||
X ScatterPlotAxis `json:"x"`
|
||||
Y ScatterPlotAxis `json:"y"`
|
||||
}
|
||||
|
||||
type ScatterPlotAxis struct {
|
||||
SoftMin *float64 `json:"softMin"`
|
||||
SoftMax *float64 `json:"softMax"`
|
||||
Scale AxisScale `json:"scale"`
|
||||
Label string `json:"label" description:"Axis title. Empty draws none."`
|
||||
}
|
||||
|
||||
type ScatterPlotChartAppearance struct {
|
||||
Points ScatterPlotPoints `json:"points"`
|
||||
}
|
||||
|
||||
// ScatterPlotPoints keeps the fixed size and the size range side by side so
|
||||
// binding or unbinding dimensions.sizeBy restores the other's last setting. Nil
|
||||
// fields resolve to the renderer default.
|
||||
type ScatterPlotPoints struct {
|
||||
Size *PointDiameter `json:"size" description:"Diameter of every dot when dimensions.sizeBy is unset."`
|
||||
MinSize *PointDiameter `json:"minSize" description:"Diameter of the smallest dot when dimensions.sizeBy is set."`
|
||||
MaxSize *PointDiameter `json:"maxSize" description:"Diameter of the largest dot when dimensions.sizeBy is set."`
|
||||
Opacity *PointOpacity `json:"opacity"`
|
||||
}
|
||||
|
||||
func (p *ScatterPlotPoints) UnmarshalJSON(data []byte) error {
|
||||
type alias ScatterPlotPoints
|
||||
var tmp alias
|
||||
if err := json.Unmarshal(data, &tmp); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid points")
|
||||
}
|
||||
*p = ScatterPlotPoints(tmp)
|
||||
return p.validate()
|
||||
}
|
||||
|
||||
func (p ScatterPlotPoints) validate() error {
|
||||
if p.MinSize != nil && p.MaxSize != nil && *p.MinSize > *p.MaxSize {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid points: minSize %d must not exceed maxSize %d", *p.MinSize, *p.MaxSize)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type TextPresentation struct {
|
||||
TextAlign TextAlign `json:"textAlign"`
|
||||
VerticalAlign VerticalAlign `json:"verticalAlign"`
|
||||
@@ -431,6 +523,11 @@ type ComparisonThreshold struct {
|
||||
Format ThresholdFormat `json:"format"`
|
||||
}
|
||||
|
||||
type ScatterPlotThreshold struct {
|
||||
ThresholdWithLabel
|
||||
Axis ThresholdAxis `json:"axis"`
|
||||
}
|
||||
|
||||
type TableThreshold struct {
|
||||
ComparisonThreshold
|
||||
ColumnName string `json:"columnName" validate:"required" required:"true"`
|
||||
@@ -716,6 +813,86 @@ func (ls *LineStyle) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
}
|
||||
|
||||
// AxisScale `auto` leaves the choice to the renderer; `symlog` is log-like but
|
||||
// defined at and below zero.
|
||||
type AxisScale struct{ valuer.String }
|
||||
|
||||
var (
|
||||
AxisScaleAuto = AxisScale{valuer.NewString("auto")} // default
|
||||
AxisScaleLinear = AxisScale{valuer.NewString("linear")}
|
||||
AxisScaleLog = AxisScale{valuer.NewString("log")}
|
||||
AxisScaleSymlog = AxisScale{valuer.NewString("symlog")}
|
||||
)
|
||||
|
||||
func (AxisScale) Enum() []any {
|
||||
return []any{AxisScaleAuto, AxisScaleLinear, AxisScaleLog, AxisScaleSymlog}
|
||||
}
|
||||
|
||||
func (as AxisScale) ValueOrDefault() string {
|
||||
if as.IsZero() {
|
||||
return AxisScaleAuto.StringValue()
|
||||
}
|
||||
return as.StringValue()
|
||||
}
|
||||
|
||||
func (as AxisScale) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(as.ValueOrDefault())
|
||||
}
|
||||
|
||||
func (as *AxisScale) UnmarshalJSON(data []byte) error {
|
||||
var v string
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid axis scale: must be a string, one of `auto`, `linear`, `log`, or `symlog`")
|
||||
}
|
||||
val := AxisScale{valuer.NewString(v)}
|
||||
switch val {
|
||||
case AxisScaleAuto, AxisScaleLinear, AxisScaleLog, AxisScaleSymlog:
|
||||
*as = val
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid axis scale %q: must be `auto`, `linear`, `log`, or `symlog`", v)
|
||||
}
|
||||
}
|
||||
|
||||
// ThresholdAxis is the axis a scatter plot threshold line marks: `y` draws it
|
||||
// horizontally, `x` vertically.
|
||||
type ThresholdAxis struct{ valuer.String }
|
||||
|
||||
var (
|
||||
ThresholdAxisX = ThresholdAxis{valuer.NewString("x")}
|
||||
ThresholdAxisY = ThresholdAxis{valuer.NewString("y")} // default
|
||||
)
|
||||
|
||||
func (ThresholdAxis) Enum() []any {
|
||||
return []any{ThresholdAxisX, ThresholdAxisY}
|
||||
}
|
||||
|
||||
func (ta ThresholdAxis) ValueOrDefault() string {
|
||||
if ta.IsZero() {
|
||||
return ThresholdAxisY.StringValue()
|
||||
}
|
||||
return ta.StringValue()
|
||||
}
|
||||
|
||||
func (ta ThresholdAxis) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(ta.ValueOrDefault())
|
||||
}
|
||||
|
||||
func (ta *ThresholdAxis) UnmarshalJSON(data []byte) error {
|
||||
var v string
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid threshold axis: must be a string, one of `x` or `y`")
|
||||
}
|
||||
val := ThresholdAxis{valuer.NewString(v)}
|
||||
switch val {
|
||||
case ThresholdAxisX, ThresholdAxisY:
|
||||
*ta = val
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid threshold axis %q: must be `x` or `y`", v)
|
||||
}
|
||||
}
|
||||
|
||||
type FillMode struct{ valuer.String }
|
||||
|
||||
var (
|
||||
@@ -854,6 +1031,53 @@ func (o *FillOpacity) UnmarshalJSON(data []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
const (
|
||||
minPointDiameter = 2
|
||||
maxPointDiameter = 40
|
||||
)
|
||||
|
||||
// PointDiameter is a scatter dot's diameter in whole CSS pixels.
|
||||
type PointDiameter int
|
||||
|
||||
func (PointDiameter) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
s.WithMinimum(minPointDiameter).WithMaximum(maxPointDiameter)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *PointDiameter) UnmarshalJSON(data []byte) error {
|
||||
var v int
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid point diameter: must be a whole number between %d and %d", minPointDiameter, maxPointDiameter)
|
||||
}
|
||||
if v < minPointDiameter || v > maxPointDiameter {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid point diameter %d: must be a whole number between %d and %d", v, minPointDiameter, maxPointDiameter)
|
||||
}
|
||||
*d = PointDiameter(v)
|
||||
return nil
|
||||
}
|
||||
|
||||
const minPointOpacity = 0.1
|
||||
|
||||
// PointOpacity is a scatter dot's fill alpha. The floor keeps a dot visible.
|
||||
type PointOpacity float64
|
||||
|
||||
func (PointOpacity) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
s.WithMinimum(minPointOpacity).WithMaximum(1)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *PointOpacity) UnmarshalJSON(data []byte) error {
|
||||
var v float64
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid point opacity: must be a number between %v and 1", minPointOpacity)
|
||||
}
|
||||
if v < minPointOpacity || v > 1 {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid point opacity %v: must be between %v and 1", v, minPointOpacity)
|
||||
}
|
||||
*o = PointOpacity(v)
|
||||
return nil
|
||||
}
|
||||
|
||||
type SpanGaps struct {
|
||||
FillOnlyBelow bool `json:"fillOnlyBelow" description:"Controls whether lines connect across null values. When false (default), all gaps are connected. When true, only gaps smaller than fillLessThan are connected."`
|
||||
FillLessThan string `json:"fillLessThan" description:"The maximum gap size to connect when fillOnlyBelow is true. Gaps larger than this duration are left disconnected."`
|
||||
|
||||
134
pkg/types/dashboardtypes/testdata/perses_scatter_plot_panel.json
vendored
Normal file
134
pkg/types/dashboardtypes/testdata/perses_scatter_plot_panel.json
vendored
Normal file
@@ -0,0 +1,134 @@
|
||||
{
|
||||
"display": {
|
||||
"name": "service health",
|
||||
"description": "throughput against tail latency per pod"
|
||||
},
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"display": {
|
||||
"name": "throughput vs p99",
|
||||
"description": ""
|
||||
},
|
||||
"plugin": {
|
||||
"kind": "signoz/ScatterPlotPanel",
|
||||
"spec": {
|
||||
"visualization": {
|
||||
"timePreference": "last_1_hr"
|
||||
},
|
||||
"dimensions": {
|
||||
"x": "A.count()",
|
||||
"y": "A.p99(duration_nano)",
|
||||
"sizeBy": "A.countIf(has_error = true)",
|
||||
"colorBy": ["k8s.namespace.name", "k8s.pod.name"]
|
||||
},
|
||||
"formatting": {
|
||||
"columnUnits": {
|
||||
"A.p99(duration_nano)": "ns"
|
||||
},
|
||||
"decimalPrecision": "1"
|
||||
},
|
||||
"axes": {
|
||||
"x": {
|
||||
"softMin": 0,
|
||||
"softMax": null,
|
||||
"scale": "log",
|
||||
"label": "Throughput"
|
||||
},
|
||||
"y": {
|
||||
"softMin": null,
|
||||
"softMax": 1000,
|
||||
"scale": "symlog",
|
||||
"label": "p99 latency"
|
||||
}
|
||||
},
|
||||
"chartAppearance": {
|
||||
"points": {
|
||||
"size": 8,
|
||||
"minSize": 4,
|
||||
"maxSize": 24,
|
||||
"opacity": 0.1
|
||||
}
|
||||
},
|
||||
"legend": {
|
||||
"position": "right",
|
||||
"mode": "list",
|
||||
"customColors": {
|
||||
"checkout": "#1f77b4"
|
||||
}
|
||||
},
|
||||
"thresholds": [
|
||||
{
|
||||
"value": 300,
|
||||
"unit": "ms",
|
||||
"color": "#f00",
|
||||
"label": "p99 SLO",
|
||||
"axis": "y"
|
||||
},
|
||||
{
|
||||
"value": 50,
|
||||
"color": "#0f0",
|
||||
"axis": "x"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"queries": [
|
||||
{
|
||||
"kind": "scalar",
|
||||
"spec": {
|
||||
"plugin": {
|
||||
"kind": "signoz/BuilderQuery",
|
||||
"spec": {
|
||||
"name": "A",
|
||||
"signal": "traces",
|
||||
"aggregations": [
|
||||
{
|
||||
"expression": "count()"
|
||||
},
|
||||
{
|
||||
"expression": "p99(duration_nano)"
|
||||
},
|
||||
{
|
||||
"expression": "countIf(has_error = true)"
|
||||
}
|
||||
],
|
||||
"groupBy": [
|
||||
{
|
||||
"name": "k8s.namespace.name"
|
||||
},
|
||||
{
|
||||
"name": "k8s.pod.name"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": [
|
||||
{
|
||||
"kind": "Grid",
|
||||
"spec": {
|
||||
"items": [
|
||||
{
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"width": 12,
|
||||
"height": 8,
|
||||
"content": {
|
||||
"$ref": "#/spec/panels/p1"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -40,9 +40,7 @@ var (
|
||||
// every read needs, the signal and queried metric that family admission
|
||||
// needs, and the query-path flags evaluated one time per request. The
|
||||
// generic flows read FamiliesOn. Only the logs storage reads BodyJSONOn, and
|
||||
// only the traces storage reads TraceAttrsJSONOn. Evolutions holds the
|
||||
// column-wide entries (field_name `__all__`) a key metadata does not report
|
||||
// still reads through, loaded one time per request.
|
||||
// only the traces storage reads TraceAttrsJSONOn.
|
||||
type QueryInfo struct {
|
||||
StartNs uint64
|
||||
EndNs uint64
|
||||
@@ -51,7 +49,6 @@ type QueryInfo struct {
|
||||
FamiliesOn bool
|
||||
BodyJSONOn bool
|
||||
TraceAttrsJSONOn bool
|
||||
Evolutions []*telemetrytypes.EvolutionEntry
|
||||
}
|
||||
|
||||
// Absent is how a field key reads for a row that does not carry it, with
|
||||
@@ -217,8 +214,8 @@ type Resolved struct {
|
||||
|
||||
type AggExprRewriter interface {
|
||||
// Rewrite rewrites the aggregation expression to be used in the query.
|
||||
Rewrite(ctx context.Context, q QueryInfo, expr string, rateInterval uint64, keys map[string][]*telemetrytypes.TelemetryFieldKey) (string, []any, error)
|
||||
RewriteMulti(ctx context.Context, q QueryInfo, exprs []string, rateInterval uint64, keys map[string][]*telemetrytypes.TelemetryFieldKey) ([]string, [][]any, error)
|
||||
Rewrite(ctx context.Context, orgID valuer.UUID, startNs, endNs uint64, expr string, rateInterval uint64, keys map[string][]*telemetrytypes.TelemetryFieldKey) (string, []any, error)
|
||||
RewriteMulti(ctx context.Context, orgID valuer.UUID, startNs, endNs uint64, exprs []string, rateInterval uint64, keys map[string][]*telemetrytypes.TelemetryFieldKey) ([]string, [][]any, error)
|
||||
}
|
||||
|
||||
type Statement struct {
|
||||
|
||||
@@ -266,6 +266,43 @@ type CompositeQuery struct {
|
||||
Queries []QueryEnvelope `json:"queries"`
|
||||
}
|
||||
|
||||
// ResultColumnKeys is what a scalar query yields per row, keyed the way the
|
||||
// UI keys columns: an aggregation column is the query name, or
|
||||
// queryName.expression when the query has several aggregations; a group
|
||||
// column is a group-by key name.
|
||||
type ResultColumnKeys struct {
|
||||
Aggregations map[string]struct{}
|
||||
Groups map[string]struct{}
|
||||
}
|
||||
|
||||
// CollectResultColumnKeys reports false when a query's columns are only known
|
||||
// at run time, as for ClickHouse SQL and PromQL. A disabled query yields no
|
||||
// aggregation column but keeps its group columns, since a formula over it
|
||||
// still carries them.
|
||||
func (c *CompositeQuery) CollectResultColumnKeys() (ResultColumnKeys, bool) {
|
||||
keys := ResultColumnKeys{Aggregations: map[string]struct{}{}, Groups: map[string]struct{}{}}
|
||||
for _, envelope := range c.Queries {
|
||||
if envelope.GetType() == QueryTypeClickHouseSQL || envelope.GetType() == QueryTypePromQL {
|
||||
return ResultColumnKeys{}, false
|
||||
}
|
||||
for _, key := range envelope.GetGroupBy() {
|
||||
keys.Groups[key.Name] = struct{}{}
|
||||
}
|
||||
if envelope.IsDisabled() {
|
||||
continue
|
||||
}
|
||||
expressions := envelope.GetAggregationExpressions()
|
||||
if len(expressions) <= 1 {
|
||||
keys.Aggregations[envelope.GetQueryName()] = struct{}{}
|
||||
continue
|
||||
}
|
||||
for _, expression := range expressions {
|
||||
keys.Aggregations[envelope.GetQueryName()+"."+expression] = struct{}{}
|
||||
}
|
||||
}
|
||||
return keys, true
|
||||
}
|
||||
|
||||
// PrepareJSONSchema adds description to the CompositeQuery schema.
|
||||
func (c *CompositeQuery) PrepareJSONSchema(schema *jsonschema.Schema) error {
|
||||
schema.WithDescription("Composite query containing one or more query envelopes. Each query envelope specifies its type and corresponding spec.")
|
||||
|
||||
@@ -225,6 +225,34 @@ func (q *QueryEnvelope) GetGroupBy() []GroupByKey {
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAggregationExpressions returns one entry per aggregation. A metric
|
||||
// aggregation has no expression, so it contributes an empty entry.
|
||||
func (q *QueryEnvelope) GetAggregationExpressions() []string {
|
||||
switch spec := q.Spec.(type) {
|
||||
case QueryBuilderTraceOperator:
|
||||
expressions := make([]string, len(spec.Aggregations))
|
||||
for i, aggregation := range spec.Aggregations {
|
||||
expressions[i] = aggregation.Expression
|
||||
}
|
||||
return expressions
|
||||
case QueryBuilderQuery[TraceAggregation]:
|
||||
expressions := make([]string, len(spec.Aggregations))
|
||||
for i, aggregation := range spec.Aggregations {
|
||||
expressions[i] = aggregation.Expression
|
||||
}
|
||||
return expressions
|
||||
case QueryBuilderQuery[LogAggregation]:
|
||||
expressions := make([]string, len(spec.Aggregations))
|
||||
for i, aggregation := range spec.Aggregations {
|
||||
expressions[i] = aggregation.Expression
|
||||
}
|
||||
return expressions
|
||||
case QueryBuilderQuery[MetricAggregation]:
|
||||
return make([]string, len(spec.Aggregations))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetFilter returns the filter.
|
||||
func (q *QueryEnvelope) GetFilter() *Filter {
|
||||
switch spec := q.Spec.(type) {
|
||||
|
||||
@@ -1903,3 +1903,63 @@ func TestQueryRangeRequest_StepIntervalForQuery(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompositeQueryCollectResultColumnKeys(t *testing.T) {
|
||||
testCases := []struct {
|
||||
scenario string
|
||||
composite CompositeQuery
|
||||
expectedKeys ResultColumnKeys
|
||||
expectedKnown bool
|
||||
}{
|
||||
{
|
||||
scenario: "a single aggregation is keyed by the query name",
|
||||
composite: CompositeQuery{Queries: []QueryEnvelope{{Type: QueryTypeBuilder, Spec: QueryBuilderQuery[TraceAggregation]{
|
||||
Name: "A",
|
||||
Aggregations: []TraceAggregation{{Expression: "count()"}},
|
||||
GroupBy: []GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "service.name"}}},
|
||||
}}}},
|
||||
expectedKeys: ResultColumnKeys{Aggregations: map[string]struct{}{"A": {}}, Groups: map[string]struct{}{"service.name": {}}},
|
||||
expectedKnown: true,
|
||||
},
|
||||
{
|
||||
scenario: "several aggregations are keyed by query name and expression",
|
||||
composite: CompositeQuery{Queries: []QueryEnvelope{{Type: QueryTypeBuilder, Spec: QueryBuilderQuery[LogAggregation]{
|
||||
Name: "A",
|
||||
Aggregations: []LogAggregation{{Expression: "count()"}, {Expression: "p99(duration_nano)"}},
|
||||
}}}},
|
||||
expectedKeys: ResultColumnKeys{Aggregations: map[string]struct{}{"A.count()": {}, "A.p99(duration_nano)": {}}, Groups: map[string]struct{}{}},
|
||||
expectedKnown: true,
|
||||
},
|
||||
{
|
||||
scenario: "a disabled query yields no aggregation but keeps its groups for the formula over it",
|
||||
composite: CompositeQuery{Queries: []QueryEnvelope{
|
||||
{Type: QueryTypeBuilder, Spec: QueryBuilderQuery[MetricAggregation]{
|
||||
Name: "A",
|
||||
Disabled: true,
|
||||
Aggregations: []MetricAggregation{{MetricName: "http.server.request.count"}},
|
||||
GroupBy: []GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "service.name"}}},
|
||||
}},
|
||||
{Type: QueryTypeFormula, Spec: QueryBuilderFormula{Name: "F1", Expression: "A * 2"}},
|
||||
}},
|
||||
expectedKeys: ResultColumnKeys{Aggregations: map[string]struct{}{"F1": {}}, Groups: map[string]struct{}{"service.name": {}}},
|
||||
expectedKnown: true,
|
||||
},
|
||||
{
|
||||
scenario: "clickhouse columns are only known at run time",
|
||||
composite: CompositeQuery{Queries: []QueryEnvelope{{Type: QueryTypeClickHouseSQL, Spec: ClickHouseQuery{
|
||||
Name: "A",
|
||||
Query: "SELECT 1",
|
||||
}}}},
|
||||
expectedKeys: ResultColumnKeys{},
|
||||
expectedKnown: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.scenario, func(t *testing.T) {
|
||||
keys, known := testCase.composite.CollectResultColumnKeys()
|
||||
assert.Equal(t, testCase.expectedKnown, known)
|
||||
assert.Equal(t, testCase.expectedKeys, keys)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,11 +45,6 @@ type MetadataStore interface {
|
||||
// rows templated by entry; FieldName and ReleaseTime are set per path.
|
||||
PromotePaths(ctx context.Context, entry EvolutionEntry, paths ...string) error
|
||||
|
||||
// GetColumnEvolutions lists the column-wide evolution entries (field_name
|
||||
// `__all__`) of the signal's field context: the homes a key metadata does
|
||||
// not report still reads through.
|
||||
GetColumnEvolutions(ctx context.Context, signal Signal, fieldContext FieldContext) ([]*EvolutionEntry, error)
|
||||
|
||||
// GetFirstSeenFromMetricMetadata gets the first seen timestamp for a metric metadata lookup key.
|
||||
GetFirstSeenFromMetricMetadata(ctx context.Context, lookupKeys []MetricMetadataLookupKey) (map[MetricMetadataLookupKey]int64, error)
|
||||
|
||||
|
||||
@@ -419,11 +419,6 @@ func (m *MockMetadataStore) updateColumnEvolutionMetadataForKeys(_ context.Conte
|
||||
return result
|
||||
}
|
||||
|
||||
func (m *MockMetadataStore) GetColumnEvolutions(_ context.Context, signal telemetrytypes.Signal, fieldContext telemetrytypes.FieldContext) ([]*telemetrytypes.EvolutionEntry, error) {
|
||||
selector := &telemetrytypes.EvolutionSelector{Signal: signal, FieldContext: fieldContext, FieldName: "__all__"}
|
||||
return m.ColumnEvolutionMetadataMap[selector.QualifiedName()], nil
|
||||
}
|
||||
|
||||
func (m *MockMetadataStore) GetFirstSeenFromMetricMetadata(ctx context.Context, lookupKeys []telemetrytypes.MetricMetadataLookupKey) (map[telemetrytypes.MetricMetadataLookupKey]int64, error) {
|
||||
return m.LookupKeysMap, nil
|
||||
}
|
||||
|
||||
@@ -13,3 +13,10 @@ SIGNOZ_E2E_BASE_URL=https://app.us.staging.signoz.cloud
|
||||
# Test credentials (required only when SIGNOZ_E2E_BASE_URL is set — i.e. staging mode)
|
||||
SIGNOZ_E2E_USERNAME=
|
||||
SIGNOZ_E2E_PASSWORD=
|
||||
|
||||
# Base path mode. `pnpm env:start:base-path` serves SigNoz under /signoz, the
|
||||
# way SIGNOZ_GLOBAL_EXTERNAL__URL deploys it, and writes this to .env.local;
|
||||
# specs then run unchanged (see helpers/base-path.ts). The root and base path
|
||||
# stacks share one database, so stop one and run `pnpm env:clean` before
|
||||
# starting the other.
|
||||
# SIGNOZ_E2E_BASE_PATH=/signoz
|
||||
|
||||
0
tests/e2e/bootstrap/basepath/__init__.py
Normal file
0
tests/e2e/bootstrap/basepath/__init__.py
Normal file
55
tests/e2e/bootstrap/basepath/conftest.py
Normal file
55
tests/e2e/bootstrap/basepath/conftest.py
Normal file
@@ -0,0 +1,55 @@
|
||||
import pytest
|
||||
from testcontainers.core.container import Network
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import license_applier, register_admin
|
||||
from fixtures.signoz import create_signoz
|
||||
|
||||
# The e2e stack served under a URL path prefix, as SIGNOZ_GLOBAL_EXTERNAL__URL
|
||||
# deploys it. These shadow the root fixtures of the same name; the separate
|
||||
# cache keys keep the two stacks from restoring each other's containers.
|
||||
BASE_PATH = "/signoz"
|
||||
|
||||
|
||||
@pytest.fixture(name="base_path", scope="package")
|
||||
def base_path() -> str:
|
||||
return BASE_PATH
|
||||
|
||||
|
||||
@pytest.fixture(name="signoz", scope="package")
|
||||
def signoz_base_path( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
network: Network,
|
||||
zeus: types.TestContainerDocker,
|
||||
gateway: types.TestContainerDocker,
|
||||
sqlstore: types.TestContainerSQL,
|
||||
clickhouse: types.TestContainerClickhouse,
|
||||
e2e_env: dict,
|
||||
request: pytest.FixtureRequest,
|
||||
pytestconfig: pytest.Config,
|
||||
) -> types.SigNoz:
|
||||
return create_signoz(
|
||||
network=network,
|
||||
zeus=zeus,
|
||||
gateway=gateway,
|
||||
sqlstore=sqlstore,
|
||||
clickhouse=clickhouse,
|
||||
request=request,
|
||||
pytestconfig=pytestconfig,
|
||||
cache_key="signoz_base_path",
|
||||
env_overrides=e2e_env | {"SIGNOZ_GLOBAL_EXTERNAL__URL": f"http://localhost:8080{BASE_PATH}"},
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(name="create_user_admin", scope="package")
|
||||
def create_user_admin_base_path(signoz: types.SigNoz, request: pytest.FixtureRequest, pytestconfig: pytest.Config) -> types.Operation:
|
||||
return register_admin(signoz, request, pytestconfig, cache_key="create_user_admin_base_path", base_path=BASE_PATH)
|
||||
|
||||
|
||||
@pytest.fixture(name="apply_license", scope="package")
|
||||
def apply_license_base_path(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
request: pytest.FixtureRequest,
|
||||
pytestconfig: pytest.Config,
|
||||
) -> types.Operation:
|
||||
return license_applier(signoz, request, pytestconfig, cache_key="apply_license_base_path", base_path=BASE_PATH)
|
||||
5
tests/e2e/bootstrap/basepath/setup.py
Normal file
5
tests/e2e/bootstrap/basepath/setup.py
Normal file
@@ -0,0 +1,5 @@
|
||||
# The root bootstrap's tests, collected again here so they resolve the
|
||||
# base-path fixtures from this package's conftest.py.
|
||||
from e2e.bootstrap.setup import test_setup, test_teardown
|
||||
|
||||
__all__ = ["test_setup", "test_teardown"]
|
||||
45
tests/e2e/bootstrap/conftest.py
Normal file
45
tests/e2e/bootstrap/conftest.py
Normal file
@@ -0,0 +1,45 @@
|
||||
import pytest
|
||||
from testcontainers.core.container import Network
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.signoz import create_signoz
|
||||
|
||||
|
||||
@pytest.fixture(name="base_path", scope="package")
|
||||
def base_path() -> str:
|
||||
"""URL path prefix SigNoz is served under. Empty serves it at the root;
|
||||
basepath/conftest.py overrides it."""
|
||||
return ""
|
||||
|
||||
|
||||
@pytest.fixture(name="e2e_env", scope="package")
|
||||
def e2e_env() -> dict:
|
||||
"""Environment every e2e SigNoz gets. Each Playwright worker logs in once,
|
||||
and the default cap of 5 sessions per user evicts the oldest, which turns a
|
||||
worker's requests into 401s mid-run."""
|
||||
return {"SIGNOZ_TOKENIZER_OPAQUE_TOKEN_MAX__PER__USER": 100}
|
||||
|
||||
|
||||
@pytest.fixture(name="signoz", scope="package")
|
||||
def signoz( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
network: Network,
|
||||
zeus: types.TestContainerDocker,
|
||||
gateway: types.TestContainerDocker,
|
||||
sqlstore: types.TestContainerSQL,
|
||||
clickhouse: types.TestContainerClickhouse,
|
||||
tls: types.TLS,
|
||||
e2e_env: dict,
|
||||
request: pytest.FixtureRequest,
|
||||
pytestconfig: pytest.Config,
|
||||
) -> types.SigNoz:
|
||||
return create_signoz(
|
||||
network=network,
|
||||
zeus=zeus,
|
||||
gateway=gateway,
|
||||
sqlstore=sqlstore,
|
||||
clickhouse=clickhouse,
|
||||
request=request,
|
||||
pytestconfig=pytestconfig,
|
||||
tls=tls,
|
||||
env_overrides=e2e_env,
|
||||
)
|
||||
@@ -15,11 +15,12 @@ def _env_file(pytestconfig: pytest.Config) -> Path:
|
||||
return pytestconfig.rootpath / "e2e" / ".env.local"
|
||||
|
||||
|
||||
def test_setup(
|
||||
def test_setup( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument
|
||||
apply_license: types.Operation, # pylint: disable=unused-argument
|
||||
seeder: types.TestContainerDocker,
|
||||
base_path: str,
|
||||
pytestconfig: pytest.Config,
|
||||
) -> None:
|
||||
"""Bring the backend up and write e2e coordinates to .env.local."""
|
||||
@@ -33,6 +34,8 @@ def test_setup(
|
||||
f.write(f"SIGNOZ_E2E_USERNAME={USER_ADMIN_EMAIL}\n")
|
||||
f.write(f"SIGNOZ_E2E_PASSWORD={USER_ADMIN_PASSWORD}\n")
|
||||
f.write(f"SIGNOZ_E2E_SEEDER_URL={seeder_cfg.base()}\n")
|
||||
if base_path:
|
||||
f.write(f"SIGNOZ_E2E_BASE_PATH={base_path}\n")
|
||||
|
||||
|
||||
def test_teardown(
|
||||
|
||||
93
tests/e2e/drivers/kit.ts
generated
Normal file
93
tests/e2e/drivers/kit.ts
generated
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import type { Browser, Page, TestType } from '@playwright/test';
|
||||
|
||||
import { newAdminContext } from '../helpers/auth';
|
||||
|
||||
export { expect, test as kit } from '../fixtures/auth';
|
||||
|
||||
export interface DriverContext {
|
||||
page: Page;
|
||||
}
|
||||
|
||||
type Fixtures = Record<string, unknown>;
|
||||
|
||||
/**
|
||||
* Adds test-scoped fixtures to any test carrying `authedPage`. `T` is inferred
|
||||
* from the argument: TypeScript cannot invert `T & { authedPage: Page }`, so
|
||||
* constraining that way widens every existing fixture to `unknown`.
|
||||
*/
|
||||
export type Layer<Adds> = <T extends { authedPage: Page }, W extends object>(
|
||||
base: TestType<T, W>,
|
||||
) => TestType<T & Adds, W>;
|
||||
|
||||
export type WorkerLayer<Adds> = <T extends object, W extends object>(
|
||||
base: TestType<T, W>,
|
||||
) => TestType<T, W & Adds>;
|
||||
|
||||
/** A `withX` layer binding `build` to the authenticated page. */
|
||||
export function driver<Name extends string, T>(
|
||||
name: Name,
|
||||
build: (ctx: DriverContext) => T,
|
||||
): Layer<{ [K in Name]: T }> {
|
||||
const fixture = {
|
||||
// Playwright reads fixture dependencies from the source text, so
|
||||
// `authedPage` must stay destructured literally.
|
||||
[name]: async (
|
||||
{ authedPage }: { authedPage: Page },
|
||||
use: (value: T) => Promise<void>,
|
||||
) => {
|
||||
await use(build({ page: authedPage }));
|
||||
},
|
||||
};
|
||||
return ((test: TestType<Fixtures, Fixtures>) =>
|
||||
test.extend(fixture as never)) as Layer<{ [K in Name]: T }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A `withX` layer that seeds once per worker and tears down after. `open` and
|
||||
* `close` run on a throwaway admin page: worker fixtures cannot reach
|
||||
* `authedPage`.
|
||||
*/
|
||||
export function seeded<Name extends string, T>(
|
||||
name: Name,
|
||||
open: (ctx: { page: Page; workerIndex: number }) => Promise<T>,
|
||||
close?: (ctx: { page: Page; value: T }) => Promise<void>,
|
||||
options: { timeout?: number } = {},
|
||||
): WorkerLayer<{ [K in Name]: T }> {
|
||||
const fixture = {
|
||||
[name]: [
|
||||
async (
|
||||
{ browser }: { browser: Browser },
|
||||
use: (value: T) => Promise<void>,
|
||||
workerInfo: { workerIndex: number },
|
||||
) => {
|
||||
const value = await onAdminPage(browser, (page) =>
|
||||
open({ page, workerIndex: workerInfo.workerIndex }),
|
||||
);
|
||||
await use(value);
|
||||
if (close) {
|
||||
await onAdminPage(browser, (page) => close({ page, value }));
|
||||
}
|
||||
},
|
||||
{ scope: 'worker', timeout: options.timeout },
|
||||
],
|
||||
};
|
||||
return ((test: TestType<Fixtures, Fixtures>) =>
|
||||
test.extend(fixture as never)) as WorkerLayer<{ [K in Name]: T }>;
|
||||
}
|
||||
|
||||
/** Run `body` on a throwaway page authenticated as the admin. */
|
||||
export async function onAdminPage<T>(
|
||||
browser: Browser,
|
||||
body: (page: Page) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const ctx = await newAdminContext(browser);
|
||||
try {
|
||||
return await body(await ctx.newPage());
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
}
|
||||
392
tests/e2e/drivers/routing.ts
generated
Normal file
392
tests/e2e/drivers/routing.ts
generated
Normal file
@@ -0,0 +1,392 @@
|
||||
/**
|
||||
* AI-owned. Generated and maintained by coding agents alongside the specs that
|
||||
* use it. Do not hand-edit: regenerate it with the spec that needs the change.
|
||||
*/
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
import { expect, type Locator, type Page } from '@playwright/test';
|
||||
|
||||
import { ADMIN } from '../helpers/auth';
|
||||
import { authToken } from '../helpers/common';
|
||||
import {
|
||||
createDashboardViaApi,
|
||||
deleteDashboardViaApi,
|
||||
} from '../helpers/dashboards';
|
||||
import { seedPodMetricsViaSeeder } from '../helpers/infra-monitoring';
|
||||
import { gotoLogsExplorer } from '../helpers/logs-explorer';
|
||||
import {
|
||||
gotoTraceUntilLoaded,
|
||||
type LargeTrace,
|
||||
loadLargeTrace,
|
||||
seedTracesViaSeeder,
|
||||
} from '../helpers/trace-details';
|
||||
|
||||
import { driver, kit, seeded } from './kit';
|
||||
|
||||
// The react-router v5 -> v7 regression net. Router-sensitive mechanics (history
|
||||
// depth, POP, same-url suppression, the `newTab` branch) live here so the specs
|
||||
// read as the property they guard.
|
||||
|
||||
export const PATH = {
|
||||
alerts: '/alerts',
|
||||
dashboards: '/dashboard',
|
||||
home: '/home',
|
||||
k8s: '/infrastructure-monitoring/kubernetes',
|
||||
login: '/login',
|
||||
metricsSummary: '/metrics-explorer/summary',
|
||||
metricsViews: '/metrics-explorer/views',
|
||||
roles: '/settings/roles',
|
||||
roleCreate: '/settings/roles/new',
|
||||
services: '/services',
|
||||
tracesExplorer: '/traces-explorer',
|
||||
/** Matches no entry of `AppRoutes/routes.ts`. */
|
||||
unknown: '/definitely-not-a-route',
|
||||
} as const;
|
||||
|
||||
/** A resource attribute every seeded pod carries. */
|
||||
const NAMESPACE_ATTR = 'k8s.namespace.name';
|
||||
|
||||
const RELATIVE_TIME_LABELS: Record<string, string> = {
|
||||
'30m': 'Last 30 minutes',
|
||||
'1h': 'Last 1 hour',
|
||||
'6h': 'Last 6 hours',
|
||||
};
|
||||
|
||||
/** Three browser projects share one backend, so names need more than a timestamp. */
|
||||
function uniqueSuffix(): string {
|
||||
return randomBytes(4).toString('hex');
|
||||
}
|
||||
|
||||
/** Role names allow lowercase letters and hyphens only. */
|
||||
export function uniqueAlphaSuffix(): string {
|
||||
return [...randomBytes(6)]
|
||||
.map((byte) => String.fromCharCode(97 + (byte % 26)))
|
||||
.join('');
|
||||
}
|
||||
|
||||
export function dashboardListParams(title: string): Record<string, string> {
|
||||
return { search: title, columnKey: 'updatedAt', order: 'descend', page: '1' };
|
||||
}
|
||||
|
||||
function groupByOf(url: URL): string {
|
||||
return url.searchParams.get('groupBy') ?? '';
|
||||
}
|
||||
|
||||
export interface Routing {
|
||||
goto(path: string): Promise<void>;
|
||||
reload(): Promise<void>;
|
||||
goBack(): Promise<void>;
|
||||
goForward(): Promise<void>;
|
||||
/** Go back one entry and wait for `pathname`. */
|
||||
goBackTo(pathname: string): Promise<void>;
|
||||
url(): URL;
|
||||
params(): Record<string, string>;
|
||||
waitForPath(pathname: string): Promise<void>;
|
||||
waitForParam(key: string, value: string): Promise<void>;
|
||||
/** `history.length`: Chromium keeps it on POP and REPLACE, grows it on PUSH. */
|
||||
historyDepth(): Promise<number>;
|
||||
/** Complete the login form on the page as it stands, without navigating. */
|
||||
login(): Promise<void>;
|
||||
/** Sidebar entries are `div.nav-item`, not links. */
|
||||
sidebarItem(label: string): Locator;
|
||||
/** antd puts a `RouteTab` item's route on `data-node-key`. */
|
||||
routeTab(route: string): Locator;
|
||||
activeRouteTab(route: string): Locator;
|
||||
/** The picker `TopNav` renders. Several pages mount their own. */
|
||||
globalTimePicker(): Locator;
|
||||
selectRelativeTime(value: string): Promise<void>;
|
||||
expectRelativeTime(value: string): Promise<void>;
|
||||
/** Ctrl/Cmd-click `target`, run `body` on the tab it opens, then close it. */
|
||||
inNewTab(
|
||||
target: Locator,
|
||||
body: (tab: Routing) => Promise<void>,
|
||||
): Promise<void>;
|
||||
/** Hold every script requested during `body`, so lazy route chunks suspend. */
|
||||
whileScriptsHeld(body: () => Promise<void>): Promise<void>;
|
||||
gotoLogsExplorer(): Promise<void>;
|
||||
dashboards: {
|
||||
gotoList(title?: string): Promise<void>;
|
||||
/** Not `dashboard-title-0`: the search tokenises on hyphens and matches leftovers. */
|
||||
row(title: string): Locator;
|
||||
landmark(): Locator;
|
||||
title(): Locator;
|
||||
};
|
||||
k8s: {
|
||||
/** Open the pods list once its mount-time `compositeQuery` rewrite landed. */
|
||||
goto(params: Record<string, string>): Promise<void>;
|
||||
/** One nuqs push: `groupBy`, `page` and `orderBy` flush together. */
|
||||
groupBy(attribute: string): Promise<void>;
|
||||
waitUntilGrouped(attribute: string, grouped?: boolean): Promise<void>;
|
||||
groupBySelect(): Locator;
|
||||
};
|
||||
trace: {
|
||||
path(trace: LargeTrace): string;
|
||||
goto(trace: LargeTrace): Promise<void>;
|
||||
rootRow(trace: LargeTrace): Locator;
|
||||
back(): Promise<void>;
|
||||
};
|
||||
roles: {
|
||||
/** Fill the create form far enough to arm the navigation blocker. */
|
||||
openCreateForm(name: string): Promise<void>;
|
||||
nameInput(): Locator;
|
||||
cancel(): Promise<void>;
|
||||
save(): Promise<void>;
|
||||
discardDialog(): Locator;
|
||||
settings(): Locator;
|
||||
removeByName(names: string[]): Promise<void>;
|
||||
};
|
||||
}
|
||||
|
||||
export function routingDriver(page: Page): Routing {
|
||||
const timePicker = (): Locator => page.getByTestId('dropDown');
|
||||
|
||||
const routing: Routing = {
|
||||
goto: async (path) => {
|
||||
await page.goto(path);
|
||||
},
|
||||
reload: async () => {
|
||||
await page.reload();
|
||||
},
|
||||
goBack: async () => {
|
||||
await page.goBack();
|
||||
},
|
||||
goForward: async () => {
|
||||
await page.goForward();
|
||||
},
|
||||
goBackTo: async (pathname) => {
|
||||
await page.goBack();
|
||||
await routing.waitForPath(pathname);
|
||||
},
|
||||
|
||||
url: () => new URL(page.url()),
|
||||
params: () => Object.fromEntries(routing.url().searchParams),
|
||||
waitForPath: (pathname) =>
|
||||
page.waitForURL((url) => url.pathname === pathname),
|
||||
waitForParam: (key, value) =>
|
||||
page.waitForURL((url) => url.searchParams.get(key) === value),
|
||||
historyDepth: () =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
(globalThis as unknown as { history: { length: number } }).history.length,
|
||||
),
|
||||
|
||||
login: async () => {
|
||||
await page.getByTestId('email').fill(ADMIN.email);
|
||||
await page.getByTestId('initiate_login').click();
|
||||
await page.getByTestId('password').fill(ADMIN.password);
|
||||
await page.getByTestId('password_authn_submit').click();
|
||||
},
|
||||
|
||||
sidebarItem: (label) =>
|
||||
page.locator('.nav-item').filter({ hasText: new RegExp(`^${label}$`) }),
|
||||
routeTab: (route) => page.locator(`.ant-tabs-tab[data-node-key="${route}"]`),
|
||||
activeRouteTab: (route) =>
|
||||
routing.routeTab(route).getByRole('tab', { selected: true }),
|
||||
|
||||
globalTimePicker: () =>
|
||||
page.locator('.top-nav-container').getByTestId('dropDown'),
|
||||
|
||||
// A click before the picker is interactive is dropped silently, so retry the
|
||||
// open. The popover unmounts on close, which is what proves the selection
|
||||
// committed: the url may legitimately stay the same.
|
||||
selectRelativeTime: async (value) => {
|
||||
const option = page.getByTestId(`time-option-${value}`);
|
||||
await expect(async () => {
|
||||
if (!(await option.isVisible())) {
|
||||
await timePicker().click();
|
||||
}
|
||||
await expect(option).toBeVisible({ timeout: 3_000 });
|
||||
}).toPass({ timeout: 30_000 });
|
||||
await option.click();
|
||||
await expect(option).toBeHidden();
|
||||
},
|
||||
|
||||
expectRelativeTime: async (value) => {
|
||||
await expect(timePicker()).toHaveAccessibleName(RELATIVE_TIME_LABELS[value]);
|
||||
},
|
||||
|
||||
inNewTab: async (target, body) => {
|
||||
const [tab] = await Promise.all([
|
||||
page.context().waitForEvent('page'),
|
||||
target.click({ modifiers: ['ControlOrMeta'] }),
|
||||
]);
|
||||
try {
|
||||
await tab.waitForLoadState();
|
||||
await body(routingDriver(tab));
|
||||
} finally {
|
||||
await tab.close();
|
||||
}
|
||||
},
|
||||
|
||||
// The handler stays installed after the release: `unroute` drops the
|
||||
// requests still parked in it.
|
||||
whileScriptsHeld: async (body) => {
|
||||
let release = (): void => {};
|
||||
const held = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
await page.route('**/*.js', async (route) => {
|
||||
await held;
|
||||
await route.continue();
|
||||
});
|
||||
try {
|
||||
await body();
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
},
|
||||
|
||||
gotoLogsExplorer: () => gotoLogsExplorer(page),
|
||||
|
||||
dashboards: {
|
||||
gotoList: async (title) => {
|
||||
if (!title) {
|
||||
await page.goto(PATH.dashboards);
|
||||
await expect(routing.dashboards.landmark()).toBeVisible();
|
||||
return;
|
||||
}
|
||||
const search = new URLSearchParams(dashboardListParams(title));
|
||||
await page.goto(`${PATH.dashboards}?${search.toString()}`);
|
||||
await expect(routing.dashboards.row(title)).toBeVisible();
|
||||
},
|
||||
row: (title) =>
|
||||
page
|
||||
.locator('[data-testid^="dashboard-title-"]')
|
||||
.filter({ hasText: title }),
|
||||
// The views rail renders whether or not the list is empty.
|
||||
landmark: () => page.getByTestId('dashboards-view-search'),
|
||||
title: () => page.getByTestId('dashboard-title'),
|
||||
},
|
||||
|
||||
k8s: {
|
||||
goto: async (params) => {
|
||||
const search = new URLSearchParams({ category: 'pods', ...params });
|
||||
await page.goto(`${PATH.k8s}?${search.toString()}`);
|
||||
await page.waitForURL((url) => url.searchParams.has('compositeQuery'));
|
||||
await expect(routing.k8s.groupBySelect()).toBeVisible();
|
||||
},
|
||||
// The search input stays readonly until the dropdown opens, and `hasText`
|
||||
// would also match longer keys, so type after opening and match the title.
|
||||
groupBy: async (attribute) => {
|
||||
await routing.k8s.groupBySelect().click();
|
||||
await page.keyboard.type(attribute);
|
||||
await page.locator(`.ant-select-item-option[title="${attribute}"]`).click();
|
||||
await page.keyboard.press('Escape');
|
||||
await routing.k8s.waitUntilGrouped(attribute);
|
||||
},
|
||||
waitUntilGrouped: (attribute, grouped = true) =>
|
||||
page.waitForURL((url) => groupByOf(url).includes(attribute) === grouped),
|
||||
groupBySelect: () => page.getByTestId('k8s-table-group-by-select'),
|
||||
},
|
||||
|
||||
trace: {
|
||||
path: (trace) => `/trace/${trace.traceId}`,
|
||||
goto: (trace) =>
|
||||
gotoTraceUntilLoaded(
|
||||
page,
|
||||
routing.trace.path(trace),
|
||||
`cell-0-${trace.landmarks.root}`,
|
||||
),
|
||||
rootRow: (trace) => page.getByTestId(`cell-0-${trace.landmarks.root}`),
|
||||
back: async () => {
|
||||
await page.getByRole('button', { name: 'Back' }).click();
|
||||
},
|
||||
},
|
||||
|
||||
roles: {
|
||||
openCreateForm: async (name) => {
|
||||
await page.goto(PATH.roleCreate);
|
||||
await routing.roles.nameInput().fill(name);
|
||||
await expect(page.getByText('Unsaved changes')).toBeVisible();
|
||||
},
|
||||
nameInput: () => page.getByTestId('role-name-input'),
|
||||
cancel: async () => {
|
||||
await page.getByTestId('cancel-button').click();
|
||||
},
|
||||
save: async () => {
|
||||
await page.getByTestId('save-button').click();
|
||||
},
|
||||
discardDialog: () => page.getByTestId('discard-changes-dialog'),
|
||||
settings: () => page.getByTestId('roles-settings'),
|
||||
removeByName: async (names) => {
|
||||
const headers = { Authorization: `Bearer ${await authToken(page)}` };
|
||||
const res = await page.request.get('/api/v1/roles', { headers });
|
||||
const { data } = (await res.json()) as {
|
||||
data: { id: string; name: string }[];
|
||||
};
|
||||
await Promise.all(
|
||||
data
|
||||
.filter((role) => names.includes(role.name))
|
||||
.map((role) =>
|
||||
page.request.delete(`/api/v1/roles/${role.id}`, { headers }),
|
||||
),
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return routing;
|
||||
}
|
||||
|
||||
// The seeder serves inserts on one ClickHouse session, so parallel workers
|
||||
// collide with a transient `500 concurrent queries within the same session`.
|
||||
const SEED_RETRY_MS = 120_000;
|
||||
const SEED_TIMEOUT_MS = 180_000;
|
||||
|
||||
async function seedWithRetry(seed: () => Promise<void>): Promise<void> {
|
||||
await expect(seed).toPass({
|
||||
timeout: SEED_RETRY_MS,
|
||||
intervals: [1_000, 2_000, 5_000],
|
||||
});
|
||||
}
|
||||
|
||||
const withRouting = driver('routing', ({ page }) => routingDriver(page));
|
||||
|
||||
const withDashboard = seeded(
|
||||
'dashboard',
|
||||
async ({ page }) => {
|
||||
const title = `routing-${uniqueSuffix()}`;
|
||||
const id = await createDashboardViaApi(page, title);
|
||||
return { id, title, path: `${PATH.dashboards}/${id}` };
|
||||
},
|
||||
async ({ page, value }) =>
|
||||
deleteDashboardViaApi(page.request, value.id, await authToken(page)),
|
||||
);
|
||||
|
||||
const withTrace = seeded(
|
||||
'largeTrace',
|
||||
async ({ page }) => {
|
||||
const trace = loadLargeTrace();
|
||||
await seedWithRetry(() => seedTracesViaSeeder(page.request, trace.spans));
|
||||
return trace;
|
||||
},
|
||||
undefined,
|
||||
{ timeout: SEED_TIMEOUT_MS },
|
||||
);
|
||||
|
||||
// Group-by options come from `/fields/keys` over the queried window, so the
|
||||
// select stays empty until pod metrics exist inside it.
|
||||
const withPods = seeded(
|
||||
'pods',
|
||||
async ({ page }) => {
|
||||
await seedWithRetry(() => seedPodMetricsViaSeeder(page));
|
||||
return { groupBy: NAMESPACE_ATTR };
|
||||
},
|
||||
undefined,
|
||||
{ timeout: SEED_TIMEOUT_MS },
|
||||
);
|
||||
|
||||
export const test = withPods(
|
||||
withTrace(withDashboard(withRouting(kit))),
|
||||
).extend<{
|
||||
/** The routing driver on an unauthenticated page. */
|
||||
guest: Routing;
|
||||
}>({
|
||||
guest: async ({ browser }, use) => {
|
||||
const ctx = await browser.newContext();
|
||||
await use(routingDriver(await ctx.newPage()));
|
||||
await ctx.close();
|
||||
},
|
||||
});
|
||||
|
||||
export { expect, onAdminPage } from './kit';
|
||||
@@ -1,27 +1,42 @@
|
||||
import { test as base, expect, type Page } from '@playwright/test';
|
||||
|
||||
import { ADMIN, storageStateFor, type User } from '../helpers/auth';
|
||||
import { installBasePathMode } from '../helpers/base-path';
|
||||
|
||||
// The login flow and the per-worker session cache live in `helpers/auth.ts` so
|
||||
// worker-scoped fixtures and suite hooks share one login with this fixture.
|
||||
export { ADMIN };
|
||||
export type { User };
|
||||
|
||||
export const test = base.extend<{
|
||||
/**
|
||||
* User identity for this test. Override with `test.use({ user: ... })` at
|
||||
* the describe or test level to run the suite as a different user.
|
||||
* Defaults to ADMIN (the pytest-bootstrap-seeded admin).
|
||||
*/
|
||||
user: User;
|
||||
export const test = base.extend<
|
||||
{
|
||||
/**
|
||||
* User identity for this test. Override with `test.use({ user: ... })` at
|
||||
* the describe or test level to run the suite as a different user.
|
||||
* Defaults to ADMIN (the pytest-bootstrap-seeded admin).
|
||||
*/
|
||||
user: User;
|
||||
|
||||
/**
|
||||
* A Page whose context is already authenticated as `user`. First request
|
||||
* for a given user triggers one login per worker; the resulting
|
||||
* storageState is held in memory and reused for all later requests.
|
||||
*/
|
||||
authedPage: Page;
|
||||
},
|
||||
{
|
||||
/** Applies SIGNOZ_E2E_BASE_PATH before any page or login in the worker. */
|
||||
basePathMode: void;
|
||||
}
|
||||
>({
|
||||
basePathMode: [
|
||||
async ({ browser }, use) => {
|
||||
await installBasePathMode(browser);
|
||||
await use();
|
||||
},
|
||||
{ scope: 'worker', auto: true },
|
||||
],
|
||||
|
||||
/**
|
||||
* A Page whose context is already authenticated as `user`. First request
|
||||
* for a given user triggers one login per worker; the resulting
|
||||
* storageState is held in memory and reused for all later requests.
|
||||
*/
|
||||
authedPage: Page;
|
||||
}>({
|
||||
user: [ADMIN, { option: true }],
|
||||
|
||||
authedPage: async ({ browser, user }, use) => {
|
||||
|
||||
@@ -72,6 +72,7 @@ export async function gotoAlertList(
|
||||
await page.goto(`${ALERTS_LIST_PATH}?${query.toString()}`);
|
||||
await expect(page.getByTestId('list-alerts-search-input')).toBeVisible();
|
||||
if (expectRows) {
|
||||
await expect(alertRuleRows(page).first()).toBeVisible();
|
||||
// Skeleton rows are empty and ignore clicks; wait for a loaded one.
|
||||
await expect(alertRuleRows(page).first()).toHaveText(/\S/);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { Browser, BrowserContext, Page } from '@playwright/test';
|
||||
|
||||
import { storageKey } from './base-path';
|
||||
|
||||
export type User = { email: string; password: string };
|
||||
|
||||
/** Default user — admin from the pytest bootstrap (.env.local) or staging .env. */
|
||||
@@ -57,7 +59,8 @@ async function login(page: Page, user: User): Promise<void> {
|
||||
async function pinSidenav(page: Page): Promise<void> {
|
||||
const token = await page.evaluate(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
() => (globalThis as any).localStorage.getItem('AUTH_TOKEN') || '',
|
||||
(key) => (globalThis as any).localStorage.getItem(key) || '',
|
||||
storageKey('AUTH_TOKEN'),
|
||||
);
|
||||
const res = await page.request.put('/api/v1/user/preferences/sidenav_pinned', {
|
||||
data: { value: true },
|
||||
|
||||
105
tests/e2e/helpers/base-path.ts
Normal file
105
tests/e2e/helpers/base-path.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import type { APIRequestContext, Browser, Page } from '@playwright/test';
|
||||
|
||||
// Base path mode: with SIGNOZ_E2E_BASE_PATH set (`pnpm env:start:base-path`
|
||||
// writes `/signoz`), specs run unchanged against SigNoz served under that URL
|
||||
// prefix. `page.goto`, `page.waitForURL` string targets and `request.*` take
|
||||
// app paths and get the prefix; `page.url()` and `waitForURL` predicates see
|
||||
// the URL without it. `expect(page).toHaveURL` still sees the real URL.
|
||||
|
||||
export const BASE_PATH = (process.env.SIGNOZ_E2E_BASE_PATH ?? '').replace(
|
||||
/\/$/,
|
||||
'',
|
||||
);
|
||||
|
||||
/** The app scopes its localStorage keys to the base path (`utils/storage.ts`). */
|
||||
export function storageKey(key: string): string {
|
||||
return BASE_PATH ? `${BASE_PATH}/${key}` : key;
|
||||
}
|
||||
|
||||
function toServedPath(path: string): string {
|
||||
return path.startsWith('/') && !path.startsWith('//')
|
||||
? `${BASE_PATH}${path}`
|
||||
: path;
|
||||
}
|
||||
|
||||
function toAppUrl(href: string): string {
|
||||
const url = new URL(href);
|
||||
if (url.pathname === BASE_PATH || url.pathname.startsWith(`${BASE_PATH}/`)) {
|
||||
url.pathname = url.pathname.slice(BASE_PATH.length) || '/';
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function patchMethod<T extends object, K extends keyof T>(
|
||||
proto: T,
|
||||
name: K,
|
||||
wrap: (original: T[K]) => T[K],
|
||||
): void {
|
||||
proto[name] = wrap(proto[name]);
|
||||
}
|
||||
|
||||
let installed = false;
|
||||
|
||||
/**
|
||||
* Patches the Page and APIRequestContext prototypes once per worker. Playwright
|
||||
* exports neither class, so the prototypes come from a throwaway page.
|
||||
*/
|
||||
export async function installBasePathMode(browser: Browser): Promise<void> {
|
||||
if (!BASE_PATH || installed) {
|
||||
return;
|
||||
}
|
||||
installed = true;
|
||||
|
||||
const context = await browser.newContext();
|
||||
const page = await context.newPage();
|
||||
const pageProto: Page = Object.getPrototypeOf(page);
|
||||
const requestProto: APIRequestContext = Object.getPrototypeOf(page.request);
|
||||
await context.close();
|
||||
|
||||
patchMethod(
|
||||
pageProto,
|
||||
'goto',
|
||||
(goto) =>
|
||||
function (this: Page, target, options) {
|
||||
return goto.call(this, toServedPath(target), options);
|
||||
},
|
||||
);
|
||||
|
||||
patchMethod(
|
||||
pageProto,
|
||||
'url',
|
||||
(url) =>
|
||||
function (this: Page) {
|
||||
return toAppUrl(url.call(this));
|
||||
},
|
||||
);
|
||||
|
||||
patchMethod(
|
||||
pageProto,
|
||||
'waitForURL',
|
||||
(waitForURL) =>
|
||||
function (this: Page, matcher, options) {
|
||||
let appMatcher = matcher;
|
||||
if (typeof matcher === 'function') {
|
||||
appMatcher = (current: URL): boolean =>
|
||||
matcher(new URL(toAppUrl(current.href)));
|
||||
} else if (typeof matcher === 'string') {
|
||||
appMatcher = toServedPath(matcher);
|
||||
}
|
||||
return waitForURL.call(this, appMatcher, options);
|
||||
},
|
||||
);
|
||||
|
||||
patchMethod(
|
||||
requestProto,
|
||||
'fetch',
|
||||
(fetch) =>
|
||||
function (this: APIRequestContext, target, options) {
|
||||
return fetch.call(
|
||||
this,
|
||||
typeof target === 'string' ? toServedPath(target) : target,
|
||||
options,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { Page, Request } from '@playwright/test';
|
||||
|
||||
import { storageKey } from './base-path';
|
||||
|
||||
// Shared helpers used across feature-specific helper modules (dashboards,
|
||||
// trace-details, …). Keep this to genuinely cross-feature utilities.
|
||||
|
||||
@@ -45,6 +47,8 @@ const HARNESS_CONSOLE_NOISE = [
|
||||
'Request failed with status code 404',
|
||||
'client never received a response, or request never left',
|
||||
'ErrorResponseHandler: unclassified error',
|
||||
// Google Fonts, which the chromium project fails at DNS (playwright.config.ts).
|
||||
'Failed to load resource: net::ERR_NAME_NOT_RESOLVED',
|
||||
];
|
||||
|
||||
export interface ConsoleWatch {
|
||||
@@ -130,7 +134,9 @@ export function requestUrl(request: Request): URL {
|
||||
export async function authToken(page: Page): Promise<string> {
|
||||
const state = await page.context().storageState();
|
||||
for (const origin of state.origins) {
|
||||
const entry = origin.localStorage.find((e) => e.name === 'AUTH_TOKEN');
|
||||
const entry = origin.localStorage.find(
|
||||
(e) => e.name === storageKey('AUTH_TOKEN'),
|
||||
);
|
||||
if (entry) {
|
||||
return entry.value;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import apmMetricsTemplate from '../testdata/apm-metrics.json';
|
||||
import chartDataTemplate from '../testdata/chart-data-dashboard.json';
|
||||
import variablesTemplate from '../testdata/variables-dashboard.json';
|
||||
|
||||
import { storageKey } from './base-path';
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────────
|
||||
//
|
||||
// UI strings and well-known values referenced both within this file and by
|
||||
@@ -45,7 +47,8 @@ export async function authToken(page: Page): Promise<string> {
|
||||
}
|
||||
return page.evaluate(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
() => (globalThis as any).localStorage.getItem('AUTH_TOKEN') || '',
|
||||
(key) => (globalThis as any).localStorage.getItem(key) || '',
|
||||
storageKey('AUTH_TOKEN'),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -82,12 +85,39 @@ async function postDashboard(
|
||||
return json.data.id;
|
||||
}
|
||||
|
||||
/** Seed a minimally-named dashboard via API. Returns the new ID. */
|
||||
/**
|
||||
* Seed a minimally-named dashboard via API. Returns the new ID.
|
||||
*
|
||||
* v2 (Perses spec) only: `POST /api/v1/dashboards` now answers 501
|
||||
* `dashboard_deprecated`. Payload mirrors what the New-dashboard modal sends
|
||||
* (`BlankDashboardPanel.tsx`), `schemaVersion` included.
|
||||
*/
|
||||
export async function createDashboardViaApi(
|
||||
page: Page,
|
||||
title: string,
|
||||
): Promise<string> {
|
||||
return postDashboard(page, { title, uploadedGrafana: false });
|
||||
const token = await authToken(page);
|
||||
const res = await page.request.post('/api/v2/dashboards', {
|
||||
data: {
|
||||
schemaVersion: 'v6',
|
||||
generateName: true,
|
||||
tags: null,
|
||||
spec: {
|
||||
display: { name: title },
|
||||
layouts: [],
|
||||
panels: {},
|
||||
variables: [],
|
||||
},
|
||||
},
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(
|
||||
`POST /api/v2/dashboards ${res.status()}: ${await res.text()}`,
|
||||
);
|
||||
}
|
||||
const json = (await res.json()) as { data: { id: string } };
|
||||
return json.data.id;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -342,7 +372,7 @@ export async function deleteDashboardViaApi(
|
||||
token: string,
|
||||
): Promise<void> {
|
||||
await request
|
||||
.delete(`/api/v1/dashboards/${id}`, {
|
||||
.delete(`/api/v2/dashboards/${id}`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
.catch(() => undefined);
|
||||
|
||||
110
tests/e2e/helpers/infra-monitoring.ts
Normal file
110
tests/e2e/helpers/infra-monitoring.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import type { Page } from '@playwright/test';
|
||||
|
||||
import { seederUrl } from './common';
|
||||
|
||||
// Seeding for the Kubernetes lists under /infrastructure-monitoring.
|
||||
|
||||
/**
|
||||
* The metric the pods list queries. Its rows are what put the pod resource
|
||||
* attributes into `/fields/keys`, which is where the group-by select gets its
|
||||
* options, so this is the metric to seed, not an arbitrary one.
|
||||
*
|
||||
* Dotted, not `k8s_pod_cpu_usage`: the list passes this name straight through as
|
||||
* `metricNamespace` and the backend matches it literally, so the underscored
|
||||
* spelling returns an empty key set and the group-by select renders "No data".
|
||||
*/
|
||||
const POD_CPU_METRIC = 'k8s.pod.cpu.usage';
|
||||
const POD_MEMORY_METRIC = 'k8s.pod.memory.usage';
|
||||
|
||||
const NAMESPACES = ['default', 'kube-system', 'signoz'];
|
||||
const PODS_PER_NAMESPACE = 2;
|
||||
const POINTS_PER_POD = 4;
|
||||
const POINT_INTERVAL_MS = 60_000;
|
||||
|
||||
type SeededMetric = {
|
||||
metric_name: string;
|
||||
timestamp: string;
|
||||
value: number;
|
||||
type_: string;
|
||||
temporality: string;
|
||||
is_monotonic: boolean;
|
||||
unit: string;
|
||||
resource_attrs: Record<string, string>;
|
||||
labels: Record<string, string>;
|
||||
};
|
||||
|
||||
function podResourceAttrs(
|
||||
namespace: string,
|
||||
pod: string,
|
||||
): Record<string, string> {
|
||||
return {
|
||||
'k8s.cluster.name': 'e2e-cluster',
|
||||
'k8s.namespace.name': namespace,
|
||||
'k8s.node.name': `node-${namespace}`,
|
||||
'k8s.pod.name': pod,
|
||||
'k8s.pod.uid': `${namespace}-${pod}`,
|
||||
};
|
||||
}
|
||||
|
||||
function podPoints(now: number): SeededMetric[] {
|
||||
const points: SeededMetric[] = [];
|
||||
for (const namespace of NAMESPACES) {
|
||||
for (let p = 0; p < PODS_PER_NAMESPACE; p += 1) {
|
||||
const pod = `${namespace}-pod-${p}`;
|
||||
const resourceAttrs = podResourceAttrs(namespace, pod);
|
||||
for (let i = 0; i < POINTS_PER_POD; i += 1) {
|
||||
const timestamp = new Date(
|
||||
now - (POINTS_PER_POD - i) * POINT_INTERVAL_MS,
|
||||
).toISOString();
|
||||
points.push({
|
||||
metric_name: POD_CPU_METRIC,
|
||||
timestamp,
|
||||
value: 0.1 + i * 0.05,
|
||||
type_: 'Gauge',
|
||||
temporality: 'Unspecified',
|
||||
is_monotonic: false,
|
||||
unit: '1',
|
||||
resource_attrs: resourceAttrs,
|
||||
labels: resourceAttrs,
|
||||
});
|
||||
points.push({
|
||||
metric_name: POD_MEMORY_METRIC,
|
||||
timestamp,
|
||||
value: 100_000_000 + i * 1_000_000,
|
||||
type_: 'Gauge',
|
||||
temporality: 'Unspecified',
|
||||
is_monotonic: false,
|
||||
unit: 'By',
|
||||
resource_attrs: resourceAttrs,
|
||||
labels: resourceAttrs,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return points;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed pod metrics so the k8s list has rows and its group-by select has
|
||||
* options. Both matter: with no rows inside the queried window the select
|
||||
* renders empty and every `setK8sGroupBy` call times out.
|
||||
*
|
||||
* The pod keys go in `labels` as well as `resource_attrs`: the list's queries
|
||||
* reference some of them as `tag` and others as `resource`, and the series
|
||||
* fingerprint is computed off labels alone. With an empty label map all six
|
||||
* pods collapse into one series and the list comes back empty.
|
||||
*
|
||||
* Wrap the call in `seedWithRetry`: the seeder serves inserts on one
|
||||
* ClickHouse session, so parallel workers collide with a transient 500.
|
||||
*/
|
||||
export async function seedPodMetricsViaSeeder(page: Page): Promise<void> {
|
||||
const res = await page.request.post(`${seederUrl()}/telemetry/metrics`, {
|
||||
data: podPoints(Date.now()),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(
|
||||
`seeder POST /telemetry/metrics ${res.status()}: ${await res.text()}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ export const LOGS_EXPLORER_PATH = '/logs/logs-explorer';
|
||||
|
||||
const SETTINGS_PANEL_TITLE = 'Edit quick filters';
|
||||
const SETTINGS_ICON_TEST_ID = 'settings-icon';
|
||||
const SETTINGS_CLOSE_TEST_ID = 'quick-filters-settings-close';
|
||||
const ANNOUNCEMENT_DISMISSED_KEY = 'QUICK_FILTERS_SETTINGS_ANNOUNCEMENT';
|
||||
/**
|
||||
* Conservative lower bound for the open panel width. The panel is 342px in
|
||||
@@ -127,7 +128,7 @@ export async function assertQuickFiltersSettingsDrawerOpen(
|
||||
await expect(panel).not.toHaveClass(/hidden/);
|
||||
await expect(page.getByText(SETTINGS_PANEL_TITLE)).toBeVisible();
|
||||
await expect(page.getByPlaceholder('Search for a filter...')).toBeVisible();
|
||||
await expect(panel.locator('.qf-header-icon')).toBeVisible();
|
||||
await expect(panel.getByTestId(SETTINGS_CLOSE_TEST_ID)).toBeVisible();
|
||||
|
||||
const addedList = panel.locator('.qf-added-filters-list');
|
||||
await expect(panel.getByText('ADDED FILTERS')).toBeVisible();
|
||||
@@ -161,6 +162,6 @@ export async function assertQuickFiltersSettingsDrawerOpen(
|
||||
export async function closeQuickFiltersSettingsPanel(
|
||||
page: Page,
|
||||
): Promise<void> {
|
||||
await settingsPanel(page).locator('.qf-header-icon').click();
|
||||
await settingsPanel(page).getByTestId(SETTINGS_CLOSE_TEST_ID).click();
|
||||
await assertQuickFiltersSettingsDrawerClosed(page);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"env:start": "cd .. && uv run pytest --basetemp=./tmp/ -vv --reuse --rebuild --capture=no --with-web e2e/bootstrap/setup.py::test_setup",
|
||||
"env:stop": "cd .. && uv run pytest --basetemp=./tmp/ -vv --teardown --capture=no e2e/bootstrap/setup.py::test_teardown",
|
||||
"env:start:base-path": "cd .. && uv run pytest --basetemp=./tmp/ -vv --reuse --rebuild --capture=no --with-web e2e/bootstrap/basepath/setup.py::test_setup",
|
||||
"env:stop:base-path": "cd .. && uv run pytest --basetemp=./tmp/ -vv --teardown --capture=no e2e/bootstrap/basepath/setup.py::test_teardown",
|
||||
"env:clean": "rm -rf ../tmp ../.pytest_cache .env.local artifacts && echo 'Cleaned. Run docker container prune if needed.'",
|
||||
"test": "playwright test",
|
||||
"test:local": "pnpm env:start && pnpm test",
|
||||
|
||||
@@ -108,7 +108,15 @@ export default defineConfig({
|
||||
},
|
||||
{
|
||||
name: 'chromium',
|
||||
use: devices['Desktop Chrome'],
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
launchOptions: {
|
||||
// Google Fonts CSS blocks the app script and `load` until it settles,
|
||||
// so a stalled request blanks the page. Failing DNS settles it at once;
|
||||
// a `route` would too, but routing turns off the HTTP cache.
|
||||
args: ['--host-resolver-rules=MAP fonts.googleapis.com ~NOTFOUND'],
|
||||
},
|
||||
},
|
||||
dependencies: ['setup'],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
v2SaveTooltip,
|
||||
v2TestButton,
|
||||
} from '../../../helpers/alert-forms/v2';
|
||||
import { BASE_PATH } from '../../../helpers/base-path';
|
||||
|
||||
// CV2-* — the v2 create builder.
|
||||
//
|
||||
@@ -446,7 +447,7 @@ test.describe('Alert create — v2 builder', () => {
|
||||
await ownedRules.register(response);
|
||||
|
||||
expect(response.status()).toBe(201);
|
||||
expect(new URL(response.url()).pathname).toBe('/api/v2/rules');
|
||||
expect(new URL(response.url()).pathname).toBe(`${BASE_PATH}/api/v2/rules`);
|
||||
|
||||
const body = response.request().postDataJSON();
|
||||
expect(body.schemaVersion).toBe('v2alpha1');
|
||||
|
||||
@@ -15,7 +15,8 @@ test.describe('Alert details — page chrome', () => {
|
||||
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
|
||||
|
||||
await gotoAlertHistory(page, alertHistory.ruleId);
|
||||
const expected = page.url();
|
||||
// The served URL: base path mode strips the prefix from `page.url()`.
|
||||
const expected = page.mainFrame().url();
|
||||
|
||||
await page.getByRole('button', { name: 'Copy link' }).click();
|
||||
await expect(page.getByText('Copied')).toBeVisible();
|
||||
|
||||
@@ -27,7 +27,8 @@ test.describe('Alerts page shell', () => {
|
||||
await page.goto(ALERTS_LIST_PATH);
|
||||
|
||||
// No `tab` param at all — `getActiveKey()` falls back to AlertRules.
|
||||
await expect(page).toHaveURL(ALERTS_LIST_PATH);
|
||||
await expect(page).toHaveURL(new RegExp(`${ALERTS_LIST_PATH}(\\?|$)`));
|
||||
await expect(page).not.toHaveURL(/[?&]tab=/);
|
||||
await expect(
|
||||
page.getByRole('tab', { name: TAB_NAMES.rules }),
|
||||
).toHaveAttribute('aria-selected', 'true');
|
||||
|
||||
45
tests/e2e/tests/routing/guards.spec.ts
Normal file
45
tests/e2e/tests/routing/guards.spec.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { expect, PATH, test } from '../../drivers/routing';
|
||||
|
||||
// `<Redirect>` replaces the history entry and v7's `<Navigate>` pushes unless
|
||||
// told otherwise, which only shows when someone presses Back. Runs as `guest`:
|
||||
// an authenticated page would skip the chain under test.
|
||||
|
||||
test.describe('Routing — auth guards', () => {
|
||||
test('TC-14 a deep-linked private route survives the login round trip', async ({
|
||||
guest,
|
||||
}) => {
|
||||
await guest.goto(PATH.services);
|
||||
await guest.waitForPath(PATH.login);
|
||||
|
||||
await guest.login();
|
||||
|
||||
// Private.tsx stashed the requested path and redirects back to it.
|
||||
await guest.waitForPath(PATH.services);
|
||||
expect(guest.url().pathname).toBe(PATH.services);
|
||||
});
|
||||
|
||||
test('TC-15 the login redirects replace, so Back does not re-run the chain', async ({
|
||||
guest,
|
||||
}) => {
|
||||
const depthBeforeGoto = await guest.historyDepth();
|
||||
|
||||
await guest.goto(PATH.services);
|
||||
await guest.waitForPath(PATH.login);
|
||||
|
||||
// A pushing redirect would read one higher.
|
||||
const depthAtLogin = await guest.historyDepth();
|
||||
expect(depthAtLogin).toBe(depthBeforeGoto + 1);
|
||||
|
||||
await guest.login();
|
||||
await guest.waitForPath(PATH.services);
|
||||
expect(await guest.historyDepth()).toBe(depthAtLogin + 1);
|
||||
|
||||
// Back reaches the replaced /login entry, where being logged in redirects
|
||||
// straight out again.
|
||||
await guest.goBack();
|
||||
await expect
|
||||
.poll(() => guest.url().pathname, { timeout: 15_000 })
|
||||
.not.toBe(PATH.login);
|
||||
expect(guest.url().pathname).toBe(PATH.services);
|
||||
});
|
||||
});
|
||||
49
tests/e2e/tests/routing/mount.spec.ts
Normal file
49
tests/e2e/tests/routing/mount.spec.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { expect, PATH, test } from '../../drivers/routing';
|
||||
|
||||
// The shape of the router mount, which no other spec observes.
|
||||
|
||||
test.describe('Routing: router mount', () => {
|
||||
// Under a React transition the previous screen stays up instead of the
|
||||
// Suspense fallback, so an uncached route renders no loader at all.
|
||||
test('TC-18 an in-app navigation to an uncached route commits the Suspense fallback', async ({
|
||||
authedPage: page,
|
||||
routing,
|
||||
}) => {
|
||||
await routing.dashboards.gotoList();
|
||||
|
||||
await routing.whileScriptsHeld(async () => {
|
||||
await routing.sidebarItem('Alerts').click();
|
||||
await routing.waitForPath(PATH.alerts);
|
||||
|
||||
await expect(routing.dashboards.landmark()).toBeHidden();
|
||||
await expect(
|
||||
page.getByRole('img', { name: 'loading' }).first(),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
await expect(
|
||||
page
|
||||
.getByRole('heading', { name: 'Alert Rules' })
|
||||
.or(page.getByTestId('list-alerts-search-input')),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
// nuqs' react-router adapter needs a router above it; the wrong order throws
|
||||
// on the first page that reads a nuqs param.
|
||||
test('TC-19 a page with nuqs params mounts with no router-context error', async ({
|
||||
authedPage: page,
|
||||
routing,
|
||||
}) => {
|
||||
const reported: string[] = [];
|
||||
page.on('pageerror', (error) => reported.push(error.message));
|
||||
page.on('console', (message) => reported.push(message.text()));
|
||||
|
||||
await routing.k8s.goto({ relativeTime: '30m' });
|
||||
|
||||
expect(
|
||||
reported.filter((text) =>
|
||||
/useNavigate|useSearchParams|context of a <Router>/i.test(text),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
62
tests/e2e/tests/routing/nav-blocker.spec.ts
Normal file
62
tests/e2e/tests/routing/nav-blocker.spec.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
expect,
|
||||
onAdminPage,
|
||||
PATH,
|
||||
routingDriver,
|
||||
test,
|
||||
uniqueAlphaSuffix,
|
||||
} from '../../drivers/routing';
|
||||
|
||||
// `/settings/roles/new` is the only `useNavigationBlocker` consumer, so this is
|
||||
// the whole observable surface of the blocker protocol.
|
||||
|
||||
// Both tests drive the create form; keep them off each other's role names.
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
const BLOCKED_ROLE_NAME = `routing-blocker-${uniqueAlphaSuffix()}`;
|
||||
const SAVED_ROLE_NAME = `routing-bypass-${uniqueAlphaSuffix()}`;
|
||||
|
||||
test.describe('Routing — unsaved-changes navigation blocker', () => {
|
||||
test.afterAll(async ({ browser }) => {
|
||||
await onAdminPage(browser, (page) =>
|
||||
routingDriver(page).roles.removeByName([BLOCKED_ROLE_NAME, SAVED_ROLE_NAME]),
|
||||
);
|
||||
});
|
||||
|
||||
test('TC-16 a blocked navigation can be cancelled and then confirmed', async ({
|
||||
routing,
|
||||
}) => {
|
||||
const { roles } = routing;
|
||||
await roles.openCreateForm(BLOCKED_ROLE_NAME);
|
||||
|
||||
await roles.cancel();
|
||||
await expect(roles.discardDialog()).toBeVisible();
|
||||
|
||||
// Cancel drops the blocked transition rather than retrying it.
|
||||
await roles
|
||||
.discardDialog()
|
||||
.getByRole('button', { name: 'Keep editing' })
|
||||
.click();
|
||||
await expect(roles.discardDialog()).toBeHidden();
|
||||
expect(routing.url().pathname).toBe(PATH.roleCreate);
|
||||
await expect(roles.nameInput()).toHaveValue(BLOCKED_ROLE_NAME);
|
||||
|
||||
await roles.cancel();
|
||||
await roles.discardDialog().getByRole('button', { name: 'Discard' }).click();
|
||||
await routing.waitForPath(PATH.roles);
|
||||
});
|
||||
|
||||
test('TC-17 saving bypasses the blocker for the next navigation', async ({
|
||||
routing,
|
||||
}) => {
|
||||
const { roles } = routing;
|
||||
await roles.openCreateForm(SAVED_ROLE_NAME);
|
||||
|
||||
// `allowNextNavigation()` unarms the blocker for the push after a save.
|
||||
await roles.save();
|
||||
await routing.waitForPath(PATH.roles);
|
||||
|
||||
await expect(roles.discardDialog()).toHaveCount(0);
|
||||
await expect(roles.settings()).toBeVisible();
|
||||
});
|
||||
});
|
||||
87
tests/e2e/tests/routing/navigation.spec.ts
Normal file
87
tests/e2e/tests/routing/navigation.spec.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { dashboardListParams, expect, PATH, test } from '../../drivers/routing';
|
||||
|
||||
// Imperative navigation: the `history.listen` counter behind `hasInAppHistory()`,
|
||||
// `useSafeNavigate`'s same-url suppression, and its `newTab` branch.
|
||||
|
||||
test.describe('Routing — imperative navigation', () => {
|
||||
test('TC-05 Back from a detail page restores the list with its params', async ({
|
||||
routing,
|
||||
dashboard,
|
||||
}) => {
|
||||
await routing.dashboards.gotoList(dashboard.title);
|
||||
await routing.dashboards.row(dashboard.title).click();
|
||||
await routing.waitForPath(dashboard.path);
|
||||
|
||||
await routing.goBackTo(PATH.dashboards);
|
||||
|
||||
expect(routing.params()).toMatchObject(dashboardListParams(dashboard.title));
|
||||
});
|
||||
|
||||
// Both branches of the trace header's Back button in one document:
|
||||
// `hasInAppHistory() ? goBack() : push(TRACES_EXPLORER)`.
|
||||
test('TC-06 the trace-details previous button pushes when deep-linked and pops once in-app', async ({
|
||||
routing,
|
||||
largeTrace: trace,
|
||||
}) => {
|
||||
await routing.goto(PATH.services);
|
||||
await routing.trace.goto(trace);
|
||||
|
||||
// The document load reset the counter, so Back pushes the explorer.
|
||||
const depthBeforePush = await routing.historyDepth();
|
||||
await routing.trace.back();
|
||||
await routing.waitForPath(PATH.tracesExplorer);
|
||||
expect(routing.url().searchParams.has('selectedExplorerView')).toBe(false);
|
||||
expect(await routing.historyDepth()).toBe(depthBeforePush + 1);
|
||||
|
||||
// That push is in-app history, so after a POP back Back pops too.
|
||||
await routing.goBackTo(routing.trace.path(trace));
|
||||
await expect(routing.trace.rootRow(trace)).toBeVisible();
|
||||
|
||||
const depthBeforePop = await routing.historyDepth();
|
||||
await routing.trace.back();
|
||||
await routing.waitForPath(PATH.services);
|
||||
expect(await routing.historyDepth()).toBe(depthBeforePop);
|
||||
});
|
||||
|
||||
test('TC-07 re-selecting the current relative time adds no history entry', async ({
|
||||
routing,
|
||||
}) => {
|
||||
test.slow();
|
||||
|
||||
await routing.k8s.goto({ relativeTime: '30m' });
|
||||
await routing.expectRelativeTime('30m');
|
||||
const before = routing.params();
|
||||
const depthBefore = await routing.historyDepth();
|
||||
|
||||
await routing.selectRelativeTime('30m');
|
||||
|
||||
// `areUrlsEffectivelySame` ignores the regenerated compositeQuery `id`. Not
|
||||
// followed by "one Back leaves the page": the k8s list pushes its own
|
||||
// compositeQuery rewrite on mount.
|
||||
expect(await routing.historyDepth()).toBe(depthBefore);
|
||||
expect(routing.url().pathname).toBe(PATH.k8s);
|
||||
expect(routing.params()).toMatchObject({
|
||||
relativeTime: '30m',
|
||||
category: before.category,
|
||||
});
|
||||
await routing.expectRelativeTime('30m');
|
||||
});
|
||||
|
||||
test('TC-08 modifier-clicking an in-app link opens the same url in a new tab', async ({
|
||||
routing,
|
||||
dashboard,
|
||||
}) => {
|
||||
await routing.dashboards.gotoList(dashboard.title);
|
||||
|
||||
await routing.inNewTab(
|
||||
routing.dashboards.row(dashboard.title),
|
||||
async (tab) => {
|
||||
expect(tab.url().pathname).toBe(dashboard.path);
|
||||
await expect(tab.dashboards.title()).toHaveText(dashboard.title);
|
||||
},
|
||||
);
|
||||
|
||||
expect(routing.url().pathname).toBe(PATH.dashboards);
|
||||
expect(routing.params()).toMatchObject(dashboardListParams(dashboard.title));
|
||||
});
|
||||
});
|
||||
84
tests/e2e/tests/routing/route-matching.spec.ts
Normal file
84
tests/e2e/tests/routing/route-matching.spec.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { expect, PATH, test } from '../../drivers/routing';
|
||||
|
||||
// Every place a `matchPath` / `generatePath` call decides what renders:
|
||||
// `RouteTab`, `TopNav`'s per-route checks, and `SETTINGS`, a prefix route that
|
||||
// needs an explicit `/*` under v7.
|
||||
|
||||
test.describe('Routing — route matching', () => {
|
||||
test('TC-09 RouteTab switches the url and survives a reload', async ({
|
||||
routing,
|
||||
}) => {
|
||||
await routing.goto(PATH.metricsSummary);
|
||||
await expect(routing.activeRouteTab(PATH.metricsSummary)).toBeVisible();
|
||||
|
||||
await routing.routeTab(PATH.metricsViews).click();
|
||||
await routing.waitForPath(PATH.metricsViews);
|
||||
await expect(routing.activeRouteTab(PATH.metricsViews)).toBeVisible();
|
||||
|
||||
await routing.reload();
|
||||
expect(routing.url().pathname).toBe(PATH.metricsViews);
|
||||
await expect(routing.activeRouteTab(PATH.metricsViews)).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-10 a prefix-route deep link loads with the right tab selected', async ({
|
||||
authedPage: page,
|
||||
routing,
|
||||
}) => {
|
||||
await routing.goto(PATH.roles);
|
||||
|
||||
expect(routing.url().pathname).toBe(PATH.roles);
|
||||
await expect(page.getByTestId('settings-page-sidenav')).toBeVisible();
|
||||
await expect(page.getByTestId('roles')).toHaveClass(/active/);
|
||||
await expect(routing.roles.settings()).toBeVisible();
|
||||
});
|
||||
|
||||
test('TC-11 a seeded dashboard id round-trips byte-identical', async ({
|
||||
routing,
|
||||
dashboard,
|
||||
}) => {
|
||||
await routing.dashboards.gotoList(dashboard.title);
|
||||
await routing.dashboards.row(dashboard.title).click();
|
||||
|
||||
// Exact match: any re-encoding of the uuid fails here.
|
||||
await routing.waitForPath(dashboard.path);
|
||||
await expect(routing.dashboards.title()).toHaveText(dashboard.title);
|
||||
|
||||
await routing.reload();
|
||||
expect(routing.url().pathname).toBe(dashboard.path);
|
||||
await expect(routing.dashboards.title()).toHaveText(dashboard.title);
|
||||
});
|
||||
|
||||
test('TC-12 the global time picker renders only where matchPath allows it', async ({
|
||||
routing,
|
||||
}) => {
|
||||
await routing.goto(`${PATH.services}?relativeTime=30m`);
|
||||
await expect(routing.globalTimePicker()).toBeVisible();
|
||||
|
||||
// LOGS_EXPLORER is in `routesToDisable`.
|
||||
await routing.gotoLogsExplorer();
|
||||
await expect(routing.globalTimePicker()).toHaveCount(0);
|
||||
|
||||
// ROLES_SETTINGS is in `routesToSkip`.
|
||||
await routing.goto(PATH.roles);
|
||||
await expect(routing.roles.settings()).toBeVisible();
|
||||
await expect(routing.globalTimePicker()).toHaveCount(0);
|
||||
});
|
||||
|
||||
// `PrivateRoute` redirects a logged-in user on an unknown path to HOME before
|
||||
// the catch-all runs, so `NotFound` is unreachable while authenticated.
|
||||
test('TC-13 an unknown path redirects home and replaces its history entry', async ({
|
||||
authedPage: page,
|
||||
routing,
|
||||
}) => {
|
||||
await routing.dashboards.gotoList();
|
||||
const depthBefore = await routing.historyDepth();
|
||||
|
||||
await routing.goto(PATH.unknown);
|
||||
await routing.waitForPath(PATH.home);
|
||||
await expect(page.getByTestId('not-found')).toHaveCount(0);
|
||||
|
||||
// The redirect replaced the entry, so one Back reaches the list.
|
||||
expect(await routing.historyDepth()).toBe(depthBefore + 1);
|
||||
await routing.goBackTo(PATH.dashboards);
|
||||
});
|
||||
});
|
||||
83
tests/e2e/tests/routing/url-state.spec.ts
Normal file
83
tests/e2e/tests/routing/url-state.spec.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { expect, PATH, test } from '../../drivers/routing';
|
||||
|
||||
// The nuqs adapter swap. On the k8s list `groupBy` is written by nuqs straight
|
||||
// to the History API with `history: 'push'`, while `relativeTime` is written by
|
||||
// the router through `useSafeNavigate`.
|
||||
|
||||
test.describe('Routing — nuqs and router param coexistence', () => {
|
||||
test('TC-01 a router write keeps the nuqs params it did not author', async ({
|
||||
routing,
|
||||
pods,
|
||||
}) => {
|
||||
await routing.k8s.goto({ relativeTime: '30m' });
|
||||
|
||||
await routing.k8s.groupBy(pods.groupBy);
|
||||
await routing.selectRelativeTime('1h');
|
||||
await routing.waitForParam('relativeTime', '1h');
|
||||
|
||||
expect(routing.url().pathname).toBe(PATH.k8s);
|
||||
expect(routing.params()).toMatchObject({
|
||||
relativeTime: '1h',
|
||||
category: 'pods',
|
||||
});
|
||||
expect(routing.params().groupBy).toContain(pods.groupBy);
|
||||
await expect(routing.k8s.groupBySelect()).toContainText(pods.groupBy);
|
||||
});
|
||||
|
||||
test('TC-02 a nuqs write keeps the relativeTime the router authored', async ({
|
||||
routing,
|
||||
pods,
|
||||
}) => {
|
||||
await routing.k8s.goto({ relativeTime: '30m' });
|
||||
|
||||
await routing.selectRelativeTime('6h');
|
||||
await routing.waitForParam('relativeTime', '6h');
|
||||
|
||||
await routing.k8s.groupBy(pods.groupBy);
|
||||
|
||||
expect(routing.url().pathname).toBe(PATH.k8s);
|
||||
expect(routing.params().relativeTime).toBe('6h');
|
||||
expect(routing.params().groupBy).toContain(pods.groupBy);
|
||||
await routing.expectRelativeTime('6h');
|
||||
});
|
||||
|
||||
test('TC-03 POP across a nuqs push reverts both url and list', async ({
|
||||
routing,
|
||||
pods,
|
||||
}) => {
|
||||
await routing.k8s.goto({ relativeTime: '30m' });
|
||||
|
||||
// One flush, one entry: otherwise one Back lands on an intermediate url.
|
||||
const depthBeforeGroupBy = await routing.historyDepth();
|
||||
await routing.k8s.groupBy(pods.groupBy);
|
||||
expect(await routing.historyDepth()).toBe(depthBeforeGroupBy + 1);
|
||||
await expect(routing.k8s.groupBySelect()).toContainText(pods.groupBy);
|
||||
|
||||
await routing.goBack();
|
||||
await routing.k8s.waitUntilGrouped(pods.groupBy, false);
|
||||
expect(routing.url().pathname).toBe(PATH.k8s);
|
||||
await expect(routing.k8s.groupBySelect()).not.toContainText(pods.groupBy);
|
||||
|
||||
await routing.goForward();
|
||||
await routing.k8s.waitUntilGrouped(pods.groupBy);
|
||||
await expect(routing.k8s.groupBySelect()).toContainText(pods.groupBy);
|
||||
});
|
||||
|
||||
test('TC-04 a deep link carrying both families applies both on first paint', async ({
|
||||
routing,
|
||||
pods,
|
||||
}) => {
|
||||
await routing.k8s.goto({
|
||||
relativeTime: '6h',
|
||||
groupBy: JSON.stringify([pods.groupBy]),
|
||||
});
|
||||
|
||||
// `k8s.goto` waited for the mount-time republish, so anything it dropped is
|
||||
// missing by now.
|
||||
expect(routing.url().pathname).toBe(PATH.k8s);
|
||||
expect(routing.params().relativeTime).toBe('6h');
|
||||
expect(routing.params().groupBy).toContain(pods.groupBy);
|
||||
await routing.expectRelativeTime('6h');
|
||||
await expect(routing.k8s.groupBySelect()).toContainText(pods.groupBy);
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,6 @@
|
||||
"outDir": "./dist",
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["tests/**/*.ts", "helpers/**/*.ts", "fixtures/**/*.ts", "bootstrap/**/*.ts", "playwright.config.ts"],
|
||||
"include": ["tests/**/*.ts", "drivers/**/*.ts", "helpers/**/*.ts", "fixtures/**/*.ts", "bootstrap/**/*.ts", "playwright.config.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
||||
53
tests/fixtures/auth.py
vendored
53
tests/fixtures/auth.py
vendored
@@ -36,28 +36,6 @@ USERS_BASE = "/api/v2/users"
|
||||
USER_ROLES_BASE = "/api/v2/user_roles"
|
||||
|
||||
|
||||
def _login(signoz: types.SigNoz, email: str, password: str) -> str:
|
||||
"""Complete GET /sessions/context + POST /sessions/email_password; return accessToken."""
|
||||
ctx = requests.get(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/sessions/context"),
|
||||
params={
|
||||
"email": email,
|
||||
"ref": f"{signoz.self.host_configs['8080'].base()}",
|
||||
},
|
||||
timeout=5,
|
||||
)
|
||||
assert ctx.status_code == HTTPStatus.OK
|
||||
org_id = ctx.json()["data"]["orgs"][0]["id"]
|
||||
|
||||
login = requests.post(
|
||||
signoz.self.host_configs["8080"].get("/api/v2/sessions/email_password"),
|
||||
json={"email": email, "password": password, "orgId": org_id},
|
||||
timeout=5,
|
||||
)
|
||||
assert login.status_code == HTTPStatus.OK
|
||||
return login.json()["data"]["accessToken"]
|
||||
|
||||
|
||||
def register_admin(
|
||||
signoz: types.SigNoz,
|
||||
request: pytest.FixtureRequest,
|
||||
@@ -182,16 +160,14 @@ def get_tokens(signoz: types.SigNoz) -> Callable[[str, str], tuple[str, str]]:
|
||||
return tokens_getter(signoz)
|
||||
|
||||
|
||||
@pytest.fixture(name="apply_license", scope="package")
|
||||
def apply_license(
|
||||
def license_applier(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument,redefined-outer-name
|
||||
request: pytest.FixtureRequest,
|
||||
pytestconfig: pytest.Config,
|
||||
cache_key: str = "apply_license",
|
||||
base_path: str = "",
|
||||
) -> types.Operation:
|
||||
"""Stub Zeus license-lookup, then POST /api/v4/licenses so the BE flips
|
||||
to ENTERPRISE. Package-scoped so an e2e bootstrap can pull it in and
|
||||
every spec inherits the licensed state."""
|
||||
"""Apply the ENTERPRISE license and complete org onboarding, under base_path. Reuse-wrapped."""
|
||||
|
||||
def create() -> types.Operation:
|
||||
Config.base_url = signoz.zeus.host_configs["8080"].get("/__admin")
|
||||
@@ -224,12 +200,12 @@ def apply_license(
|
||||
)
|
||||
)
|
||||
|
||||
access_token = _login(signoz, USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
access_token = token_getter(signoz, base_path)(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
# 201 = applied, 409 = already applied. Retry transient failures —
|
||||
# the BE occasionally 5xxs right after startup before the license
|
||||
# sync goroutine is ready.
|
||||
license_url = signoz.self.host_configs["8080"].get("/api/v4/licenses")
|
||||
license_url = signoz.self.host_configs["8080"].get(f"{base_path}/api/v4/licenses")
|
||||
auth_header = {"Authorization": f"Bearer {access_token}"}
|
||||
for attempt in range(10):
|
||||
resp = requests.post(
|
||||
@@ -248,7 +224,7 @@ def apply_license(
|
||||
# redirects first-time admins to a questionnaire. Mark the preference
|
||||
# complete so specs can navigate directly to the feature under test.
|
||||
pref_resp = requests.put(
|
||||
signoz.self.host_configs["8080"].get("/api/v1/org/preferences/org_onboarding"),
|
||||
signoz.self.host_configs["8080"].get(f"{base_path}/api/v1/org/preferences/org_onboarding"),
|
||||
json={"value": True},
|
||||
headers=auth_header,
|
||||
timeout=5,
|
||||
@@ -265,7 +241,7 @@ def apply_license(
|
||||
return reuse.wrap(
|
||||
request,
|
||||
pytestconfig,
|
||||
"apply_license",
|
||||
cache_key,
|
||||
lambda: types.Operation(name=""),
|
||||
create,
|
||||
delete,
|
||||
@@ -273,6 +249,19 @@ def apply_license(
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(name="apply_license", scope="package")
|
||||
def apply_license(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: types.Operation, # pylint: disable=unused-argument,redefined-outer-name
|
||||
request: pytest.FixtureRequest,
|
||||
pytestconfig: pytest.Config,
|
||||
) -> types.Operation:
|
||||
"""Stub Zeus license-lookup, then POST /api/v4/licenses so the BE flips
|
||||
to ENTERPRISE. Package-scoped so an e2e bootstrap can pull it in and
|
||||
every spec inherits the licensed state."""
|
||||
return license_applier(signoz, request, pytestconfig)
|
||||
|
||||
|
||||
# This is not a fixture purposefully, we just want to add a license to the signoz instance.
|
||||
# This is also idempotent in nature.
|
||||
def add_license(
|
||||
|
||||
@@ -13,7 +13,6 @@ from fixtures.querier import (
|
||||
build_aggregation,
|
||||
build_group_by_field,
|
||||
build_traces_scalar_query,
|
||||
get_all_warnings,
|
||||
get_rows,
|
||||
index_series_by_label,
|
||||
make_query_request,
|
||||
@@ -411,71 +410,3 @@ def test_traces_attributes_json_list_view(
|
||||
assert row["data"]["name"] == name
|
||||
# int/float compare equal in Python, so map-sourced float64 and JSON-sourced numbers both match.
|
||||
assert row["data"]["attributes"] == attributes, name
|
||||
|
||||
|
||||
def test_traces_attributes_json_unknown_key_follows_rollout(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_traces: Callable[[list[Traces]], None],
|
||||
seed_attribute_evolution: Callable[[str, datetime], None],
|
||||
) -> None:
|
||||
"""A key with no metadata rows resolves through the column-wide evolution row like a reported
|
||||
one: the Map before the rollout, the JSON column after it, both while straddling. The before
|
||||
span writes only the Map and the after span only the JSON column, so each window's rows prove
|
||||
which home was read. The not-found warning still fires."""
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
evolution_time = datetime.now(tz=UTC).replace(second=0, microsecond=0) - timedelta(minutes=30)
|
||||
seed_attribute_evolution("traces", evolution_time)
|
||||
|
||||
before = evolution_time - timedelta(minutes=5)
|
||||
after = evolution_time + timedelta(minutes=5)
|
||||
|
||||
spans = [
|
||||
Traces(
|
||||
timestamp=before,
|
||||
trace_id=TraceIdGenerator.trace_id(),
|
||||
span_id=TraceIdGenerator.span_id(),
|
||||
name="before",
|
||||
attributes={"ad.hoc": "/old"},
|
||||
attribute_write_mode="legacy_only",
|
||||
),
|
||||
Traces(
|
||||
timestamp=after,
|
||||
trace_id=TraceIdGenerator.trace_id(),
|
||||
span_id=TraceIdGenerator.span_id(),
|
||||
name="after",
|
||||
attributes={"ad.hoc": "/new"},
|
||||
attribute_write_mode="json_only",
|
||||
),
|
||||
]
|
||||
for span in spans:
|
||||
span.attribute_keys = []
|
||||
span.tag_attributes = []
|
||||
insert_traces(spans)
|
||||
|
||||
windows = {
|
||||
"before": (before - timedelta(minutes=1), before + timedelta(minutes=1), {"/old": {int(before.timestamp() * 1000): 1}}),
|
||||
"after": (after - timedelta(minutes=1), after + timedelta(minutes=1), {"/new": {int(after.timestamp() * 1000): 1}}),
|
||||
"straddle": (before - timedelta(minutes=1), after + timedelta(minutes=1), {"/old": {int(before.timestamp() * 1000): 1}, "/new": {int(after.timestamp() * 1000): 1}}),
|
||||
}
|
||||
for window, (start, end, expected) in windows.items():
|
||||
response = make_query_request(
|
||||
signoz,
|
||||
token,
|
||||
start_ms=int(start.timestamp() * 1000),
|
||||
end_ms=int(end.timestamp() * 1000),
|
||||
request_type=RequestType.TIME_SERIES,
|
||||
queries=[
|
||||
build_traces_scalar_query(
|
||||
aggregations=[build_aggregation("count()")],
|
||||
filter_expression="ad.hoc EXISTS",
|
||||
group_by=[build_group_by_field("ad.hoc", field_context="attribute")],
|
||||
)
|
||||
],
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, window
|
||||
series = index_series_by_label(response.json()["data"]["data"]["results"][0]["aggregations"][0]["series"], "ad.hoc")
|
||||
assert_grouped_series(series, expected_values_by_group=expected)
|
||||
assert any("not found in metadata" in warning.get("message", "") for warning in get_all_warnings(response.json())), window
|
||||
|
||||
Reference in New Issue
Block a user