Compare commits

..

1 Commits

Author SHA1 Message Date
Nikhil Soni
fcf3fd067a feat(traces-qb): read unreported span attributes through the JSON rollout
A key metadata does not report used to fall back to the legacy maps in
every window. It now carries the column-wide attribute evolutions, loaded
once per request, so it follows the same rollout as a reported key.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-16 12:46:43 +05:30
79 changed files with 528 additions and 2565 deletions

1
.gitattributes vendored
View File

@@ -1,4 +1,3 @@
*.css linguist-detectable=false
*.stories.mocks.tsx linguist-generated=true
**/__story_mockdata__/** linguist-generated=true
tests/e2e/drivers/** linguist-generated=true

View File

@@ -3,44 +3,16 @@ 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'))) && needs.changes.outputs.e2e == 'true'
(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')
runs-on: ubuntu-latest
steps:
- name: checkout
@@ -69,10 +41,9 @@ 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'))) && needs.changes.outputs.e2e == 'true'
(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')
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
@@ -181,17 +152,3 @@ 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

View File

@@ -3702,13 +3702,6 @@ components:
nullable: true
type: number
type: object
DashboardtypesAxisScale:
enum:
- auto
- linear
- log
- symlog
type: string
DashboardtypesBarChartPanelSpec:
properties:
axes:
@@ -4451,7 +4444,6 @@ 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'
@@ -4467,7 +4459,6 @@ components:
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec'
type: object
DashboardtypesPanelPluginKind:
enum:
@@ -4481,7 +4472,6 @@ components:
- signoz/ListPanel
- signoz/TextPanel
- signoz/HeatmapPanel
- signoz/ScatterPlotPanel
type: string
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesAreaChartPanelSpec:
properties:
@@ -4567,18 +4557,6 @@ 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:
@@ -4658,16 +4636,6 @@ 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:
@@ -4856,104 +4824,6 @@ 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
@@ -5067,11 +4937,6 @@ components:
- value
- name
type: object
DashboardtypesThresholdAxis:
enum:
- x
- "y"
type: string
DashboardtypesThresholdFormat:
enum:
- text

View File

@@ -86,9 +86,6 @@ 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
@@ -115,24 +112,6 @@ 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>/`.

View File

@@ -72,8 +72,6 @@ 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({
@@ -97,7 +95,6 @@ function CustomTimePicker({
minTime,
maxTime,
isModalTimeSelection = false,
'data-testid': dataTestId,
}: CustomTimePickerProps): JSX.Element {
const [selectedTimePlaceholderValue, setSelectedTimePlaceholderValue] =
useState('Select / Enter Time Range');
@@ -599,7 +596,6 @@ function CustomTimePicker({
>
<Input
ref={inputRef}
data-testid={dataTestId}
autoComplete="off"
className={cx(
'timeSelection-input',
@@ -686,5 +682,4 @@ CustomTimePicker.defaultProps = {
onExitLiveLogs: noop,
showLiveLogs: false,
showRecentlyUsed: true,
'data-testid': undefined,
};

View File

@@ -180,7 +180,6 @@ 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);
@@ -260,7 +259,6 @@ 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();

View File

@@ -98,7 +98,6 @@ exports[`Not Found page test should render Not Found page without errors 1`] = `
<div
class="c0"
data-testid="not-found"
>
<img
alt="not-found"

View File

@@ -6,7 +6,7 @@ import { Button, Container, Text, TextContainer } from './styles';
function NotFound({ text = defaultText }: Props): JSX.Element {
return (
<Container data-testid="not-found">
<Container>
<NotFoundImage />
<TextContainer>

View File

@@ -35,8 +35,7 @@ function TanStackRowCellInner<TData>({
<td
className={cx(tableStyles.tableCell, 'tanstack-cell-' + cell.column.id)}
data-single-column={hasSingleColumn || undefined}
// While loading, rowData may be a placeholder ({ id: 'skeleton-N' }).
onClick={showSkeleton ? undefined : onClick}
onClick={onClick}
>
{showSkeleton ? (
<Skeleton.Input

View File

@@ -91,18 +91,6 @@ 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() },

View File

@@ -314,7 +314,7 @@ function CreateEditRolePageContent(): JSX.Element {
cancelText="Keep editing"
onConfirm={confirmNavigation}
onCancel={cancelNavigation}
testId="discard-changes-dialog"
data-testid="discard-changes-dialog"
>
<Typography>
{isCreateMode

View File

@@ -1,10 +1,7 @@
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';
@@ -25,16 +22,11 @@ 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.
//TODO: AI query check should be removed after we support alerts on ai query @tewarig
if (
!actions.createAlert ||
getQueryMode(currentQuery) === QueryMode.AI_QUERY_BUILDER
) {
if (!actions.createAlert) {
return null;
}

View File

@@ -188,7 +188,10 @@ func (s *traceStore) genAISpanColumns(ctx context.Context, orgID valuer.UUID, bo
return nil, err
}
q := querybuilder.NewQueryInfo(ctx, orgID, s.flagger, telemetrytypes.SignalTraces, nil, uint64(bounds.Start.UnixNano()), uint64(bounds.End.UnixNano()))
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
}
isGenAI, err := s.anyExistsCondition(ctx, q, aiobservabilitytypes.GenAISpanGateKeys, keys, sb)
if err != nil {
return nil, err
@@ -288,7 +291,10 @@ 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 := querybuilder.NewQueryInfo(ctx, orgID, s.flagger, telemetrytypes.SignalTraces, nil, uint64(bounds.Start.UnixNano()), uint64(bounds.End.UnixNano()))
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
}
sb := sqlbuilder.NewSelectBuilder()
sb.Select(
"DISTINCT ON (span_id) timestamp", "duration_nano", "span_id", "parent_span_id", "has_error", "name", "kind_string",

View File

@@ -9,7 +9,6 @@ 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"
@@ -20,8 +19,6 @@ type aggExprRewriter struct {
logger *slog.Logger
fullTextColumn *telemetrytypes.TelemetryFieldKey
storage qbtypes.Storage
flagger flagger.Flagger
signal telemetrytypes.Signal
}
var _ qbtypes.AggExprRewriter = (*aggExprRewriter)(nil)
@@ -30,8 +27,6 @@ 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")
@@ -39,8 +34,6 @@ func NewAggExprRewriter(
logger: set.Logger(),
fullTextColumn: fullTextColumn,
storage: storage,
flagger: fl,
signal: signal,
}
}
@@ -49,9 +42,7 @@ func NewAggExprRewriter(
// and the args if the parametric aggregation function is used.
func (r *aggExprRewriter) Rewrite(
ctx context.Context,
orgID valuer.UUID,
startNs uint64,
endNs uint64,
q qbtypes.QueryInfo,
expr string,
rateInterval uint64,
keys map[string][]*telemetrytypes.TelemetryFieldKey,
@@ -80,7 +71,7 @@ func (r *aggExprRewriter) Rewrite(
visitor := &exprVisitor{
ctx: ctx,
query: NewQueryInfo(ctx, orgID, r.flagger, r.signal, nil, startNs, endNs),
query: q,
logger: r.logger,
fieldKeys: keys,
fullTextColumn: r.fullTextColumn,
@@ -100,9 +91,7 @@ func (r *aggExprRewriter) Rewrite(
// RewriteMulti rewrites a slice of expressions.
func (r *aggExprRewriter) RewriteMulti(
ctx context.Context,
orgID valuer.UUID,
startNs uint64,
endNs uint64,
q qbtypes.QueryInfo,
exprs []string,
rateInterval uint64,
keys map[string][]*telemetrytypes.TelemetryFieldKey,
@@ -111,7 +100,7 @@ func (r *aggExprRewriter) RewriteMulti(
var errs []error
var chArgsList [][]any
for i, e := range exprs {
w, chArgs, err := r.Rewrite(ctx, orgID, startNs, endNs, e, rateInterval, keys)
w, chArgs, err := r.Rewrite(ctx, q, e, rateInterval, keys)
if err != nil {
errs = append(errs, err)
out[i] = e

View File

@@ -31,6 +31,21 @@ 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:

View File

@@ -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, fl, telemetrytypes.SignalLogs)
aggExprRewriter := querybuilder.NewAggExprRewriter(settings, audittelemetryschema.DefaultFullTextColumn, storage)
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, orgID, start, end, agg.Expression, uint64(query.StepInterval.Seconds()), keys)
rewritten, chArgs, err := b.aggExprRewriter.Rewrite(ctx, info, 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, orgID, start, end, aggExpr.Expression, rateInterval, keys)
rewritten, chArgs, err := b.aggExprRewriter.Rewrite(ctx, info, aggExpr.Expression, rateInterval, keys)
if err != nil {
return nil, err
}

View File

@@ -56,7 +56,7 @@ func newTestAuditStatementBuilder(t *testing.T) *auditQueryStatementBuilder {
mockMetadataStore.KeysMap = auditFieldKeyMap()
storage := audittelemetryschema.NewStorage()
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage, fl, telemetrytypes.SignalLogs)
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage)
return NewAuditQueryStatementBuilder(
instrumentationtest.New().ToProviderSettings(),

View File

@@ -58,7 +58,7 @@ func TestStatementBuilderResolvesLogFamilies(t *testing.T) {
}
mockMetadataStore.KeysMap = keys
storage := logstelemetryschema.NewStorage()
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage, fl, telemetrytypes.SignalLogs)
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage)
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, fl, telemetrytypes.SignalLogs)
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage)
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, fl, telemetrytypes.SignalLogs)
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage)
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, fl, telemetrytypes.SignalLogs)
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage)
statementBuilder := NewLogQueryStatementBuilder(
instrumentationtest.New().ToProviderSettings(),
mockMetadataStore, storage, aggExprRewriter,

View File

@@ -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, fl, telemetrytypes.SignalLogs)
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage)
statementBuilder := NewLogQueryStatementBuilder(
instrumentationtest.New().ToProviderSettings(),
mockMetadataStore, storage, aggExprRewriter,

View File

@@ -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, fl, telemetrytypes.SignalLogs)
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage)
statementBuilder := NewLogQueryStatementBuilder(
instrumentationtest.New().ToProviderSettings(),

View File

@@ -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, fl, telemetrytypes.SignalLogs)
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage)
statementBuilder := NewLogQueryStatementBuilder(
instrumentationtest.New().ToProviderSettings(),
store,

View File

@@ -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, fl, telemetrytypes.SignalLogs)
rewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage)
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, fl, telemetrytypes.SignalLogs)
rewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage)
sb := NewLogQueryStatementBuilder(
instrumentationtest.New().ToProviderSettings(),
store, storage, rewriter, logstelemetryschema.DefaultFullTextColumn, fl, nil,

View File

@@ -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, fl, telemetrytypes.SignalLogs)
aggExprRewriter := querybuilder.NewAggExprRewriter(settings, logstelemetryschema.DefaultFullTextColumn, storage)
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, orgID, start, end, agg.Expression,
ctx, info, 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, orgID, start, end, aggExpr.Expression,
ctx, info, aggExpr.Expression,
rateInterval,
keys,
)

View File

@@ -220,7 +220,7 @@ func TestStatementBuilderTimeSeries(t *testing.T) {
storage := logstelemetryschema.NewStorage()
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage, fl, telemetrytypes.SignalLogs)
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage)
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, fl, telemetrytypes.SignalLogs)
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage)
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, fl, telemetrytypes.SignalLogs)
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage)
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, fl, telemetrytypes.SignalLogs)
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage)
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, fl, telemetrytypes.SignalLogs)
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage)
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, fl, telemetrytypes.SignalLogs)
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage)
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, fl, telemetrytypes.SignalLogs)
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage)
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, fl, telemetrytypes.SignalLogs)
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage)
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, fl, telemetrytypes.SignalLogs)
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), logstelemetryschema.DefaultFullTextColumn, storage)
return NewLogQueryStatementBuilder(
instrumentationtest.New().ToProviderSettings(),

View File

@@ -226,7 +226,10 @@ func (b *scopedTraceStatementBuilder) buildTraceListQuery(
return nil, err
}
matchedSB := sqlbuilder.NewSelectBuilder()
q := querybuilder.NewQueryInfo(ctx, orgID, b.fl, telemetrytypes.SignalTraces, nil, start, end)
q, err := querybuilder.LoadColumnEvolutions(ctx, querybuilder.NewQueryInfo(ctx, orgID, b.fl, telemetrytypes.SignalTraces, nil, start, end), b.metadataStore)
if err != nil {
return nil, err
}
maskExpr, resolved, err := b.resolveFor(ctx, q, keys, matchedSB)
if err != nil {
return nil, err
@@ -245,7 +248,7 @@ func (b *scopedTraceStatementBuilder) buildTraceListQuery(
return nil, err
}
fp, err := b.splitFilter(ctx, orgID, query, b.aggregateAliasSet(), keys, start, end, variables, matchedSB)
fp, err := b.splitFilter(ctx, q, query, b.aggregateAliasSet(), keys, variables, matchedSB)
if err != nil {
return nil, err
}
@@ -445,7 +448,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, 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) {
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) {
var fp filterParts
havingExpr := ""
if query.Filter != nil && strings.TrimSpace(query.Filter.Expression) != "" {
@@ -455,7 +458,7 @@ func (b *scopedTraceStatementBuilder) splitFilter(ctx context.Context, orgID val
}
havingExpr = traceExpr
if strings.TrimSpace(spanExpr) != "" {
pred, warnings, url, err := b.resolveSpanPredicate(ctx, querybuilder.NewQueryInfo(ctx, orgID, b.fl, telemetrytypes.SignalTraces, nil, start, end), spanExpr, keys, variables, sb)
pred, warnings, url, err := b.resolveSpanPredicate(ctx, q, spanExpr, keys, variables, sb)
if err != nil {
return fp, err
}

View File

@@ -340,8 +340,12 @@ 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, querybuilder.NewQueryInfo(ctx, orgID, b.fl, telemetrytypes.SignalTraces, nil, start, end), keys, sb)
maskExpr, resolved, err := b.resolveFor(ctx, q, keys, sb)
if err != nil {
return nil, nil, err
}
@@ -511,15 +515,13 @@ type scanContext struct {
func (b *scopedTraceStatementBuilder) newScanContext(
ctx context.Context,
orgID valuer.UUID,
start, end uint64,
q qbtypes.QueryInfo,
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
@@ -566,6 +568,10 @@ 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 {
@@ -582,7 +588,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, orgID, start, end, keys, "", traceExpr, variables)
qsc, err := b.newScanContext(ctx, q, keys, "", traceExpr, variables)
if err != nil {
return nil, err
}
@@ -598,7 +604,7 @@ func (b *scopedTraceStatementBuilder) buildTraceAggregationQuery(
}
}
groupCols, err := b.resolveGroupColumns(ctx, querybuilder.NewQueryInfo(ctx, orgID, b.fl, telemetrytypes.SignalTraces, nil, start, end), query.GroupBy, keys)
groupCols, err := b.resolveGroupColumns(ctx, q, query.GroupBy, keys)
if err != nil {
return nil, err
}
@@ -640,7 +646,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, orgID, start, end, keys, spanExpr, "", variables)
tsc, err := b.newScanContext(ctx, q, keys, spanExpr, "", variables)
if err != nil {
return nil, err
}
@@ -666,7 +672,7 @@ func (b *scopedTraceStatementBuilder) buildTraceAggregationQuery(
strings.Join(exprs, ", "), strings.Join(groupNames, ", "))
}
msc, err := b.newScanContext(ctx, orgID, start, end, keys, spanExpr, "", variables)
msc, err := b.newScanContext(ctx, q, keys, spanExpr, "", variables)
if err != nil {
return nil, err
}

View File

@@ -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, fl, telemetrytypes.SignalTraces)
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage)
return NewTraceQueryStatementBuilder(
instrumentationtest.New().ToProviderSettings(),
store, storage, aggExprRewriter, nil, fl, false, 100000,
@@ -148,3 +148,71 @@ 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")
})
}
}

View File

@@ -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, fl, telemetrytypes.SignalTraces)
aggExprRewriter := querybuilder.NewAggExprRewriter(settings, nil, storage)
return NewTraceQueryStatementBuilder(
settings, metadataStore, storage, aggExprRewriter, telemetryStore, fl,
cfg.SkipResourceFingerprint.Enabled, cfg.SkipResourceFingerprint.Threshold,
@@ -169,18 +169,22 @@ 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, keys, variables, isSelectFieldsEmpty)
return b.buildListQuery(ctx, orgID, q, query, start, end, info, keys, variables, isSelectFieldsEmpty)
case qbtypes.RequestTypeTimeSeries:
return b.buildTimeSeriesQuery(ctx, orgID, q, query, start, end, keys, variables)
return b.buildTimeSeriesQuery(ctx, orgID, q, query, start, end, info, keys, variables)
case qbtypes.RequestTypeScalar:
return b.buildScalarQuery(ctx, orgID, q, query, start, end, keys, variables, false, false)
return b.buildScalarQuery(ctx, orgID, q, query, start, end, info, keys, variables, false, false)
case qbtypes.RequestTypeTrace:
return b.buildTraceQuery(ctx, orgID, q, query, start, end, keys, variables)
return b.buildTraceQuery(ctx, orgID, q, query, start, end, info, keys, variables)
}
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "unsupported request type: %s", requestType)
@@ -355,6 +359,7 @@ 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,
@@ -379,7 +384,6 @@ 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 {
@@ -398,7 +402,7 @@ func (b *traceQueryStatementBuilder) buildListQuery(
sb.From(fmt.Sprintf("%s.%s", tracestelemetryschema.DBName, tracestelemetryschema.SpanIndexV3TableName))
// Add filter conditions
preparedWhereClause, err := b.addFilterCondition(ctx, orgID, sb, start, end, query, keys, variables, skipResourceFilter)
preparedWhereClause, err := b.addFilterCondition(ctx, sb, start, end, info, query, keys, variables, skipResourceFilter)
if err != nil {
return nil, err
}
@@ -444,6 +448,7 @@ 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) {
@@ -470,7 +475,7 @@ func (b *traceQueryStatementBuilder) buildTraceQuery(
}
// Add filter conditions
preparedWhereClause, err := b.addFilterCondition(ctx, orgID, distSB, start, end, query, keys, variables, skipResourceFilter)
preparedWhereClause, err := b.addFilterCondition(ctx, distSB, start, end, info, query, keys, variables, skipResourceFilter)
if err != nil {
return nil, err
}
@@ -563,6 +568,7 @@ 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) {
@@ -593,7 +599,6 @@ 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 {
@@ -608,7 +613,7 @@ func (b *traceQueryStatementBuilder) buildTimeSeriesQuery(
allAggChArgs := make([]any, 0)
for i, agg := range query.Aggregations {
rewritten, chArgs, err := b.aggExprRewriter.Rewrite(
ctx, orgID, start, end, agg.Expression,
ctx, info, agg.Expression,
uint64(query.StepInterval.Seconds()),
keys,
)
@@ -620,7 +625,7 @@ func (b *traceQueryStatementBuilder) buildTimeSeriesQuery(
}
sb.From(fmt.Sprintf("%s.%s", tracestelemetryschema.DBName, tracestelemetryschema.SpanIndexV3TableName))
preparedWhereClause, err := b.addFilterCondition(ctx, orgID, sb, start, end, query, keys, variables, skipResourceFilter)
preparedWhereClause, err := b.addFilterCondition(ctx, sb, start, end, info, query, keys, variables, skipResourceFilter)
if err != nil {
return nil, err
}
@@ -631,7 +636,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, keys, variables, true, true)
cteStmt, err := b.buildScalarQuery(ctx, orgID, cteSB, query, start, end, info, keys, variables, true, true)
if err != nil {
return nil, err
}
@@ -727,6 +732,7 @@ 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,
@@ -757,7 +763,6 @@ 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 {
@@ -776,7 +781,7 @@ func (b *traceQueryStatementBuilder) buildScalarQuery(
for idx := range query.Aggregations {
aggExpr := query.Aggregations[idx]
rewritten, chArgs, err := b.aggExprRewriter.Rewrite(
ctx, orgID, start, end, aggExpr.Expression,
ctx, info, aggExpr.Expression,
rateInterval,
keys,
)
@@ -792,7 +797,7 @@ func (b *traceQueryStatementBuilder) buildScalarQuery(
sb.From(fmt.Sprintf("%s.%s", tracestelemetryschema.DBName, tracestelemetryschema.SpanIndexV3TableName))
// Add filter conditions
preparedWhereClause, err := b.addFilterCondition(ctx, orgID, sb, start, end, query, keys, variables, skipResourceFilter)
preparedWhereClause, err := b.addFilterCondition(ctx, sb, start, end, info, query, keys, variables, skipResourceFilter)
if err != nil {
return nil, err
}
@@ -854,9 +859,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,
@@ -870,7 +875,7 @@ func (b *traceQueryStatementBuilder) addFilterCondition(
// add filter expression
preparedWhereClause, err = querybuilder.PrepareWhereClause(query.Filter.Expression, querybuilder.FilterExprVisitorOpts{
Context: ctx,
Query: querybuilder.NewQueryInfo(ctx, orgID, b.fl, telemetrytypes.SignalTraces, nil, start, end),
Query: info,
Storage: b.storage,
Logger: b.logger,
FieldKeys: keys,

View File

@@ -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, fl, telemetrytypes.SignalTraces)
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage)
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, fl, telemetrytypes.SignalTraces)
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage)
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, fl, telemetrytypes.SignalTraces)
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage)
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, fl, telemetrytypes.SignalTraces)
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage)
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, fl, telemetrytypes.SignalTraces)
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage)
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, fl, telemetrytypes.SignalTraces)
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage)
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, fl, telemetrytypes.SignalTraces)
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage)
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, fl, telemetrytypes.SignalTraces)
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage)
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, fl, telemetrytypes.SignalTraces)
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage)
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, fl, telemetrytypes.SignalTraces)
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage)
statementBuilder := NewTraceQueryStatementBuilder(
instrumentationtest.New().ToProviderSettings(),

View File

@@ -28,6 +28,7 @@ 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]
@@ -265,7 +266,7 @@ func (b *traceOperatorCTEBuilder) buildQueryCTE(ctx context.Context, queryName s
query.Filter.Expression,
querybuilder.FilterExprVisitorOpts{
Context: ctx,
Query: querybuilder.NewQueryInfo(ctx, b.orgID, b.stmtBuilder.fl, telemetrytypes.SignalTraces, nil, b.start, b.end),
Query: b.queryInfo,
Storage: b.stmtBuilder.storage,
Logger: b.stmtBuilder.logger,
FieldKeys: keys,
@@ -485,7 +486,7 @@ func (b *traceOperatorCTEBuilder) buildListQuery(ctx context.Context, selectFrom
}
// Add selectFields since we now have all base table columns
info := querybuilder.NewQueryInfo(ctx, b.orgID, b.stmtBuilder.fl, telemetrytypes.SignalTraces, nil, b.start, b.end)
info := b.queryInfo
for i, field := range b.operator.SelectFields {
if selectedFields[field.Name] {
continue
@@ -629,7 +630,7 @@ func (b *traceOperatorCTEBuilder) buildTimeSeriesQuery(ctx context.Context, sele
int64(b.operator.StepInterval.Seconds()),
))
info := querybuilder.NewQueryInfo(ctx, b.orgID, b.stmtBuilder.fl, telemetrytypes.SignalTraces, nil, b.start, b.end)
info := b.queryInfo
for _, gb := range b.operator.GroupBy {
expr, err := querybuilder.ResolveColumn(ctx, info, b.stmtBuilder.storage, &gb.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
if err != nil {
@@ -647,9 +648,7 @@ func (b *traceOperatorCTEBuilder) buildTimeSeriesQuery(ctx context.Context, sele
for i, agg := range b.operator.Aggregations {
rewritten, chArgs, err := b.stmtBuilder.aggExprRewriter.Rewrite(
ctx,
b.orgID,
b.start,
b.end,
b.queryInfo,
agg.Expression,
uint64(b.operator.StepInterval.Seconds()),
keys,
@@ -726,7 +725,7 @@ func (b *traceOperatorCTEBuilder) buildTraceQuery(ctx context.Context, selectFro
sb := sqlbuilder.NewSelectBuilder()
info := querybuilder.NewQueryInfo(ctx, b.orgID, b.stmtBuilder.fl, telemetrytypes.SignalTraces, nil, b.start, b.end)
info := b.queryInfo
for _, gb := range b.operator.GroupBy {
expr, err := querybuilder.ResolveColumn(ctx, info, b.stmtBuilder.storage, &gb.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
if err != nil {
@@ -746,9 +745,7 @@ func (b *traceOperatorCTEBuilder) buildTraceQuery(ctx context.Context, selectFro
for i, agg := range b.operator.Aggregations {
rewritten, chArgs, err := b.stmtBuilder.aggExprRewriter.Rewrite(
ctx,
b.orgID,
b.start,
b.end,
b.queryInfo,
agg.Expression,
rateInterval,
keys,
@@ -853,7 +850,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 := querybuilder.NewQueryInfo(ctx, b.orgID, b.stmtBuilder.fl, telemetrytypes.SignalTraces, nil, b.start, b.end)
info := b.queryInfo
for _, gb := range b.operator.GroupBy {
expr, err := querybuilder.ResolveColumn(ctx, info, b.stmtBuilder.storage, &gb.TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
if err != nil {
@@ -871,9 +868,7 @@ func (b *traceOperatorCTEBuilder) buildScalarQuery(ctx context.Context, selectFr
for i, agg := range b.operator.Aggregations {
rewritten, chArgs, err := b.stmtBuilder.aggExprRewriter.Rewrite(
ctx,
b.orgID,
b.start,
b.end,
b.queryInfo,
agg.Expression,
uint64((b.end-b.start)/querybuilder.NsToSeconds),
keys,

View File

@@ -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, fl, telemetrytypes.SignalTraces)
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage)
traceStmtBuilder := NewTraceQueryStatementBuilder(
instrumentationtest.New().ToProviderSettings(),
mockMetadataStore, storage, aggExprRewriter, nil, fl, false, 100000,

View File

@@ -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, fl, telemetrytypes.SignalTraces)
aggExprRewriter := querybuilder.NewAggExprRewriter(settings, nil, storage)
traceStmtBuilder := NewTraceQueryStatementBuilder(
settings, metadataStore, storage, aggExprRewriter, telemetryStore, fl,
cfg.SkipResourceFingerprint.Enabled, cfg.SkipResourceFingerprint.Threshold,
@@ -116,11 +116,17 @@ 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]),

View File

@@ -39,7 +39,7 @@ func TestTraceTimeRangeOptimization(t *testing.T) {
Signal: telemetrytypes.SignalTraces,
}}
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage, fl, telemetrytypes.SignalTraces)
aggExprRewriter := querybuilder.NewAggExprRewriter(instrumentationtest.New().ToProviderSettings(), nil, storage)
statementBuilder := NewTraceQueryStatementBuilder(
instrumentationtest.New().ToProviderSettings(),

View File

@@ -2430,6 +2430,16 @@ 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

View File

@@ -531,7 +531,8 @@ 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.
// spelling. A synthesized attribute key reads through the column-wide
// evolutions of the query, so it follows the JSON rollout like a reported one.
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 {
@@ -560,9 +561,29 @@ 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 {

View File

@@ -527,3 +527,81 @@ 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']")
})
}

View File

@@ -123,22 +123,10 @@ 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)

View File

@@ -1870,319 +1870,6 @@ 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": [],
@@ -2541,7 +2228,7 @@ func TestPanelTypeQueryTypeCompatibility(t *testing.T) {
switch panelKind {
case "signoz/ListPanel":
return "raw"
case "signoz/TablePanel", "signoz/NumberPanel", "signoz/PieChartPanel", "signoz/HistogramPanel", "signoz/ScatterPlotPanel":
case "signoz/TablePanel", "signoz/NumberPanel", "signoz/PieChartPanel", "signoz/HistogramPanel":
return "scalar"
default:
return "time_series"
@@ -2585,17 +2272,14 @@ 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},
}

View File

@@ -28,17 +28,16 @@ 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(PanelKindScatterPlot): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesScatterPlotPanelSpec"),
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"),
})
}
@@ -72,7 +71,6 @@ func (PanelPlugin) JSONSchemaOneOf() []any {
PanelPluginVariant[ListPanelSpec]{Kind: string(PanelKindList)},
PanelPluginVariant[TextPanelSpec]{Kind: string(PanelKindText)},
PanelPluginVariant[HeatmapPanelSpec]{Kind: string(PanelKindHeatmap)},
PanelPluginVariant[ScatterPlotPanelSpec]{Kind: string(PanelKindScatterPlot)},
}
}
@@ -236,17 +234,16 @@ 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) },
PanelKindScatterPlot: func() any { return new(ScatterPlotPanelSpec) },
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) },
}
queryPluginSpecs = map[QueryPluginKind]func() any{
QueryKindBuilder: func() any { return new(BuilderQuerySpec) },
@@ -263,17 +260,16 @@ 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},
PanelKindScatterPlot: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, 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},
}
)

View File

@@ -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 || panel.Spec.Plugin.Kind == PanelKindScatterPlot,
FormatTableResultForUI: panel.Spec.Plugin.Kind == PanelKindTable,
},
}, nil
}

View File

@@ -251,38 +251,34 @@ func TestDashboardV2GetPanelQuery(t *testing.T) {
assert.Equal(t, telemetrytypes.SignalTraces, spec.Signal)
})
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"}},
},
},
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"}},
},
},
},
},
},
},
}
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) {

View File

@@ -2,8 +2,6 @@ package dashboardtypes
import (
"encoding/json"
"maps"
"slices"
"strconv"
"github.com/SigNoz/signoz/pkg/errors"
@@ -189,21 +187,20 @@ 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"
PanelKindScatterPlot PanelPluginKind = "signoz/ScatterPlotPanel"
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"
)
func (PanelPluginKind) Enum() []any {
return []any{PanelKindTimeSeries, PanelKindBarChart, PanelKindAreaChart, PanelKindNumber, PanelKindPieChart, PanelKindTable, PanelKindHistogram, PanelKindList, PanelKindText, PanelKindHeatmap, PanelKindScatterPlot}
return []any{PanelKindTimeSeries, PanelKindBarChart, PanelKindAreaChart, PanelKindNumber, PanelKindPieChart, PanelKindTable, PanelKindHistogram, PanelKindList, PanelKindText, PanelKindHeatmap}
}
func (k PanelPluginKind) rendersWithoutQuery() bool {
@@ -353,95 +350,6 @@ 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"`
@@ -523,11 +431,6 @@ 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"`
@@ -813,86 +716,6 @@ 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 (
@@ -1031,53 +854,6 @@ 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."`

View File

@@ -1,134 +0,0 @@
{
"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"
}
}
]
}
}
]
}

View File

@@ -40,7 +40,9 @@ 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.
// 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.
type QueryInfo struct {
StartNs uint64
EndNs uint64
@@ -49,6 +51,7 @@ 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
@@ -214,8 +217,8 @@ type Resolved struct {
type AggExprRewriter interface {
// Rewrite rewrites the aggregation expression to be used in the query.
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)
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)
}
type Statement struct {

View File

@@ -266,43 +266,6 @@ 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.")

View File

@@ -225,34 +225,6 @@ 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) {

View File

@@ -1903,63 +1903,3 @@ 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)
})
}
}

View File

@@ -45,6 +45,11 @@ 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)

View File

@@ -419,6 +419,11 @@ 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
}

View File

@@ -13,10 +13,3 @@ 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

View File

@@ -1,55 +0,0 @@
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)

View File

@@ -1,5 +0,0 @@
# 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"]

View File

@@ -1,45 +0,0 @@
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,
)

View File

@@ -15,12 +15,11 @@ def _env_file(pytestconfig: pytest.Config) -> Path:
return pytestconfig.rootpath / "e2e" / ".env.local"
def test_setup( # pylint: disable=too-many-arguments,too-many-positional-arguments
def test_setup(
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."""
@@ -34,8 +33,6 @@ def test_setup( # pylint: disable=too-many-arguments,too-many-positional-argume
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(

View File

@@ -1,93 +0,0 @@
/**
* 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();
}
}

View File

@@ -1,392 +0,0 @@
/**
* 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';

View File

@@ -1,42 +1,27 @@
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;
/**
* 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 },
],
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;
}>({
user: [ADMIN, { option: true }],
authedPage: async ({ browser, user }, use) => {

View File

@@ -72,7 +72,6 @@ export async function gotoAlertList(
await page.goto(`${ALERTS_LIST_PATH}?${query.toString()}`);
await expect(page.getByTestId('list-alerts-search-input')).toBeVisible();
if (expectRows) {
// Skeleton rows are empty and ignore clicks; wait for a loaded one.
await expect(alertRuleRows(page).first()).toHaveText(/\S/);
await expect(alertRuleRows(page).first()).toBeVisible();
}
}

View File

@@ -1,7 +1,5 @@
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. */
@@ -59,8 +57,7 @@ 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
(key) => (globalThis as any).localStorage.getItem(key) || '',
storageKey('AUTH_TOKEN'),
() => (globalThis as any).localStorage.getItem('AUTH_TOKEN') || '',
);
const res = await page.request.put('/api/v1/user/preferences/sidenav_pinned', {
data: { value: true },

View File

@@ -1,105 +0,0 @@
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,
);
},
);
}

View File

@@ -1,7 +1,5 @@
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.
@@ -47,8 +45,6 @@ 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 {
@@ -134,9 +130,7 @@ 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 === storageKey('AUTH_TOKEN'),
);
const entry = origin.localStorage.find((e) => e.name === 'AUTH_TOKEN');
if (entry) {
return entry.value;
}

View File

@@ -11,8 +11,6 @@ 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
@@ -47,8 +45,7 @@ export async function authToken(page: Page): Promise<string> {
}
return page.evaluate(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(key) => (globalThis as any).localStorage.getItem(key) || '',
storageKey('AUTH_TOKEN'),
() => (globalThis as any).localStorage.getItem('AUTH_TOKEN') || '',
);
}
@@ -85,39 +82,12 @@ async function postDashboard(
return json.data.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.
*/
/** Seed a minimally-named dashboard via API. Returns the new ID. */
export async function createDashboardViaApi(
page: Page,
title: string,
): Promise<string> {
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;
return postDashboard(page, { title, uploadedGrafana: false });
}
/**
@@ -372,7 +342,7 @@ export async function deleteDashboardViaApi(
token: string,
): Promise<void> {
await request
.delete(`/api/v2/dashboards/${id}`, {
.delete(`/api/v1/dashboards/${id}`, {
headers: { Authorization: `Bearer ${token}` },
})
.catch(() => undefined);

View File

@@ -1,110 +0,0 @@
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()}`,
);
}
}

View File

@@ -6,7 +6,6 @@ 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
@@ -128,7 +127,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.getByTestId(SETTINGS_CLOSE_TEST_ID)).toBeVisible();
await expect(panel.locator('.qf-header-icon')).toBeVisible();
const addedList = panel.locator('.qf-added-filters-list');
await expect(panel.getByText('ADDED FILTERS')).toBeVisible();
@@ -162,6 +161,6 @@ export async function assertQuickFiltersSettingsDrawerOpen(
export async function closeQuickFiltersSettingsPanel(
page: Page,
): Promise<void> {
await settingsPanel(page).getByTestId(SETTINGS_CLOSE_TEST_ID).click();
await settingsPanel(page).locator('.qf-header-icon').click();
await assertQuickFiltersSettingsDrawerClosed(page);
}

View File

@@ -7,8 +7,6 @@
"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",

View File

@@ -108,15 +108,7 @@ export default defineConfig({
},
{
name: 'chromium',
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'],
},
},
use: devices['Desktop Chrome'],
dependencies: ['setup'],
},
{

View File

@@ -26,7 +26,6 @@ import {
v2SaveTooltip,
v2TestButton,
} from '../../../helpers/alert-forms/v2';
import { BASE_PATH } from '../../../helpers/base-path';
// CV2-* — the v2 create builder.
//
@@ -447,7 +446,7 @@ test.describe('Alert create — v2 builder', () => {
await ownedRules.register(response);
expect(response.status()).toBe(201);
expect(new URL(response.url()).pathname).toBe(`${BASE_PATH}/api/v2/rules`);
expect(new URL(response.url()).pathname).toBe('/api/v2/rules');
const body = response.request().postDataJSON();
expect(body.schemaVersion).toBe('v2alpha1');

View File

@@ -15,8 +15,7 @@ test.describe('Alert details — page chrome', () => {
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
await gotoAlertHistory(page, alertHistory.ruleId);
// The served URL: base path mode strips the prefix from `page.url()`.
const expected = page.mainFrame().url();
const expected = page.url();
await page.getByRole('button', { name: 'Copy link' }).click();
await expect(page.getByText('Copied')).toBeVisible();

View File

@@ -27,8 +27,7 @@ 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(new RegExp(`${ALERTS_LIST_PATH}(\\?|$)`));
await expect(page).not.toHaveURL(/[?&]tab=/);
await expect(page).toHaveURL(ALERTS_LIST_PATH);
await expect(
page.getByRole('tab', { name: TAB_NAMES.rules }),
).toHaveAttribute('aria-selected', 'true');

View File

@@ -1,45 +0,0 @@
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);
});
});

View File

@@ -1,49 +0,0 @@
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([]);
});
});

View File

@@ -1,62 +0,0 @@
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();
});
});

View File

@@ -1,87 +0,0 @@
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));
});
});

View File

@@ -1,84 +0,0 @@
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);
});
});

View File

@@ -1,83 +0,0 @@
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);
});
});

View File

@@ -18,6 +18,6 @@
"outDir": "./dist",
"rootDir": "."
},
"include": ["tests/**/*.ts", "drivers/**/*.ts", "helpers/**/*.ts", "fixtures/**/*.ts", "bootstrap/**/*.ts", "playwright.config.ts"],
"include": ["tests/**/*.ts", "helpers/**/*.ts", "fixtures/**/*.ts", "bootstrap/**/*.ts", "playwright.config.ts"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -36,6 +36,28 @@ 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,
@@ -160,14 +182,16 @@ def get_tokens(signoz: types.SigNoz) -> Callable[[str, str], tuple[str, str]]:
return tokens_getter(signoz)
def license_applier(
@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,
cache_key: str = "apply_license",
base_path: str = "",
) -> types.Operation:
"""Apply the ENTERPRISE license and complete org onboarding, under base_path. Reuse-wrapped."""
"""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."""
def create() -> types.Operation:
Config.base_url = signoz.zeus.host_configs["8080"].get("/__admin")
@@ -200,12 +224,12 @@ def license_applier(
)
)
access_token = token_getter(signoz, base_path)(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
access_token = _login(signoz, 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(f"{base_path}/api/v4/licenses")
license_url = signoz.self.host_configs["8080"].get("/api/v4/licenses")
auth_header = {"Authorization": f"Bearer {access_token}"}
for attempt in range(10):
resp = requests.post(
@@ -224,7 +248,7 @@ def license_applier(
# 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(f"{base_path}/api/v1/org/preferences/org_onboarding"),
signoz.self.host_configs["8080"].get("/api/v1/org/preferences/org_onboarding"),
json={"value": True},
headers=auth_header,
timeout=5,
@@ -241,7 +265,7 @@ def license_applier(
return reuse.wrap(
request,
pytestconfig,
cache_key,
"apply_license",
lambda: types.Operation(name=""),
create,
delete,
@@ -249,19 +273,6 @@ def license_applier(
)
@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(

View File

@@ -13,8 +13,11 @@ from fixtures.querier import (
build_aggregation,
build_group_by_field,
build_traces_scalar_query,
get_all_warnings,
get_preview_sql,
get_rows,
index_series_by_label,
make_preview_query_request,
make_query_request,
)
from fixtures.traces import TraceIdGenerator, Traces
@@ -410,3 +413,79 @@ 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_unreported_key(
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:
"""An attribute metadata does not report follows the rollout like a reported one: the legacy
map before it, the attributes JSON column after it."""
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={"unreported.key": "value"},
attribute_write_mode="legacy_only",
),
Traces(
timestamp=after,
trace_id=TraceIdGenerator.trace_id(),
span_id=TraceIdGenerator.span_id(),
name="after",
attributes={"unreported.key": "value"},
attribute_write_mode="json_only",
),
]
# no metadata rows, so the querier does not know the key
for span in spans:
span.attribute_keys = []
span.tag_attributes = []
insert_traces(spans)
queries = [
build_traces_scalar_query(
aggregations=[build_aggregation("count()")],
filter_expression="unreported.key = 'value'",
)
]
# before the rollout -> legacy map
start_ms = int((before - timedelta(minutes=1)).timestamp() * 1000)
end_ms = int((before + timedelta(minutes=1)).timestamp() * 1000)
sql = get_preview_sql(make_preview_query_request(signoz, token, start_ms=start_ms, end_ms=end_ms, queries=queries), "A")
assert "attributes_string['unreported.key']" in sql
assert "attributes.`unreported.key`" not in sql
response = make_query_request(signoz, token, start_ms=start_ms, end_ms=end_ms, queries=queries)
assert response.status_code == HTTPStatus.OK
series = response.json()["data"]["data"]["results"][0]["aggregations"][0]["series"]
assert [value["value"] for value in series[0]["values"]] == [1]
assert any("not found in metadata" in warning.get("message", "") for warning in get_all_warnings(response.json()))
# after the rollout -> attributes JSON column
start_ms = int((after - timedelta(minutes=1)).timestamp() * 1000)
end_ms = int((after + timedelta(minutes=1)).timestamp() * 1000)
sql = get_preview_sql(make_preview_query_request(signoz, token, start_ms=start_ms, end_ms=end_ms, queries=queries), "A")
assert "attributes.`unreported.key`" in sql
assert "attributes_string" not in sql
response = make_query_request(signoz, token, start_ms=start_ms, end_ms=end_ms, queries=queries)
assert response.status_code == HTTPStatus.OK
series = response.json()["data"]["data"]["results"][0]["aggregations"][0]["series"]
assert [value["value"] for value in series[0]["values"]] == [1]
assert any("not found in metadata" in warning.get("message", "") for warning in get_all_warnings(response.json()))