mirror of
https://github.com/SigNoz/signoz.git
synced 2026-08-27 14:50:46 +01:00
Compare commits
10 Commits
nv/area-ch
...
nv/text-pa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ca618573cb | ||
|
|
1b8155fea9 | ||
|
|
dac406eb93 | ||
|
|
f69bce998a | ||
|
|
ae9f58ba44 | ||
|
|
a930da0901 | ||
|
|
5314cb7828 | ||
|
|
02c5555a48 | ||
|
|
0bdc7bf6a1 | ||
|
|
a8c04cb563 |
@@ -20,6 +20,16 @@ You are the Playwright Test Generator for the SigNoz frontend. You take a plan w
|
||||
await expect(page.getByRole('tab', { name: /alert rules/i })).toBeVisible();
|
||||
});
|
||||
```
|
||||
- **Extended fixtures:** For features needing complex setup (seeded data, API calls, cleanup), import from domain-specific fixtures that extend `auth`. See [docs/contributing/tests/e2e.md](../../docs/contributing/tests/e2e.md) for the full pattern.
|
||||
- `fixtures/alerts/alert-rules` — worker-scoped rule list + test-scoped rule factory
|
||||
- `fixtures/alerts/alert-history` — extends alert-rules, adds history fixtures (waits on ruler evaluation)
|
||||
```ts
|
||||
// Alert list tests - need rules, no history
|
||||
import { test, expect } from '../../../fixtures/alerts/alert-rules';
|
||||
|
||||
// Alert history tests - need evaluated history rows
|
||||
import { test, expect } from '../../../fixtures/alerts/alert-history';
|
||||
```
|
||||
- **Test titles:** `TC-NN <short description>` — matches the planner's IDs.
|
||||
- **Self-contained state.** The bootstrap creates a fresh stack with **zero** dashboards / alerts / etc. — never assume pre-existing data. Two cleanup shapes are valid; pick based on the spec size:
|
||||
- **Per-test `try / finally`** — small specs (~ <10 scenarios) where each test owns its data.
|
||||
|
||||
@@ -49,6 +49,7 @@ Don't try to start the stack yourself — it can take ~4 minutes on a cold build
|
||||
- **The list pages render zero-state when the workspace is empty.** Many locators (search input, sort button, `new-dashboard-cta` testid, "All Dashboards" header) are absent in zero-state. A 30s timeout on those usually means the workspace was empty — seed first via `createDashboardViaApi`.
|
||||
- **The "Enter dashboard name…" inline field is a `RequestDashboardBtn` (template-request feedback form), not a create flow.** Tests that try to use it to create a named dashboard will silently no-op. The only UI create paths are the "New dashboard" dropdown → "Create dashboard" (default name "Sample Title", see `DEFAULT_DASHBOARD_TITLE`) or "Import JSON".
|
||||
- **Auth.** `tests/e2e/fixtures/auth.ts` logs in once per worker and caches `storageState` (cookies + localStorage with `AUTH_TOKEN`). For API-driven seeding/cleanup, use `authToken(page)` from `helpers/dashboards.ts` and pass `Authorization: Bearer <token>`. Never re-implement login.
|
||||
- **Extended fixtures.** Domain-specific fixtures extend `auth` and add seeded data. Alerts uses `fixtures/alerts/alert-rules` (worker-scoped rule list, test-scoped factory) and `fixtures/alerts/alert-history` (extends alert-rules, waits on ruler evaluation). See [docs/contributing/tests/e2e.md](../../docs/contributing/tests/e2e.md) for the pattern. When a test fails on missing data, check if it imports the wrong fixture level.
|
||||
- **Ant Design popovers** (sort menu, action menu) are click-toggle. The trigger element is often an inline `<svg>` with a `data-testid` — clicking it opens the popover; clicking it again closes. After selecting an option, the popover auto-closes. If a test interacts with the popover twice, wait for the menu items to be visible explicitly between toggles.
|
||||
- **Artifacts.** Every failed test writes to `tests/e2e/artifacts/results/<test-slug>/` — the `error-context.md` accessibility snapshot is the fastest way to see what the page actually looked like when it failed.
|
||||
- **Type-check.** After edits, run `npx tsc --noEmit -p tests/e2e/tsconfig.json` if it succeeds, or rely on `npx playwright test --list` to validate the spec parses.
|
||||
|
||||
1
.github/workflows/integrationci.yaml
vendored
1
.github/workflows/integrationci.yaml
vendored
@@ -62,6 +62,7 @@ jobs:
|
||||
- role
|
||||
- rootuser
|
||||
- savedview
|
||||
- semconvfamilies
|
||||
- serviceaccount
|
||||
- spanmapper
|
||||
- querier_json_body
|
||||
|
||||
@@ -3280,6 +3280,11 @@ components:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelBackground:
|
||||
enum:
|
||||
- solid
|
||||
- transparent
|
||||
type: string
|
||||
DashboardtypesPanelFormatting:
|
||||
properties:
|
||||
decimalPrecision:
|
||||
@@ -3300,6 +3305,7 @@ components:
|
||||
signoz/NumberPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec'
|
||||
signoz/PieChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec'
|
||||
signoz/TablePanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec'
|
||||
signoz/TextPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
|
||||
signoz/TimeSeriesPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec'
|
||||
propertyName: kind
|
||||
oneOf:
|
||||
@@ -3310,6 +3316,7 @@ components:
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
|
||||
type: object
|
||||
DashboardtypesPanelPluginKind:
|
||||
enum:
|
||||
@@ -3320,6 +3327,7 @@ components:
|
||||
- signoz/TablePanel
|
||||
- signoz/HistogramPanel
|
||||
- signoz/ListPanel
|
||||
- signoz/TextPanel
|
||||
type: string
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec:
|
||||
properties:
|
||||
@@ -3393,6 +3401,18 @@ components:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- signoz/TextPanel
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/DashboardtypesTextPanelSpec'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec:
|
||||
properties:
|
||||
kind:
|
||||
@@ -3684,6 +3704,34 @@ components:
|
||||
- color
|
||||
- columnName
|
||||
type: object
|
||||
DashboardtypesTextAlign:
|
||||
enum:
|
||||
- left
|
||||
- center
|
||||
- right
|
||||
type: string
|
||||
DashboardtypesTextMode:
|
||||
enum:
|
||||
- markdown
|
||||
type: string
|
||||
DashboardtypesTextPanelSpec:
|
||||
properties:
|
||||
mode:
|
||||
$ref: '#/components/schemas/DashboardtypesTextMode'
|
||||
presentation:
|
||||
$ref: '#/components/schemas/DashboardtypesTextPresentation'
|
||||
text:
|
||||
type: string
|
||||
type: object
|
||||
DashboardtypesTextPresentation:
|
||||
properties:
|
||||
background:
|
||||
$ref: '#/components/schemas/DashboardtypesPanelBackground'
|
||||
textAlign:
|
||||
$ref: '#/components/schemas/DashboardtypesTextAlign'
|
||||
verticalAlign:
|
||||
$ref: '#/components/schemas/DashboardtypesVerticalAlign'
|
||||
type: object
|
||||
DashboardtypesTextVariableSpec:
|
||||
properties:
|
||||
constant:
|
||||
@@ -3893,6 +3941,12 @@ components:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesVerticalAlign:
|
||||
enum:
|
||||
- top
|
||||
- center
|
||||
- bottom
|
||||
type: string
|
||||
ErrorsJSON:
|
||||
properties:
|
||||
code:
|
||||
@@ -8010,6 +8064,7 @@ components:
|
||||
- logs
|
||||
- metrics
|
||||
- meter
|
||||
- ai_observability
|
||||
type: string
|
||||
SavedviewtypesUpdatableSavedView:
|
||||
properties:
|
||||
@@ -8800,6 +8855,7 @@ components:
|
||||
- span
|
||||
- trace
|
||||
- resource
|
||||
- scope
|
||||
- attribute
|
||||
- body
|
||||
- ""
|
||||
@@ -15467,10 +15523,8 @@ paths:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
- api_key: []
|
||||
- tokenizer: []
|
||||
summary: Get features
|
||||
tags:
|
||||
- features
|
||||
|
||||
@@ -112,6 +112,41 @@ 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/`.
|
||||
|
||||
### 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>/`.
|
||||
|
||||
**Fixture scopes:**
|
||||
- **test scope** — fresh data per test. Use for mutations (edit, delete, rename).
|
||||
- **worker scope** — shared across tests in one worker. Use for read-only data. Worker scope pays the setup cost once per worker instead of once per test.
|
||||
|
||||
**The alerts pattern** (`fixtures/alerts/`) demonstrates extending fixtures:
|
||||
|
||||
```
|
||||
fixtures/alerts/
|
||||
├── alert-rules.ts # extends auth — worker-scoped rule list + test-scoped factory
|
||||
└── alert-history.ts # extends alert-rules — adds history fixtures (waits on ruler)
|
||||
```
|
||||
|
||||
Specs import from the fixture they need:
|
||||
|
||||
```ts
|
||||
// List tests — just need rules, no history
|
||||
import { test, expect } from '../../../fixtures/alerts/alert-rules';
|
||||
|
||||
// History tests — need history rows from ruler evaluation
|
||||
import { test, expect } from '../../../fixtures/alerts/alert-history';
|
||||
```
|
||||
|
||||
**When creating new fixtures:**
|
||||
|
||||
1. **Identify scope** — Will tests mutate the data? If yes, test-scoped. If read-only, worker-scoped.
|
||||
2. **Group by domain** — Put fixtures in `fixtures/<domain>/`. Helpers in `helpers/<domain>/`.
|
||||
3. **Extend existing fixtures** — Chain from `auth` or another fixture to inherit its setup.
|
||||
4. **Handle timeouts** — Worker-scoped fixtures that wait on backend processing need explicit timeouts.
|
||||
5. **Clean up** — Always delete seeded data in the fixture teardown (after `use()`).
|
||||
6. **Extract logic into functions** — Keep the `test.extend()` block lean; move setup/teardown logic to named functions so the extend block reads as a manifest of "what fixtures exist."
|
||||
|
||||
Each spec follows these principles:
|
||||
|
||||
1. **Directory per feature**: `tests/e2e/tests/<feature>/*.spec.ts`. Cross-resource junction concerns (e.g. cascade-delete) go in their own file, not packed into one giant spec.
|
||||
@@ -232,11 +267,14 @@ cd tests/e2e
|
||||
# Single feature dir
|
||||
npx playwright test tests/alerts/ --project=chromium
|
||||
|
||||
# Single sub-area
|
||||
npx playwright test tests/alerts/history/ --project=chromium
|
||||
|
||||
# Single file
|
||||
npx playwright test tests/alerts/alerts.spec.ts --project=chromium
|
||||
npx playwright test tests/alerts/page.spec.ts --project=chromium
|
||||
|
||||
# Single test by title grep
|
||||
npx playwright test --project=chromium -g "TC-01"
|
||||
npx playwright test --project=chromium -g "AL-01"
|
||||
```
|
||||
|
||||
### Iterative modes
|
||||
@@ -270,7 +308,14 @@ yarn test:staging
|
||||
| `SIGNOZ_E2E_PASSWORD` | Admin password. Bootstrap writes the integration-test default. |
|
||||
| `SIGNOZ_E2E_SEEDER_URL` | Seeder HTTP base URL — hit by specs that need per-test telemetry. |
|
||||
|
||||
Loading order in `playwright.config.ts`: `.env` first (user-provided, staging), then `.env.local` with `override: true` (bootstrap-generated, local mode). Anything already set in `process.env` at yarn-test time wins because dotenv doesn't touch vars that are already present.
|
||||
Precedence in `playwright.config.ts`, lowest to highest: `.env` (user-provided, staging) → `.env.local` (bootstrap-generated, local mode) → whatever is already in `process.env`. The config parses both files itself and only fills in keys the environment does not already define, so exporting a variable always wins:
|
||||
|
||||
```bash
|
||||
# runs against a locally served frontend, not whatever .env.local points at
|
||||
SIGNOZ_E2E_BASE_URL=http://127.0.0.1:3301 pnpm test tests/alerts
|
||||
```
|
||||
|
||||
This is deliberately not `dotenv.config({ override: true })`. That flag makes the *file* beat `process.env`, which silently discarded exported values — including the `SIGNOZ_E2E_BASE_URL` in `pnpm test:staging`, whenever a `.env.local` happened to exist.
|
||||
|
||||
### Playwright options
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ func (ah *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
|
||||
// note: add ee override methods first
|
||||
|
||||
// routes available only in ee version
|
||||
router.HandleFunc("/api/v1/features", am.ViewAccess(ah.getFeatureFlags)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/v1/features", am.OpenAccess(ah.getFeatureFlags)).Methods(http.MethodGet)
|
||||
|
||||
// base overrides
|
||||
router.HandleFunc("/api/v1/version", am.OpenAccess(ah.getVersion)).Methods(http.MethodGet)
|
||||
|
||||
@@ -53,7 +53,7 @@ export function ErrorResponseHandler(error: AxiosError): ErrorResponse {
|
||||
};
|
||||
}
|
||||
// anything else
|
||||
console.error('any');
|
||||
console.error('ErrorResponseHandler: unclassified error');
|
||||
return {
|
||||
statusCode: 500,
|
||||
payload: null,
|
||||
|
||||
@@ -3492,6 +3492,7 @@ export enum TelemetrytypesFieldContextDTO {
|
||||
span = 'span',
|
||||
trace = 'trace',
|
||||
resource = 'resource',
|
||||
scope = 'scope',
|
||||
attribute = 'attribute',
|
||||
body = 'body',
|
||||
'' = '',
|
||||
@@ -9021,6 +9022,7 @@ export enum SavedviewtypesSourceDTO {
|
||||
logs = 'logs',
|
||||
metrics = 'metrics',
|
||||
meter = 'meter',
|
||||
ai_observability = 'ai_observability',
|
||||
}
|
||||
export interface SavedviewtypesSavedViewSpecDTO {
|
||||
display?: SavedviewtypesDisplayDTO;
|
||||
|
||||
@@ -8,12 +8,14 @@ export interface AlertBreadcrumbProps {
|
||||
items: BreadcrumbItemConfig[];
|
||||
className?: string;
|
||||
showDivider?: boolean;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
function AlertBreadcrumb({
|
||||
items,
|
||||
className,
|
||||
showDivider = true,
|
||||
testId,
|
||||
}: AlertBreadcrumbProps): JSX.Element {
|
||||
const breadcrumbItems = items.map((item) => ({
|
||||
title: <BreadcrumbItem {...item} />,
|
||||
@@ -24,6 +26,7 @@ function AlertBreadcrumb({
|
||||
<Breadcrumb
|
||||
className={`${styles.breadcrumb} ${className || ''}`}
|
||||
items={breadcrumbItems}
|
||||
data-testid={testId}
|
||||
/>
|
||||
{showDivider && <Divider className={styles.divider} />}
|
||||
</>
|
||||
|
||||
@@ -29,6 +29,7 @@ function PopoverContent({
|
||||
<Link
|
||||
to={`${ROUTES.LOGS_EXPLORER}?${relatedLogsLink}`}
|
||||
className="contributor-row-popover-buttons__button"
|
||||
data-testid="alert-popover-view-logs"
|
||||
>
|
||||
<div className="icon">
|
||||
<LogsIcon />
|
||||
@@ -40,6 +41,7 @@ function PopoverContent({
|
||||
<Link
|
||||
to={`${ROUTES.TRACES_EXPLORER}?${relatedTracesLink}`}
|
||||
className="contributor-row-popover-buttons__button"
|
||||
data-testid="alert-popover-view-traces"
|
||||
>
|
||||
<div className="icon">
|
||||
<DraftingCompass
|
||||
|
||||
@@ -26,7 +26,10 @@ function ChangePercentage({
|
||||
}: ChangePercentageProps): JSX.Element {
|
||||
if (direction > 0) {
|
||||
return (
|
||||
<div className="change-percentage change-percentage--success">
|
||||
<div
|
||||
className="change-percentage change-percentage--success"
|
||||
data-testid="stats-card-change"
|
||||
>
|
||||
<div className="change-percentage__icon">
|
||||
<ArrowDownLeft size={14} color={Color.BG_FOREST_500} />
|
||||
</div>
|
||||
@@ -38,7 +41,10 @@ function ChangePercentage({
|
||||
}
|
||||
if (direction < 0) {
|
||||
return (
|
||||
<div className="change-percentage change-percentage--error">
|
||||
<div
|
||||
className="change-percentage change-percentage--error"
|
||||
data-testid="stats-card-change"
|
||||
>
|
||||
<div className="change-percentage__icon">
|
||||
<ArrowUpRight size={14} color={Color.BG_CHERRY_500} />
|
||||
</div>
|
||||
@@ -50,7 +56,10 @@ function ChangePercentage({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="change-percentage change-percentage--no-previous-data">
|
||||
<div
|
||||
className="change-percentage change-percentage--no-previous-data"
|
||||
data-testid="stats-card-change"
|
||||
>
|
||||
<div className="change-percentage__label">no previous data</div>
|
||||
</div>
|
||||
);
|
||||
@@ -103,7 +112,12 @@ function StatsCard({
|
||||
const formattedEndTimeForTooltip = convertTimestampToLocaleDateString(endTime);
|
||||
|
||||
return (
|
||||
<div className={`stats-card ${isEmpty ? 'stats-card--empty' : ''}`}>
|
||||
<div
|
||||
className={`stats-card ${isEmpty ? 'stats-card--empty' : ''}`}
|
||||
data-testid="stats-card"
|
||||
data-stats-title={title}
|
||||
data-empty={isEmpty ? 'true' : 'false'}
|
||||
>
|
||||
<div className="stats-card__title-wrapper">
|
||||
<div className="title">{title}</div>
|
||||
<div className="duration-indicator">
|
||||
@@ -123,7 +137,7 @@ function StatsCard({
|
||||
</div>
|
||||
|
||||
<div className="stats-card__stats">
|
||||
<div className="count-label">
|
||||
<div className="count-label" data-testid="stats-card-value">
|
||||
{isEmpty ? emptyMessage : displayValue || totalCurrentCount}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -81,7 +81,11 @@ function StatsGraph({ timeSeries, changeDirection }: Props): JSX.Element {
|
||||
);
|
||||
|
||||
return (
|
||||
<div style={{ height: '100%', width: '100%' }} ref={graphRef}>
|
||||
<div
|
||||
style={{ height: '100%', width: '100%' }}
|
||||
ref={graphRef}
|
||||
data-testid="stats-card-sparkline"
|
||||
>
|
||||
<Uplot data={[xData, yData]} options={options} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -48,11 +48,16 @@ function TopContributorsCard({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="top-contributors-card">
|
||||
<div className="top-contributors-card" data-testid="top-contributors-card">
|
||||
<div className="top-contributors-card__header">
|
||||
<div className="title">top contributors</div>
|
||||
{topContributorsData.length > 3 && (
|
||||
<Button type="text" className="view-all" onClick={toggleViewAllDrawer}>
|
||||
<Button
|
||||
type="text"
|
||||
className="view-all"
|
||||
onClick={toggleViewAllDrawer}
|
||||
data-testid="top-contributors-view-all"
|
||||
>
|
||||
<div className="label">View all</div>
|
||||
<div className="icon">
|
||||
<ArrowRight
|
||||
|
||||
@@ -68,7 +68,10 @@ function TopContributorsRows({
|
||||
relatedTracesLink={record.relatedTracesLink}
|
||||
relatedLogsLink={record.relatedLogsLink}
|
||||
>
|
||||
<div className="total-contribution">
|
||||
<div
|
||||
className="total-contribution"
|
||||
data-testid="top-contributors-row-count"
|
||||
>
|
||||
{count}/{totalCurrentTriggers}
|
||||
</div>
|
||||
</ConditionalAlertPopover>
|
||||
@@ -78,7 +81,10 @@ function TopContributorsRows({
|
||||
|
||||
const handleRowClick = (
|
||||
record: AlertRuleTopContributors,
|
||||
): HTMLAttributes<AlertRuleTimelineTableResponse> => ({
|
||||
): HTMLAttributes<AlertRuleTimelineTableResponse> & {
|
||||
'data-testid': string;
|
||||
} => ({
|
||||
'data-testid': 'top-contributors-row',
|
||||
onClick: (): void => {
|
||||
logEvent('Alert history: Top contributors row: Clicked', {
|
||||
labels: record.labels,
|
||||
|
||||
@@ -31,7 +31,10 @@ function ViewAllDrawer({
|
||||
}}
|
||||
title="Viewing All Contributors"
|
||||
>
|
||||
<div className="top-contributors-card--view-all">
|
||||
<div
|
||||
className="top-contributors-card--view-all"
|
||||
data-testid="top-contributors-drawer"
|
||||
>
|
||||
<div className="top-contributors-card__content">
|
||||
<TopContributorsRows
|
||||
topContributors={topContributorsData}
|
||||
|
||||
@@ -32,8 +32,8 @@ function GraphWrapper({
|
||||
}, [data?.data]);
|
||||
|
||||
return (
|
||||
<div className="timeline-graph">
|
||||
<div className="timeline-graph__title">
|
||||
<div className="timeline-graph" data-testid="timeline-graph">
|
||||
<div className="timeline-graph__title" data-testid="timeline-graph-title">
|
||||
{totalCurrentTriggers} triggers in {relativeTime}
|
||||
</div>
|
||||
<div className="timeline-graph__chart">
|
||||
|
||||
@@ -118,7 +118,10 @@ function TimelineTableContent(): JSX.Element {
|
||||
|
||||
const handleRowClick = (
|
||||
record: AlertRuleTimelineTableResponse,
|
||||
): HTMLAttributes<AlertRuleTimelineTableResponse> => ({
|
||||
): HTMLAttributes<AlertRuleTimelineTableResponse> & {
|
||||
'data-testid': string;
|
||||
} => ({
|
||||
'data-testid': 'timeline-row',
|
||||
onClick: (): void => {
|
||||
void logEvent('Alert history: Timeline table row: Clicked', {
|
||||
ruleId: record.ruleID,
|
||||
@@ -128,12 +131,15 @@ function TimelineTableContent(): JSX.Element {
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="timeline-table">
|
||||
<div className="timeline-table" data-testid="timeline-table">
|
||||
{/* If we don't wait to have the keys, the QuerySearch will not render them at first usage */}
|
||||
{!isLoadingKeys && hardcodedAttributeKeys ? (
|
||||
<div className="timeline-table__filter">
|
||||
<div className="timeline-table__filter-row">
|
||||
<div className="timeline-table__filter-search">
|
||||
<div
|
||||
className="timeline-table__filter-search"
|
||||
data-testid="timeline-filter-search"
|
||||
>
|
||||
<QuerySearch
|
||||
onChange={querySearchOnChange}
|
||||
queryData={queryData}
|
||||
@@ -155,6 +161,7 @@ function TimelineTableContent(): JSX.Element {
|
||||
<Skeleton.Input
|
||||
className="timeline-table__filter--loading-skeleton"
|
||||
active
|
||||
data-testid="timeline-filter-skeleton"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -172,14 +179,17 @@ function TimelineTableContent(): JSX.Element {
|
||||
locale={{
|
||||
emptyText:
|
||||
isError && apiError ? (
|
||||
<div className="timeline-table__error">
|
||||
<div className="timeline-table__error" data-testid="timeline-error">
|
||||
<ErrorContent error={apiError} />
|
||||
</div>
|
||||
) : undefined,
|
||||
}}
|
||||
footer={(): JSX.Element => (
|
||||
<div className="timeline-table__pagination">
|
||||
<div className="timeline-table__pagination-info">
|
||||
<div
|
||||
className="timeline-table__pagination-info"
|
||||
data-testid="timeline-footer-range"
|
||||
>
|
||||
{paginationConfig.showTotal?.(totalItems, [
|
||||
totalItems === 0
|
||||
? 0
|
||||
|
||||
@@ -21,18 +21,14 @@ export const timelineTableColumns = ({
|
||||
sorter: true,
|
||||
width: 140,
|
||||
render: (value): JSX.Element => (
|
||||
<div className="alert-rule-state">
|
||||
<AlertState state={value} showLabel />
|
||||
</div>
|
||||
<AlertState state={value} showLabel testId="timeline-row-state" />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'LABELS',
|
||||
dataIndex: 'labels',
|
||||
render: (labels): JSX.Element => (
|
||||
<div className="alert-rule-labels">
|
||||
<AlertLabels labels={labels} />
|
||||
</div>
|
||||
<AlertLabels labels={labels} testId="timeline-row-labels" />
|
||||
),
|
||||
},
|
||||
{
|
||||
@@ -40,7 +36,10 @@ export const timelineTableColumns = ({
|
||||
dataIndex: 'unixMilli',
|
||||
width: 200,
|
||||
render: (value): JSX.Element => (
|
||||
<div className="alert-rule__created-at">
|
||||
<div
|
||||
className="alert-rule__created-at"
|
||||
data-testid="timeline-row-created-at"
|
||||
>
|
||||
{formatTimezoneAdjustedTimestamp(value, DATE_TIME_FORMATS.DASH_DATETIME)}
|
||||
</div>
|
||||
),
|
||||
@@ -53,7 +52,7 @@ export const timelineTableColumns = ({
|
||||
if (!record.relatedTracesLink && !record.relatedLogsLink) {
|
||||
return (
|
||||
<Tooltip title="No links available for this item">
|
||||
<Button type="text" ghost disabled>
|
||||
<Button type="text" ghost disabled data-testid="timeline-row-actions">
|
||||
<Ellipsis className="dropdown-icon" size="md" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
@@ -65,7 +64,7 @@ export const timelineTableColumns = ({
|
||||
relatedTracesLink={record.relatedTracesLink ?? ''}
|
||||
relatedLogsLink={record.relatedLogsLink ?? ''}
|
||||
>
|
||||
<Button type="text" ghost>
|
||||
<Button type="text" ghost data-testid="timeline-row-actions">
|
||||
<Ellipsis className="dropdown-icon" size="md" />
|
||||
</Button>
|
||||
</ConditionalAlertPopover>
|
||||
|
||||
@@ -10,6 +10,7 @@ const fieldContextToSuggestionMap: Record<
|
||||
[TelemetrytypesFieldContextDTO.attribute]: 'attribute',
|
||||
// no maps for the following values on suggestion context
|
||||
[TelemetrytypesFieldContextDTO.trace]: undefined,
|
||||
[TelemetrytypesFieldContextDTO.scope]: undefined,
|
||||
[TelemetrytypesFieldContextDTO.body]: undefined,
|
||||
[TelemetrytypesFieldContextDTO.metric]: undefined,
|
||||
[TelemetrytypesFieldContextDTO.log]: undefined,
|
||||
|
||||
@@ -23,6 +23,7 @@ function TimelineTabs(): JSX.Element {
|
||||
{
|
||||
value: TimelineTab.OVERALL_STATUS,
|
||||
label: 'Overall Status',
|
||||
testId: 'timeline-tab-overall-status',
|
||||
},
|
||||
{
|
||||
value: TimelineTab.TOP_5_CONTRIBUTORS,
|
||||
@@ -33,6 +34,7 @@ function TimelineTabs(): JSX.Element {
|
||||
</div>
|
||||
),
|
||||
disabled: true,
|
||||
testId: 'timeline-tab-top-contributors',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -57,14 +59,17 @@ function TimelineFilters(): JSX.Element {
|
||||
{
|
||||
value: TimelineFilter.ALL,
|
||||
label: 'All',
|
||||
testId: 'timeline-filter-all',
|
||||
},
|
||||
{
|
||||
value: TimelineFilter.FIRED,
|
||||
label: 'Fired',
|
||||
testId: 'timeline-filter-fired',
|
||||
},
|
||||
{
|
||||
value: TimelineFilter.RESOLVED,
|
||||
label: 'Resolved',
|
||||
testId: 'timeline-filter-resolved',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ function AdvancedOptions(): JSX.Element {
|
||||
})
|
||||
}
|
||||
value={advancedOptions.sendNotificationIfDataIsMissing.toleranceLimit}
|
||||
testId="send-notification-if-data-is-missing-input"
|
||||
/>
|
||||
<Typography.Text>Minutes</Typography.Text>
|
||||
</div>
|
||||
@@ -66,6 +67,7 @@ function AdvancedOptions(): JSX.Element {
|
||||
})
|
||||
}
|
||||
value={advancedOptions.enforceMinimumDatapoints.minimumDatapoints}
|
||||
testId="enforce-minimum-datapoints-input"
|
||||
/>
|
||||
<Typography.Text>Datapoints</Typography.Text>
|
||||
</div>
|
||||
|
||||
@@ -66,6 +66,7 @@ function EvaluationWindowPopover({
|
||||
tabIndex={0}
|
||||
data-value={option.value}
|
||||
data-section-id={sectionId}
|
||||
data-testid={`${sectionId}-option-${option.value}`}
|
||||
onClick={(): void => onChange(option.value)}
|
||||
onKeyDown={(e): void => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
|
||||
@@ -186,6 +186,7 @@ function Footer(): JSX.Element {
|
||||
color="primary"
|
||||
onClick={handleSaveAlert}
|
||||
disabled={disableButtons || Boolean(alertValidationMessage)}
|
||||
testId="save-alert-rule-button"
|
||||
>
|
||||
{isCreatingAlertRule || isUpdatingAlertRule ? (
|
||||
<Loader data-testid="save-alert-rule-loader-icon" size={14} />
|
||||
@@ -218,6 +219,7 @@ function Footer(): JSX.Element {
|
||||
color="secondary"
|
||||
onClick={handleTestNotification}
|
||||
disabled={disableButtons || Boolean(alertValidationMessage)}
|
||||
testId="test-notification-button"
|
||||
>
|
||||
{isTestingAlertRule ? (
|
||||
<Loader data-testid="test-notification-loader-icon" size={14} />
|
||||
@@ -249,6 +251,7 @@ function Footer(): JSX.Element {
|
||||
color="secondary"
|
||||
onClick={handleDiscard}
|
||||
disabled={disableButtons}
|
||||
testId="discard-alert-rule-button"
|
||||
>
|
||||
<X size={14} /> Discard
|
||||
</Button>
|
||||
|
||||
@@ -119,6 +119,7 @@ function BasicInfo({
|
||||
<SeveritySelect
|
||||
getPopupContainer={popupContainer}
|
||||
defaultValue="critical"
|
||||
data-testid="alert-severity-select"
|
||||
onChange={(value: unknown | string): void => {
|
||||
const s = (value as string) || 'critical';
|
||||
setAlertDef({
|
||||
@@ -147,6 +148,7 @@ function BasicInfo({
|
||||
]}
|
||||
>
|
||||
<InputSmall
|
||||
data-testid="alert-name-input-v1"
|
||||
onChange={(e): void => {
|
||||
setAlertDef({
|
||||
...alertDef,
|
||||
@@ -161,6 +163,7 @@ function BasicInfo({
|
||||
name={['annotations', 'description']}
|
||||
>
|
||||
<TextareaMedium
|
||||
data-testid="alert-description-input"
|
||||
onChange={(e): void => {
|
||||
setAlertDef({
|
||||
...alertDef,
|
||||
|
||||
@@ -105,7 +105,7 @@ function QuerySection({
|
||||
{
|
||||
label: (
|
||||
<Tooltip title="Query Builder">
|
||||
<Button className="nav-btns">
|
||||
<Button className="nav-btns" data-testid="query-builder-tab">
|
||||
<Atom size={14} />
|
||||
<Typography.Text>Query Builder</Typography.Text>
|
||||
</Button>
|
||||
@@ -122,7 +122,11 @@ function QuerySection({
|
||||
: 'ClickHouse'
|
||||
}
|
||||
>
|
||||
<Button className="nav-btns" disabled={isAnomalyDetection}>
|
||||
<Button
|
||||
className="nav-btns"
|
||||
disabled={isAnomalyDetection}
|
||||
data-testid="clickhouse-tab"
|
||||
>
|
||||
<Terminal size={14} />
|
||||
<Typography.Text>ClickHouse Query</Typography.Text>
|
||||
</Button>
|
||||
@@ -162,7 +166,11 @@ function QuerySection({
|
||||
: 'ClickHouse'
|
||||
}
|
||||
>
|
||||
<Button className="nav-btns" disabled={isAnomalyDetection}>
|
||||
<Button
|
||||
className="nav-btns"
|
||||
disabled={isAnomalyDetection}
|
||||
data-testid="clickhouse-tab"
|
||||
>
|
||||
<Terminal size={14} />
|
||||
<Typography.Text>ClickHouse Query</Typography.Text>
|
||||
</Button>
|
||||
@@ -180,7 +188,11 @@ function QuerySection({
|
||||
: 'PromQL'
|
||||
}
|
||||
>
|
||||
<Button className="nav-btns" disabled={isAnomalyDetection}>
|
||||
<Button
|
||||
className="nav-btns"
|
||||
disabled={isAnomalyDetection}
|
||||
data-testid="promql-tab"
|
||||
>
|
||||
<PromQLIcon
|
||||
fillColor={isDarkMode ? Color.BG_VANILLA_200 : Color.BG_INK_300}
|
||||
/>
|
||||
|
||||
@@ -80,6 +80,7 @@ function RuleOptions({
|
||||
defaultValue={defaultCompareOp}
|
||||
value={alertDef.condition?.op}
|
||||
style={{ minWidth: '120px' }}
|
||||
data-testid="alert-threshold-op-select"
|
||||
onChange={(value: string | unknown): void => {
|
||||
const newOp = (value as string) || '';
|
||||
|
||||
@@ -116,6 +117,7 @@ function RuleOptions({
|
||||
defaultValue={defaultMatchType}
|
||||
style={{ minWidth: '130px' }}
|
||||
value={alertDef.condition?.matchType}
|
||||
data-testid="alert-threshold-match-type-select-v1"
|
||||
onChange={(value: string | unknown): void => handleMatchOptChange(value)}
|
||||
>
|
||||
<Select.Option value="1">{t('option_atleastonce')}</Select.Option>
|
||||
@@ -177,6 +179,7 @@ function RuleOptions({
|
||||
style={{ minWidth: '120px' }}
|
||||
value={alertDef.evalWindow}
|
||||
onChange={onChangeEvalWindow}
|
||||
data-testid="alert-eval-window-select"
|
||||
>
|
||||
<Select.Option value="5m0s">{t('option_5min')}</Select.Option>
|
||||
<Select.Option value="10m0s">{t('option_10min')}</Select.Option>
|
||||
@@ -194,6 +197,7 @@ function RuleOptions({
|
||||
style={{ minWidth: '120px' }}
|
||||
value={alertDef.evalWindow}
|
||||
onChange={onChangeEvalWindow}
|
||||
data-testid="alert-eval-window-select"
|
||||
>
|
||||
<Select.Option value="5m0s">{t('option_5min')}</Select.Option>
|
||||
<Select.Option value="10m0s">{t('option_10min')}</Select.Option>
|
||||
@@ -395,6 +399,7 @@ function RuleOptions({
|
||||
value={alertDef?.condition?.target}
|
||||
onChange={onChange}
|
||||
type="number"
|
||||
data-testid="alert-threshold-target-input"
|
||||
onWheel={(e): void => e.currentTarget.blur()}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -844,8 +844,6 @@ function FormAlertRules({
|
||||
|
||||
return (
|
||||
<>
|
||||
{Element}
|
||||
|
||||
<div
|
||||
id="top"
|
||||
className={`form-alert-rules-container ${
|
||||
@@ -968,6 +966,7 @@ function FormAlertRules({
|
||||
!isChannelConfigurationValid ||
|
||||
queryStatus === 'error'
|
||||
}
|
||||
data-testid="alert-save-button"
|
||||
>
|
||||
{isNewRule ? t('button_createrule') : t('button_savechanges')}
|
||||
</ActionButton>
|
||||
@@ -981,6 +980,7 @@ function FormAlertRules({
|
||||
}
|
||||
type="default"
|
||||
onClick={onTestRuleHandler}
|
||||
data-testid="alert-test-button"
|
||||
>
|
||||
{' '}
|
||||
{t('button_testrule')}
|
||||
@@ -989,6 +989,7 @@ function FormAlertRules({
|
||||
disabled={loading || false}
|
||||
type="default"
|
||||
onClick={onCancelHandler}
|
||||
data-testid="alert-cancel-button"
|
||||
>
|
||||
{isNewRule && t('button_cancelchanges')}
|
||||
{ruleId && !isEmpty(ruleId) && t('button_discard')}
|
||||
@@ -998,6 +999,7 @@ function FormAlertRules({
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
testId="alert-save-confirm-dialog"
|
||||
open={isConfirmSaveOpen}
|
||||
onOpenChange={setIsConfirmSaveOpen}
|
||||
title={t('confirm_save_title')}
|
||||
|
||||
@@ -174,6 +174,7 @@ function LabelSelect({
|
||||
|
||||
<div style={{ display: 'flex', width: '100%' }}>
|
||||
<Input
|
||||
data-testid="alert-labels-input-v1"
|
||||
placeholder={renderPlaceholder()}
|
||||
onChange={handleLabelChange}
|
||||
onKeyUp={(e): void => {
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
.explorer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-2) var(--spacing-0);
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
.trace-explorer-header {
|
||||
.trace-explorer-run-query {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
align-items: center;
|
||||
margin: 8px 16px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filter-outlined-btn {
|
||||
border-radius: 0px 2px 2px 0px;
|
||||
border-top: 1px solid var(--l1-border);
|
||||
border-right: 1px solid var(--l1-border);
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
.trace-explorer-header.single-child {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.traces-explorer-views {
|
||||
padding: 8px;
|
||||
padding-bottom: 60px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.ant-tabs-tabpane {
|
||||
padding: 0 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.qb-search-view-container {
|
||||
padding: 8px;
|
||||
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.ant-select-selector {
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l1-border) !important;
|
||||
background: var(--l2-background) !important;
|
||||
height: 34px !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
}
|
||||
|
||||
.trace-explorer-list-view {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.trace-explorer-traces-view {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.trace-explorer-table-view {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.trace-explorer-time-series-view {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.trace-explorer-page {
|
||||
display: flex;
|
||||
|
||||
// Meant to fix the query builder colors
|
||||
--input-background: var(--l2-background);
|
||||
--input-hover-background: var(--l2-background);
|
||||
--input-focus-background: var(--l2-background);
|
||||
--input-border-color: var(--l2-border);
|
||||
--input-hover-border-color: var(--internal-ant-border-color-hover);
|
||||
--input-focus-border-color: var(--internal-ant-border-color-hover);
|
||||
|
||||
.filter {
|
||||
width: 260px;
|
||||
height: 100%;
|
||||
min-height: 100vh;
|
||||
|
||||
border-right: 0px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background-color: var(--l1-background);
|
||||
|
||||
> .ant-card-body {
|
||||
padding: 0;
|
||||
width: 258px;
|
||||
}
|
||||
}
|
||||
|
||||
.trace-explorer {
|
||||
width: 100%;
|
||||
background: var(--l1-background);
|
||||
|
||||
> .ant-card-body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
border-color: var(--l1-border);
|
||||
}
|
||||
.trace-explorer.filters-expanded {
|
||||
width: calc(100% - 260px);
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,373 @@
|
||||
import styles from './Explorer.module.scss';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { useSearchParams } from 'react-router-dom-v5-compat';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { Card } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import cx from 'classnames';
|
||||
import ExplorerCard from 'components/ExplorerCard/ExplorerCard';
|
||||
import QueryCancelledPlaceholder from 'components/QueryCancelledPlaceholder';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||
import { AVAILABLE_EXPORT_PANEL_TYPES } from 'constants/panelTypes';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { usePageActions } from 'container/AIAssistant/pageActions/usePageActions';
|
||||
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
|
||||
import { useOptionsMenu } from 'container/OptionsMenu';
|
||||
import LeftToolbarActions from 'container/QueryBuilder/components/ToolbarActions/LeftToolbarActions';
|
||||
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
|
||||
import Toolbar from 'container/Toolbar/Toolbar';
|
||||
import {
|
||||
getExportQueryData,
|
||||
getQueryByPanelType,
|
||||
} from 'container/TracesExplorer/explorerUtils';
|
||||
import { ExportDashboard } from 'hooks/dashboard/useExportDashboards';
|
||||
import { useGetExportToDashboardLink } from 'hooks/dashboard/useGetExportToDashboardLink';
|
||||
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
|
||||
import {
|
||||
ICurrentQueryData,
|
||||
useHandleExplorerTabChange,
|
||||
} from 'hooks/useHandleExplorerTabChange';
|
||||
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
|
||||
import { ExplorerViews } from 'pages/LogsExplorer/utils';
|
||||
import {
|
||||
tracesAddFilterAction,
|
||||
tracesChangeViewAction,
|
||||
tracesRunQueryAction,
|
||||
tracesSaveViewAction,
|
||||
} from 'pages/TracesExplorer/aiActions';
|
||||
import { Warning } from 'types/api';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import {
|
||||
explorerViewToPanelType,
|
||||
getExplorerViewFromUrl,
|
||||
} from 'utils/explorerUtils';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { TOOLBAR_VIEWS } from './constants';
|
||||
import ListView from './ListView/ListView';
|
||||
import { defaultSelectedColumns } from './ListView/configs';
|
||||
import QuerySection from './QuerySection/QuerySection';
|
||||
import TableView from './TableView/TableView';
|
||||
import TimeSeriesView from './TimeSeriesView/TimeSeriesView';
|
||||
import TracesView from './TracesView/TracesView';
|
||||
|
||||
import './Explorer.styles.scss';
|
||||
|
||||
// Shell for the AI Observability Explorer tab. Owns the
|
||||
// /ai-observability/explorer route and is intentionally empty for now: the
|
||||
// query builder + results surface land in a follow-up.
|
||||
function Explorer(): JSX.Element {
|
||||
const {
|
||||
panelType,
|
||||
updateAllQueriesOperators,
|
||||
handleRunQuery,
|
||||
stagedQuery,
|
||||
handleSetConfig,
|
||||
currentQuery,
|
||||
handleSetQueryData,
|
||||
redirectWithQueryBuilderData,
|
||||
} = useQueryBuilder();
|
||||
|
||||
const isAIAssistantEnabled = useIsAIAssistantEnabled();
|
||||
|
||||
const { options } = useOptionsMenu({
|
||||
dataSource: DataSource.TRACES,
|
||||
aggregateOperator: 'noop',
|
||||
initialOptions: {
|
||||
selectColumns: defaultSelectedColumns,
|
||||
},
|
||||
});
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
const listQueryKeyRef = useRef<any>();
|
||||
|
||||
// Get panel type from URL
|
||||
const panelTypesFromUrl = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
|
||||
const [isLoadingQueries, setIsLoadingQueries] = useState<boolean>(false);
|
||||
const [isCancelled, setIsCancelled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoadingQueries) {
|
||||
setIsCancelled(false);
|
||||
}
|
||||
}, [isLoadingQueries]);
|
||||
|
||||
const handleCancelQuery = useCallback(() => {
|
||||
if (listQueryKeyRef.current) {
|
||||
queryClient.cancelQueries(listQueryKeyRef.current);
|
||||
}
|
||||
setIsCancelled(true);
|
||||
// Reset loading state — the active view unmounts when cancelled, so no
|
||||
// child will call setIsLoadingQueries(false) otherwise.
|
||||
setIsLoadingQueries(false);
|
||||
}, [queryClient]);
|
||||
|
||||
const [selectedView, setSelectedView] = useState<ExplorerViews>(() =>
|
||||
getExplorerViewFromUrl(searchParams, panelTypesFromUrl),
|
||||
);
|
||||
|
||||
const [warning, setWarning] = useState<Warning | undefined>();
|
||||
const [isOpen, setOpen] = useState<boolean>(true);
|
||||
|
||||
const defaultQuery = useMemo(
|
||||
(): Query =>
|
||||
updateAllQueriesOperators(
|
||||
initialQueriesMap.traces,
|
||||
PANEL_TYPES.LIST,
|
||||
DataSource.TRACES,
|
||||
),
|
||||
[updateAllQueriesOperators],
|
||||
);
|
||||
|
||||
const { handleExplorerTabChange } = useHandleExplorerTabChange();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const getExportToDashboardLink = useGetExportToDashboardLink();
|
||||
|
||||
const handleChangeSelectedView = useCallback(
|
||||
(view: ExplorerViews, querySearchParameters?: ICurrentQueryData): void => {
|
||||
handleSetConfig(explorerViewToPanelType[view], DataSource.TRACES);
|
||||
|
||||
setSelectedView(view);
|
||||
|
||||
handleExplorerTabChange(
|
||||
explorerViewToPanelType[view],
|
||||
querySearchParameters,
|
||||
);
|
||||
},
|
||||
[handleExplorerTabChange, handleSetConfig],
|
||||
);
|
||||
|
||||
// ─── AI Assistant page actions (only when license feature is on) ───────────
|
||||
const aiActions = useMemo(
|
||||
() =>
|
||||
isAIAssistantEnabled
|
||||
? [
|
||||
tracesRunQueryAction({
|
||||
currentQuery,
|
||||
handleSetQueryData,
|
||||
redirectWithQueryBuilderData,
|
||||
}),
|
||||
tracesAddFilterAction({
|
||||
currentQuery,
|
||||
handleSetQueryData,
|
||||
redirectWithQueryBuilderData,
|
||||
}),
|
||||
tracesChangeViewAction({
|
||||
onChangeView: (view) => handleChangeSelectedView(view as ExplorerViews),
|
||||
}),
|
||||
tracesSaveViewAction({
|
||||
// POC stub — logs a save request; wire to real API when available
|
||||
onSaveView: async (name) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.info('[AI Assistant] Save view requested:', name);
|
||||
},
|
||||
}),
|
||||
]
|
||||
: [],
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
isAIAssistantEnabled,
|
||||
currentQuery,
|
||||
handleSetQueryData,
|
||||
redirectWithQueryBuilderData,
|
||||
handleChangeSelectedView,
|
||||
],
|
||||
);
|
||||
usePageActions('traces-explorer', aiActions);
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
const exportDefaultQuery = useMemo(
|
||||
() =>
|
||||
getQueryByPanelType(
|
||||
stagedQuery || initialQueriesMap.traces,
|
||||
panelType || PANEL_TYPES.LIST,
|
||||
),
|
||||
[stagedQuery, panelType],
|
||||
);
|
||||
|
||||
const handleExport = useCallback(
|
||||
(dashboard: ExportDashboard | null, isNewDashboard?: boolean): void => {
|
||||
if (!dashboard || !panelType) {
|
||||
return;
|
||||
}
|
||||
|
||||
const panelTypeParam = AVAILABLE_EXPORT_PANEL_TYPES.includes(panelType)
|
||||
? panelType
|
||||
: PANEL_TYPES.TIME_SERIES;
|
||||
|
||||
const widgetId = v4();
|
||||
|
||||
const query = getExportQueryData(
|
||||
exportDefaultQuery,
|
||||
panelTypeParam,
|
||||
options,
|
||||
);
|
||||
|
||||
logEvent('Traces Explorer: Add to dashboard successful', {
|
||||
panelType,
|
||||
isNewDashboard,
|
||||
dashboardName: dashboard?.title,
|
||||
});
|
||||
|
||||
const dashboardEditView = getExportToDashboardLink({
|
||||
query,
|
||||
panelType: panelTypeParam,
|
||||
dashboardId: dashboard.id,
|
||||
widgetId,
|
||||
});
|
||||
|
||||
if (dashboardEditView) {
|
||||
safeNavigate(dashboardEditView);
|
||||
}
|
||||
},
|
||||
[
|
||||
exportDefaultQuery,
|
||||
panelType,
|
||||
safeNavigate,
|
||||
options,
|
||||
getExportToDashboardLink,
|
||||
],
|
||||
);
|
||||
|
||||
useShareBuilderUrl({ defaultValue: defaultQuery });
|
||||
|
||||
const logEventCalledRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!logEventCalledRef.current) {
|
||||
logEvent('Traces Explorer: Page visited', {});
|
||||
logEventCalledRef.current = true;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const isFilterApplied = useMemo(() => {
|
||||
// if any of the non-disabled queries has filters applied, return true
|
||||
const result = stagedQuery?.builder?.queryData?.filter(
|
||||
(item) => !isEmpty(item.filters?.items) && !item.disabled,
|
||||
);
|
||||
return !!result?.length;
|
||||
}, [stagedQuery]);
|
||||
|
||||
return (
|
||||
<div className={styles.explorer} data-testid="llm-observability-explorer">
|
||||
<div className={styles.placeholder}>Explorer coming soon.</div>
|
||||
</div>
|
||||
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
|
||||
<div
|
||||
className="trace-explorer-page"
|
||||
data-testid="llm-observability-explorer"
|
||||
>
|
||||
<Card className="filter" hidden={!isOpen}>
|
||||
<QuickFilters
|
||||
className="qf-traces-explorer"
|
||||
source={QuickFiltersSource.TRACES_EXPLORER}
|
||||
signal={SignalType.TRACES}
|
||||
handleFilterVisibilityChange={(): void => {
|
||||
setOpen(!isOpen);
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
<div
|
||||
className={cx('trace-explorer', {
|
||||
'filters-expanded': isOpen,
|
||||
})}
|
||||
>
|
||||
<div className="trace-explorer-header">
|
||||
<Toolbar
|
||||
showAutoRefresh
|
||||
leftActions={
|
||||
<LeftToolbarActions
|
||||
showFilter={isOpen}
|
||||
handleFilterVisibilityChange={(): void => setOpen(!isOpen)}
|
||||
items={TOOLBAR_VIEWS}
|
||||
selectedView={selectedView}
|
||||
onChangeSelectedView={handleChangeSelectedView}
|
||||
/>
|
||||
}
|
||||
warningElement={
|
||||
!isEmpty(warning) ? <WarningPopover warningData={warning} /> : <div />
|
||||
}
|
||||
rightActions={
|
||||
<RightToolbarActions
|
||||
onStageRunQuery={(): void => {
|
||||
setIsCancelled(false);
|
||||
handleRunQuery();
|
||||
}}
|
||||
isLoadingQueries={isLoadingQueries}
|
||||
handleCancelQuery={handleCancelQuery}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<ExplorerCard sourcepage={DataSource.TRACES}>
|
||||
<div className="query-section-container">
|
||||
<QuerySection />
|
||||
</div>
|
||||
</ExplorerCard>
|
||||
|
||||
<div className="traces-explorer-views">
|
||||
{isCancelled && (
|
||||
<QueryCancelledPlaceholder subText='Click "Run Query" to load traces.' />
|
||||
)}
|
||||
|
||||
{!isCancelled && selectedView === ExplorerViews.LIST && (
|
||||
<div className="trace-explorer-list-view">
|
||||
<ListView
|
||||
isFilterApplied={isFilterApplied}
|
||||
setWarning={setWarning}
|
||||
setIsLoadingQueries={setIsLoadingQueries}
|
||||
queryKeyRef={listQueryKeyRef}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isCancelled && selectedView === ExplorerViews.TRACE && (
|
||||
<div className="trace-explorer-traces-view">
|
||||
<TracesView
|
||||
isFilterApplied={isFilterApplied}
|
||||
setWarning={setWarning}
|
||||
setIsLoadingQueries={setIsLoadingQueries}
|
||||
queryKeyRef={listQueryKeyRef}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isCancelled && selectedView === ExplorerViews.TIMESERIES && (
|
||||
<div className="trace-explorer-time-series-view">
|
||||
<TimeSeriesView
|
||||
dataSource={DataSource.TRACES}
|
||||
isFilterApplied={isFilterApplied}
|
||||
setWarning={setWarning}
|
||||
setIsLoadingQueries={setIsLoadingQueries}
|
||||
queryKeyRef={listQueryKeyRef}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isCancelled && selectedView === ExplorerViews.TABLE && (
|
||||
<div className="trace-explorer-table-view">
|
||||
<TableView
|
||||
setWarning={setWarning}
|
||||
setIsLoadingQueries={setIsLoadingQueries}
|
||||
queryKeyRef={listQueryKeyRef}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ExplorerOptionWrapper
|
||||
disabled={!stagedQuery}
|
||||
query={exportDefaultQuery}
|
||||
sourcepage={DataSource.TRACES}
|
||||
onExport={handleExport}
|
||||
handleChangeSelectedView={handleChangeSelectedView}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Sentry.ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
height: calc(100vh - 240px);
|
||||
min-height: 400px;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
.trace-explorer-controls {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.order-by-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
.order-by-label {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 16px; /* 133.333% */
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.order-by-select {
|
||||
width: 100px;
|
||||
|
||||
.ant-select-selector {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
background-color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import {
|
||||
Dispatch,
|
||||
memo,
|
||||
MutableRefObject,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { QueryKey } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
|
||||
import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView/types';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { useOptionsMenu } from 'container/OptionsMenu';
|
||||
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
import TraceExplorerControls from 'container/TracesExplorer/Controls';
|
||||
import { getListViewQuery } from 'container/TracesExplorer/explorerUtils';
|
||||
import {
|
||||
getTraceLink,
|
||||
transformSpanRows,
|
||||
} from 'container/TracesExplorer/ListView/utils';
|
||||
import {
|
||||
getFieldColumn,
|
||||
TracesTableRow,
|
||||
} from 'container/TracesExplorer/TracesTable/getFieldColumn';
|
||||
import TracesTable from 'container/TracesExplorer/TracesTable/TracesTable';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { Pagination } from 'hooks/queryPagination';
|
||||
import { getDefaultPaginationConfig } from 'hooks/queryPagination/utils';
|
||||
import useUrlQueryData from 'hooks/useUrlQueryData';
|
||||
import { ArrowUp10, Minus } from '@signozhq/icons';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { Warning } from 'types/api';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import {
|
||||
defaultSelectedColumns,
|
||||
PER_PAGE_OPTIONS,
|
||||
TIMESTAMP_FIELD,
|
||||
} from './configs';
|
||||
import './ListView.styles.scss';
|
||||
|
||||
import styles from './ListView.module.scss';
|
||||
|
||||
interface ListViewProps {
|
||||
isFilterApplied: boolean;
|
||||
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
|
||||
setIsLoadingQueries: Dispatch<SetStateAction<boolean>>;
|
||||
queryKeyRef?: MutableRefObject<QueryKey | undefined>;
|
||||
}
|
||||
|
||||
function ListView({
|
||||
isFilterApplied,
|
||||
setWarning,
|
||||
setIsLoadingQueries,
|
||||
queryKeyRef,
|
||||
}: ListViewProps): JSX.Element {
|
||||
const { stagedQuery, panelType: panelTypeFromQueryBuilder } =
|
||||
useQueryBuilder();
|
||||
|
||||
const panelType = panelTypeFromQueryBuilder || PANEL_TYPES.LIST;
|
||||
|
||||
const [orderBy, setOrderBy] = useState<string>('timestamp:desc');
|
||||
|
||||
const {
|
||||
selectedTime: globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
loading: timeRangeUpdateLoading,
|
||||
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
|
||||
|
||||
const { options, config } = useOptionsMenu({
|
||||
dataSource: DataSource.TRACES,
|
||||
aggregateOperator: 'count',
|
||||
initialOptions: {
|
||||
selectColumns: defaultSelectedColumns,
|
||||
},
|
||||
});
|
||||
|
||||
const { queryData: paginationQueryData } = useUrlQueryData<Pagination>(
|
||||
QueryParams.pagination,
|
||||
);
|
||||
const paginationConfig =
|
||||
paginationQueryData ?? getDefaultPaginationConfig(PER_PAGE_OPTIONS);
|
||||
|
||||
const requestQuery = useMemo(
|
||||
() => getListViewQuery(stagedQuery || initialQueriesMap.traces, orderBy),
|
||||
[stagedQuery, orderBy],
|
||||
);
|
||||
|
||||
// Stable sorted-name signature for the queryKey.
|
||||
// - Drag updates selectColumns; raw queryKey would churn on reorder.
|
||||
// - Trace API fetches only listed columns → add/remove must refetch.
|
||||
// - Sorted-name signature: stable on reorder, changes on add/remove.
|
||||
const selectColumnsSignature = useMemo(
|
||||
() =>
|
||||
(options?.selectColumns ?? [])
|
||||
.map((c) => c.name)
|
||||
.sort()
|
||||
.join(','),
|
||||
[options?.selectColumns],
|
||||
);
|
||||
|
||||
const queryKey = useMemo(
|
||||
() => [
|
||||
REACT_QUERY_KEY.GET_QUERY_RANGE,
|
||||
globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
stagedQuery,
|
||||
panelType,
|
||||
paginationConfig,
|
||||
selectColumnsSignature,
|
||||
orderBy,
|
||||
],
|
||||
[
|
||||
stagedQuery,
|
||||
panelType,
|
||||
globalSelectedTime,
|
||||
paginationConfig,
|
||||
selectColumnsSignature,
|
||||
maxTime,
|
||||
minTime,
|
||||
orderBy,
|
||||
],
|
||||
);
|
||||
|
||||
if (queryKeyRef) {
|
||||
queryKeyRef.current = queryKey;
|
||||
}
|
||||
|
||||
const { data, isFetching, isLoading, isError, error } = useGetQueryRange(
|
||||
{
|
||||
query: requestQuery,
|
||||
graphType: panelType,
|
||||
selectedTime: 'GLOBAL_TIME' as const,
|
||||
globalSelectedInterval: globalSelectedTime as CustomTimeType,
|
||||
params: {
|
||||
dataSource: 'traces',
|
||||
},
|
||||
tableParams: {
|
||||
pagination: paginationConfig,
|
||||
selectColumns: options?.selectColumns,
|
||||
},
|
||||
},
|
||||
ENTITY_VERSION_V5,
|
||||
{
|
||||
queryKey,
|
||||
enabled:
|
||||
// don't make api call while the time range state in redux is loading
|
||||
!timeRangeUpdateLoading &&
|
||||
!!stagedQuery &&
|
||||
panelType === PANEL_TYPES.LIST &&
|
||||
!!options?.selectColumns?.length,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.payload) {
|
||||
setWarning(data?.warning);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data?.payload, data?.warning]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || isFetching) {
|
||||
setIsLoadingQueries(true);
|
||||
} else {
|
||||
setIsLoadingQueries(false);
|
||||
}
|
||||
}, [isLoading, isFetching, setIsLoadingQueries]);
|
||||
|
||||
const queryTableDataResult = data?.payload?.data?.newResult?.data?.result;
|
||||
const queryTableData = useMemo(
|
||||
() => queryTableDataResult || [],
|
||||
[queryTableDataResult],
|
||||
);
|
||||
|
||||
const columns = useMemo<TableColumnDef<TracesTableRow>[]>(() => {
|
||||
const fields = [
|
||||
TIMESTAMP_FIELD,
|
||||
...(options?.selectColumns ?? []).filter(
|
||||
(field) => field.name !== TIMESTAMP_FIELD.name,
|
||||
),
|
||||
];
|
||||
return fields.map((field) => getFieldColumn(field));
|
||||
}, [options?.selectColumns]);
|
||||
|
||||
const rows = useMemo(
|
||||
() => transformSpanRows(queryTableData),
|
||||
[queryTableData],
|
||||
);
|
||||
|
||||
const handleColumnOrderChange = useCallback(
|
||||
(reordered: TableColumnDef<TracesTableRow>[]): void => {
|
||||
config?.addColumn?.onReorder(reordered.map((column) => column.id));
|
||||
},
|
||||
[config],
|
||||
);
|
||||
|
||||
const handleOrderChange = useCallback((value: string) => {
|
||||
setOrderBy(value);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isFetching && !isError && rows.length !== 0) {
|
||||
void logEvent('Traces Explorer: Data present', {
|
||||
panelType,
|
||||
});
|
||||
}
|
||||
}, [isLoading, isFetching, isError, rows, panelType]);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className="trace-explorer-controls">
|
||||
<div className="order-by-container">
|
||||
<div className="order-by-label">
|
||||
Order by <Minus size={14} /> <ArrowUp10 size={14} />
|
||||
</div>
|
||||
|
||||
<ListViewOrderBy
|
||||
value={orderBy}
|
||||
onChange={handleOrderChange}
|
||||
dataSource={DataSource.TRACES}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DownloadOptionsMenu
|
||||
dataSource={DataSource.TRACES}
|
||||
selectedColumns={options?.selectColumns}
|
||||
/>
|
||||
|
||||
<TraceExplorerControls
|
||||
isLoading={isFetching}
|
||||
totalCount={rows.length}
|
||||
config={config}
|
||||
perPageOptions={PER_PAGE_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TracesTable
|
||||
data={rows}
|
||||
columns={columns}
|
||||
panelType="LIST"
|
||||
getRowHref={getTraceLink}
|
||||
isLoading={isLoading}
|
||||
isFetching={isFetching}
|
||||
isError={isError}
|
||||
error={error}
|
||||
isFilterApplied={isFilterApplied}
|
||||
onColumnOrderChange={handleColumnOrderChange}
|
||||
onColumnRemove={config?.addColumn?.onRemove}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ListView.defaultProps = {
|
||||
queryKeyRef: undefined,
|
||||
};
|
||||
|
||||
export default memo(ListView);
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination';
|
||||
|
||||
export const defaultSelectedColumns: string[] = [
|
||||
'service.name',
|
||||
'name',
|
||||
'duration_nano',
|
||||
'http_method',
|
||||
'response_status_code',
|
||||
'timestamp',
|
||||
];
|
||||
|
||||
export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];
|
||||
|
||||
// Pinned timestamp column
|
||||
export const TIMESTAMP_FIELD = {
|
||||
name: 'timestamp',
|
||||
fieldContext: 'span',
|
||||
} as TelemetryFieldKey;
|
||||
@@ -0,0 +1,61 @@
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ExplorerOrderBy from 'container/ExplorerOrderBy';
|
||||
import { OrderByFilterProps } from 'container/QueryBuilder/filters/OrderByFilter/OrderByFilter.interfaces';
|
||||
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
|
||||
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
function QuerySection(): JSX.Element {
|
||||
const panelTypes = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
|
||||
|
||||
const filterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(() => {
|
||||
const isList = panelTypes === PANEL_TYPES.LIST;
|
||||
const config: QueryBuilderProps['filterConfigs'] = {
|
||||
stepInterval: { isHidden: false, isDisabled: false },
|
||||
limit: { isHidden: isList, isDisabled: true },
|
||||
having: { isHidden: isList, isDisabled: true },
|
||||
};
|
||||
|
||||
return config;
|
||||
}, [panelTypes]);
|
||||
|
||||
const renderOrderBy = useCallback(
|
||||
({ query, onChange }: OrderByFilterProps) => (
|
||||
<ExplorerOrderBy query={query} onChange={onChange} />
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const queryComponents = useMemo((): QueryBuilderProps['queryComponents'] => {
|
||||
const shouldRenderCustomOrderBy =
|
||||
panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE;
|
||||
|
||||
return {
|
||||
...(shouldRenderCustomOrderBy ? { renderOrderBy } : {}),
|
||||
};
|
||||
}, [panelTypes, renderOrderBy]);
|
||||
|
||||
const isListViewPanel = useMemo(
|
||||
() => panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE,
|
||||
[panelTypes],
|
||||
);
|
||||
|
||||
return (
|
||||
<QueryBuilderV2
|
||||
isListViewPanel={isListViewPanel}
|
||||
showTraceOperator
|
||||
config={{ initialDataSource: DataSource.TRACES, queryVariant: 'static' }}
|
||||
queryComponents={queryComponents}
|
||||
panelType={panelTypes}
|
||||
filterConfigs={filterConfigs}
|
||||
showOnlyWhereClause={
|
||||
panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE
|
||||
}
|
||||
version="v3" // setting this to v3 as we this is rendered in logs explorer
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(QuerySection);
|
||||
@@ -0,0 +1,7 @@
|
||||
.traces-table-view-header {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import {
|
||||
Dispatch,
|
||||
memo,
|
||||
MutableRefObject,
|
||||
SetStateAction,
|
||||
useEffect,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Space } from 'antd';
|
||||
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
|
||||
import ExportMenu from 'components/ExportMenu/ExportMenu';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { QueryTable } from 'container/QueryTable';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { Warning } from 'types/api';
|
||||
import APIError from 'types/api/error';
|
||||
import { QueryDataV3 } from 'types/api/widgets/getQuery';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import './TableView.styles.scss';
|
||||
|
||||
function TableView({
|
||||
setWarning,
|
||||
setIsLoadingQueries,
|
||||
queryKeyRef,
|
||||
}: {
|
||||
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
|
||||
setIsLoadingQueries: Dispatch<SetStateAction<boolean>>;
|
||||
queryKeyRef?: MutableRefObject<any>;
|
||||
}): JSX.Element {
|
||||
const { stagedQuery, panelType } = useQueryBuilder();
|
||||
|
||||
const {
|
||||
selectedTime: globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
|
||||
|
||||
const queryKey = useMemo(
|
||||
() => [
|
||||
REACT_QUERY_KEY.GET_QUERY_RANGE,
|
||||
globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
stagedQuery,
|
||||
],
|
||||
[globalSelectedTime, maxTime, minTime, stagedQuery],
|
||||
);
|
||||
|
||||
if (queryKeyRef) {
|
||||
queryKeyRef.current = queryKey;
|
||||
}
|
||||
|
||||
const { data, isLoading, isFetching, isError, error } = useGetQueryRange(
|
||||
{
|
||||
query: stagedQuery || initialQueriesMap.traces,
|
||||
graphType: panelType || PANEL_TYPES.TABLE,
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
globalSelectedInterval: globalSelectedTime,
|
||||
params: {
|
||||
dataSource: 'traces',
|
||||
},
|
||||
},
|
||||
ENTITY_VERSION_V5,
|
||||
{
|
||||
queryKey,
|
||||
enabled: !!stagedQuery && panelType === PANEL_TYPES.TABLE,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || isFetching) {
|
||||
setIsLoadingQueries(true);
|
||||
} else {
|
||||
setIsLoadingQueries(false);
|
||||
}
|
||||
}, [isLoading, isFetching, setIsLoadingQueries]);
|
||||
|
||||
const queryTableData = useMemo(
|
||||
() =>
|
||||
data?.payload?.data?.newResult?.data?.result ||
|
||||
data?.payload.data.result ||
|
||||
[],
|
||||
[data],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.payload) {
|
||||
setWarning(data.warning);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data?.payload, data?.warning]);
|
||||
|
||||
return (
|
||||
<Space.Compact block direction="vertical">
|
||||
{isError && error && <ErrorInPlace error={error as APIError} />}
|
||||
{!isError && data && (
|
||||
<div className="traces-table-view-header">
|
||||
<ExportMenu
|
||||
dataSource={DataSource.TRACES}
|
||||
data={data}
|
||||
query={stagedQuery || initialQueriesMap.traces}
|
||||
fileName="traces-table"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!isError && (
|
||||
<QueryTable
|
||||
query={stagedQuery || initialQueriesMap.traces}
|
||||
queryTableData={queryTableData as QueryDataV3[]}
|
||||
loading={isLoading}
|
||||
sticky
|
||||
/>
|
||||
)}
|
||||
</Space.Compact>
|
||||
);
|
||||
}
|
||||
|
||||
TableView.defaultProps = {
|
||||
queryKeyRef: undefined,
|
||||
};
|
||||
|
||||
export default memo(TableView);
|
||||
@@ -0,0 +1,8 @@
|
||||
.trace-explorer-time-series-view-container {
|
||||
&-header {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import {
|
||||
Dispatch,
|
||||
MutableRefObject,
|
||||
SetStateAction,
|
||||
useEffect,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
|
||||
import { convertDataValueToMs } from 'container/TimeSeriesView/utils';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import useUrlYAxisUnit from 'hooks/useUrlYAxisUnit';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { Warning } from 'types/api';
|
||||
import APIError from 'types/api/error';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import './TimeSeriesView.styles.scss';
|
||||
|
||||
function TimeSeriesViewContainer({
|
||||
dataSource = DataSource.TRACES,
|
||||
isFilterApplied,
|
||||
setWarning,
|
||||
setIsLoadingQueries,
|
||||
queryKeyRef,
|
||||
}: TimeSeriesViewProps): JSX.Element {
|
||||
const { stagedQuery, currentQuery, panelType } = useQueryBuilder();
|
||||
|
||||
const isValidToConvertToMs = useMemo(() => {
|
||||
const isValid: boolean[] = [];
|
||||
|
||||
currentQuery.builder.queryData.forEach(
|
||||
({ aggregateAttribute, aggregateOperator }) => {
|
||||
const isExistDurationNanoAttribute =
|
||||
aggregateAttribute?.key === 'durationNano' ||
|
||||
aggregateAttribute?.key === 'duration_nano';
|
||||
|
||||
const isCountOperator =
|
||||
aggregateOperator === 'count' || aggregateOperator === 'count_distinct';
|
||||
|
||||
isValid.push(!isCountOperator && isExistDurationNanoAttribute);
|
||||
},
|
||||
);
|
||||
|
||||
return isValid.every(Boolean);
|
||||
}, [currentQuery]);
|
||||
|
||||
const defaultUnit = isValidToConvertToMs ? 'ms' : 'short';
|
||||
const { yAxisUnit, onUnitChange } = useUrlYAxisUnit(defaultUnit);
|
||||
|
||||
const {
|
||||
selectedTime: globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
|
||||
|
||||
const queryKey = useMemo(
|
||||
() => [
|
||||
REACT_QUERY_KEY.GET_QUERY_RANGE,
|
||||
globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
stagedQuery,
|
||||
],
|
||||
[globalSelectedTime, maxTime, minTime, stagedQuery],
|
||||
);
|
||||
|
||||
if (queryKeyRef) {
|
||||
queryKeyRef.current = queryKey;
|
||||
}
|
||||
|
||||
const { data, isLoading, isFetching, isError, error } = useGetQueryRange(
|
||||
{
|
||||
query: stagedQuery || initialQueriesMap[dataSource],
|
||||
graphType: panelType || PANEL_TYPES.TIME_SERIES,
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
globalSelectedInterval: globalSelectedTime,
|
||||
params: {
|
||||
dataSource,
|
||||
},
|
||||
},
|
||||
// ENTITY_VERSION_V4,
|
||||
ENTITY_VERSION_V5,
|
||||
{
|
||||
queryKey,
|
||||
enabled: !!stagedQuery && panelType === PANEL_TYPES.TIME_SERIES,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.payload) {
|
||||
setWarning(data?.warning);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data?.payload, data?.warning]);
|
||||
|
||||
const responseData = useMemo(
|
||||
() => (isValidToConvertToMs ? convertDataValueToMs(data) : data),
|
||||
[data, isValidToConvertToMs],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || isFetching) {
|
||||
setIsLoadingQueries(true);
|
||||
} else {
|
||||
setIsLoadingQueries(false);
|
||||
}
|
||||
}, [isLoading, isFetching, setIsLoadingQueries]);
|
||||
|
||||
return (
|
||||
<div className="trace-explorer-time-series-view-container">
|
||||
<TimeSeriesView
|
||||
isFilterApplied={isFilterApplied}
|
||||
isError={isError}
|
||||
error={error as APIError}
|
||||
isLoading={isLoading || isFetching}
|
||||
data={responseData}
|
||||
yAxisUnit={yAxisUnit}
|
||||
onYAxisUnitChange={onUnitChange}
|
||||
dataSource={dataSource}
|
||||
setWarning={setWarning}
|
||||
allowExport
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface TimeSeriesViewProps {
|
||||
dataSource?: DataSource;
|
||||
isFilterApplied: boolean;
|
||||
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
|
||||
setIsLoadingQueries: Dispatch<SetStateAction<boolean>>;
|
||||
queryKeyRef?: MutableRefObject<any>;
|
||||
}
|
||||
|
||||
TimeSeriesViewContainer.defaultProps = {
|
||||
dataSource: DataSource.TRACES,
|
||||
queryKeyRef: undefined,
|
||||
};
|
||||
|
||||
export default TimeSeriesViewContainer;
|
||||
@@ -0,0 +1,15 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
// Page chain isn't a flex column, so anchor the virtualized table against the viewport.
|
||||
height: calc(100vh - 240px);
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.actionsContainer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import {
|
||||
Dispatch,
|
||||
memo,
|
||||
MutableRefObject,
|
||||
SetStateAction,
|
||||
useEffect,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import { QueryKey } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import TraceExplorerControls from 'container/TracesExplorer/Controls';
|
||||
import { getListViewQuery } from 'container/TracesExplorer/explorerUtils';
|
||||
import { getTraceLink } from 'container/TracesExplorer/ListView/utils';
|
||||
import { TracesTableRow } from 'container/TracesExplorer/TracesTable/getFieldColumn';
|
||||
import TracesTable from 'container/TracesExplorer/TracesTable/TracesTable';
|
||||
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { Pagination } from 'hooks/queryPagination';
|
||||
import useUrlQueryData from 'hooks/useUrlQueryData';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { Warning } from 'types/api';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
import DOCLINKS from 'utils/docLinks';
|
||||
|
||||
import { columns, PER_PAGE_OPTIONS } from './configs';
|
||||
import styles from './TracesView.module.scss';
|
||||
|
||||
interface TracesViewProps {
|
||||
isFilterApplied: boolean;
|
||||
setWarning: Dispatch<SetStateAction<Warning | undefined>>;
|
||||
setIsLoadingQueries: Dispatch<SetStateAction<boolean>>;
|
||||
queryKeyRef?: MutableRefObject<QueryKey | undefined>;
|
||||
}
|
||||
|
||||
function TracesView({
|
||||
isFilterApplied,
|
||||
setWarning,
|
||||
setIsLoadingQueries,
|
||||
queryKeyRef,
|
||||
}: TracesViewProps): JSX.Element {
|
||||
const { stagedQuery, panelType } = useQueryBuilder();
|
||||
|
||||
const {
|
||||
selectedTime: globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
} = useSelector<AppState, GlobalReducer>((state) => state.globalTime);
|
||||
|
||||
const { queryData: paginationQueryData } = useUrlQueryData<Pagination>(
|
||||
QueryParams.pagination,
|
||||
);
|
||||
|
||||
const transformedQuery = useMemo(
|
||||
() => getListViewQuery(stagedQuery || initialQueriesMap.traces),
|
||||
[stagedQuery],
|
||||
);
|
||||
|
||||
const queryKey = useMemo(
|
||||
() => [
|
||||
REACT_QUERY_KEY.GET_QUERY_RANGE,
|
||||
globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
stagedQuery,
|
||||
panelType,
|
||||
paginationQueryData,
|
||||
],
|
||||
[
|
||||
globalSelectedTime,
|
||||
maxTime,
|
||||
minTime,
|
||||
stagedQuery,
|
||||
panelType,
|
||||
paginationQueryData,
|
||||
],
|
||||
);
|
||||
|
||||
if (queryKeyRef) {
|
||||
queryKeyRef.current = queryKey;
|
||||
}
|
||||
|
||||
const { data, isLoading, isFetching, isError, error } = useGetQueryRange(
|
||||
{
|
||||
query: transformedQuery,
|
||||
graphType: panelType || PANEL_TYPES.TRACE,
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
globalSelectedInterval: globalSelectedTime,
|
||||
params: {
|
||||
dataSource: 'traces',
|
||||
},
|
||||
tableParams: {
|
||||
pagination: paginationQueryData,
|
||||
},
|
||||
},
|
||||
ENTITY_VERSION_V5,
|
||||
{
|
||||
queryKey,
|
||||
enabled: !!stagedQuery && panelType === PANEL_TYPES.TRACE,
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.payload) {
|
||||
setWarning(data?.warning);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data?.payload, data?.warning]);
|
||||
|
||||
const responseData = data?.payload?.data?.newResult?.data?.result[0]?.list;
|
||||
|
||||
const rows = useMemo<TracesTableRow[]>(
|
||||
() =>
|
||||
(responseData ?? []).map((item) => {
|
||||
const row = item.data;
|
||||
return { ...row, id: row.trace_id };
|
||||
}) as TracesTableRow[],
|
||||
[responseData],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoading || isFetching) {
|
||||
setIsLoadingQueries(true);
|
||||
} else {
|
||||
setIsLoadingQueries(false);
|
||||
}
|
||||
}, [isLoading, isFetching, setIsLoadingQueries]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isFetching && !isError && rows.length !== 0) {
|
||||
void logEvent('Traces Explorer: Data present', {
|
||||
panelType: 'TRACE',
|
||||
});
|
||||
}
|
||||
}, [isLoading, isFetching, isError, rows.length]);
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.actionsContainer}>
|
||||
<Typography>
|
||||
This tab only shows Root Spans. More details
|
||||
<Typography.Link href={DOCLINKS.TRACES_DETAILS_LINK} target="_blank">
|
||||
{' '}
|
||||
here
|
||||
</Typography.Link>
|
||||
</Typography>
|
||||
|
||||
<div className="trace-explorer-controls">
|
||||
<DownloadOptionsMenu
|
||||
dataSource={DataSource.TRACES}
|
||||
panelType={PANEL_TYPES.TRACE}
|
||||
/>
|
||||
|
||||
<TraceExplorerControls
|
||||
isLoading={isLoading}
|
||||
totalCount={rows.length}
|
||||
perPageOptions={PER_PAGE_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TracesTable
|
||||
data={rows}
|
||||
columns={columns}
|
||||
respectColumnOrder
|
||||
panelType="TRACE"
|
||||
getRowHref={getTraceLink}
|
||||
isLoading={isLoading}
|
||||
isFetching={isFetching}
|
||||
isError={isError}
|
||||
error={error}
|
||||
isFilterApplied={isFilterApplied}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
TracesView.defaultProps = {
|
||||
queryKeyRef: undefined,
|
||||
};
|
||||
|
||||
export default memo(TracesView);
|
||||
@@ -0,0 +1,25 @@
|
||||
import { TelemetryFieldKey } from 'api/v5/v5';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView/types';
|
||||
import {
|
||||
getFieldColumn,
|
||||
TracesTableRow,
|
||||
} from 'container/TracesExplorer/TracesTable/getFieldColumn';
|
||||
import { DEFAULT_PER_PAGE_OPTIONS } from 'hooks/queryPagination';
|
||||
|
||||
export const PER_PAGE_OPTIONS: number[] = [10, ...DEFAULT_PER_PAGE_OPTIONS];
|
||||
|
||||
const TRACE_FIELDS = [
|
||||
{ name: 'service.name', fieldContext: 'resource' },
|
||||
{ name: 'name' },
|
||||
{ name: 'duration_nano' },
|
||||
{ name: 'span_count' },
|
||||
{ name: 'trace_id' },
|
||||
] as TelemetryFieldKey[];
|
||||
|
||||
export const columns: TableColumnDef<TracesTableRow>[] = TRACE_FIELDS.map(
|
||||
(field) => ({
|
||||
...getFieldColumn(field),
|
||||
enableRemove: false,
|
||||
canBeHidden: false,
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,36 @@
|
||||
export const TOOLBAR_VIEWS = {
|
||||
list: {
|
||||
name: 'list',
|
||||
label: 'List',
|
||||
show: true,
|
||||
key: 'list',
|
||||
},
|
||||
timeseries: {
|
||||
name: 'timeseries',
|
||||
label: 'Timeseries',
|
||||
disabled: false,
|
||||
show: true,
|
||||
key: 'timeseries',
|
||||
},
|
||||
trace: {
|
||||
name: 'trace',
|
||||
label: 'Trace',
|
||||
disabled: false,
|
||||
show: true,
|
||||
key: 'trace',
|
||||
},
|
||||
table: {
|
||||
name: 'table',
|
||||
label: 'Table',
|
||||
disabled: false,
|
||||
show: true,
|
||||
key: 'table',
|
||||
},
|
||||
clickhouse: {
|
||||
name: 'clickhouse',
|
||||
label: 'Clickhouse',
|
||||
disabled: false,
|
||||
show: false,
|
||||
key: 'clickhouse',
|
||||
},
|
||||
};
|
||||
@@ -18,6 +18,12 @@ jest.mock('pages/DashboardPageV2/DashboardContainer', () => ({
|
||||
default: (): JSX.Element => <div data-testid="llm-overview-dashboard" />,
|
||||
}));
|
||||
|
||||
// Same data-router gap as the dashboard above: the Explorer toolbar calls useNavigationType.
|
||||
jest.mock('container/LLMObservability/Explorer/Explorer', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="llm-observability-explorer" />,
|
||||
}));
|
||||
|
||||
function setupList(items = mockRules): void {
|
||||
server.use(
|
||||
rest.get(LLM_PRICING_ENDPOINT, (_req, res, ctx) =>
|
||||
|
||||
@@ -4,13 +4,6 @@
|
||||
padding: 0px 8px;
|
||||
|
||||
.logs-frequency-chart {
|
||||
.ant-card-body {
|
||||
height: 140px;
|
||||
min-height: 140px;
|
||||
padding: 0 16px 22px 16px;
|
||||
font-family: 'Geist Mono';
|
||||
}
|
||||
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,13 +3,6 @@
|
||||
min-height: 200px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
|
||||
.ant-card-body {
|
||||
height: 200px;
|
||||
min-height: 200px;
|
||||
padding: 0 16px 16px 16px;
|
||||
font-family: 'Geist Mono';
|
||||
}
|
||||
|
||||
.logs-frequency-chart-loading {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
|
||||
@@ -1,25 +1,29 @@
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { memo, useCallback, useMemo, useRef } from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import Graph from 'components/Graph';
|
||||
import Spinner from 'components/Spinner';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import BarChart from 'container/DashboardContainer/visualization/charts/BarChart/BarChart';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import getChartData, { GetChartDataProps } from 'lib/getChartData';
|
||||
import GetMinMax from 'lib/getMinMax';
|
||||
import { colors } from 'lib/getRandomColor';
|
||||
import { LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
import { StackMode } from 'lib/uPlotV2/config/types';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { UpdateTimeInterval } from 'store/actions';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
|
||||
import { LogsExplorerChartProps } from './LogsExplorerChart.interfaces';
|
||||
import { getColorsForSeverityLabels } from './utils';
|
||||
import { useLogsExplorerChartConfig } from './useLogsExplorerChartConfig';
|
||||
|
||||
import './LogsExplorerChart.styles.scss';
|
||||
|
||||
// Axis and tooltip format separately; both need this or only one abbreviates.
|
||||
const Y_AXIS_UNIT = 'short';
|
||||
|
||||
function LogsExplorerChart({
|
||||
data,
|
||||
isLoading,
|
||||
@@ -37,24 +41,6 @@ function LogsExplorerChart({
|
||||
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
|
||||
(state) => state.globalTime,
|
||||
);
|
||||
const handleCreateDatasets: Required<GetChartDataProps>['createDataset'] =
|
||||
useCallback(
|
||||
(element, index, allLabels) => ({
|
||||
data: element,
|
||||
backgroundColor: isLogsExplorerViews
|
||||
? getColorsForSeverityLabels(allLabels[index], index)
|
||||
: colors[index % colors.length] || themeColors.red,
|
||||
borderColor: isLogsExplorerViews
|
||||
? getColorsForSeverityLabels(allLabels[index], index)
|
||||
: colors[index % colors.length] || themeColors.red,
|
||||
...(isLabelEnabled
|
||||
? {
|
||||
label: allLabels[index],
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
[isLabelEnabled, isLogsExplorerViews],
|
||||
);
|
||||
|
||||
const onDragSelect = useCallback(
|
||||
(start: number, end: number): void => {
|
||||
@@ -86,44 +72,47 @@ function LogsExplorerChart({
|
||||
[dispatch, location.pathname, safeNavigate, urlQuery, isShowingLiveLogs],
|
||||
);
|
||||
|
||||
const graphData = useMemo(
|
||||
() =>
|
||||
getChartData({
|
||||
queryData: [
|
||||
{
|
||||
queryData: data,
|
||||
},
|
||||
],
|
||||
createDataset: handleCreateDatasets,
|
||||
}),
|
||||
[data, handleCreateDatasets],
|
||||
);
|
||||
|
||||
// Convert nanosecond timestamps to milliseconds for Chart.js
|
||||
const { chartMinTime, chartMaxTime } = useMemo(
|
||||
// uPlot plots the series on a seconds-based x scale
|
||||
const { minTimeScale, maxTimeScale } = useMemo(
|
||||
() => ({
|
||||
chartMinTime: minTime ? Math.floor(minTime / 1e6) : undefined,
|
||||
chartMaxTime: maxTime ? Math.floor(maxTime / 1e6) : undefined,
|
||||
minTimeScale: minTime ? Math.floor(minTime / 1e9) : undefined,
|
||||
maxTimeScale: maxTime ? Math.floor(maxTime / 1e9) : undefined,
|
||||
}),
|
||||
[minTime, maxTime],
|
||||
);
|
||||
|
||||
const { timezone } = useTimezone();
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const dimensions = useResizeObserver(graphRef);
|
||||
|
||||
const { config, chartData } = useLogsExplorerChartConfig({
|
||||
data,
|
||||
isLogsExplorerViews,
|
||||
isLabelEnabled,
|
||||
onDragSelect,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
yAxisUnit: Y_AXIS_UNIT,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={`${className} logs-frequency-chart-container`}>
|
||||
<div ref={graphRef} className={`${className} logs-frequency-chart-container`}>
|
||||
{isLoading ? (
|
||||
<div className="logs-frequency-chart-loading">
|
||||
<Spinner size="default" height="100%" />
|
||||
</div>
|
||||
) : (
|
||||
<Graph
|
||||
name="logsExplorerChart"
|
||||
data={graphData.data}
|
||||
isStacked={isLogsExplorerViews}
|
||||
type="bar"
|
||||
animate
|
||||
onDragSelect={onDragSelect}
|
||||
minTime={chartMinTime}
|
||||
maxTime={chartMaxTime}
|
||||
<BarChart
|
||||
config={config}
|
||||
data={chartData}
|
||||
width={dimensions.width}
|
||||
height={dimensions.height}
|
||||
stack={isLogsExplorerViews ? StackMode.Normal : StackMode.None}
|
||||
showLegend={isLabelEnabled}
|
||||
legendConfig={{ position: LegendPosition.BOTTOM }}
|
||||
timezone={timezone}
|
||||
data-testid="logs-frequency-chart"
|
||||
yAxisUnit={Y_AXIS_UNIT}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useMemo } from 'react';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import { buildBaseConfig } from 'container/DashboardContainer/visualization/panels/utils/baseConfigBuilder';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import getLabelName from 'lib/getLabelName';
|
||||
import { colors } from 'lib/getRandomColor';
|
||||
import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
|
||||
import { DrawStyle } from 'lib/uPlotV2/config/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import { useTimezone } from 'providers/Timezone';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import { QueryData } from 'types/api/widgets/getQuery';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
import { getColorsForSeverityLabels } from './utils';
|
||||
|
||||
export interface UseLogsExplorerChartConfigParams {
|
||||
data: QueryData[];
|
||||
isLogsExplorerViews?: boolean;
|
||||
isLabelEnabled?: boolean;
|
||||
onDragSelect: (start: number, end: number) => void;
|
||||
minTimeScale?: number;
|
||||
maxTimeScale?: number;
|
||||
yAxisUnit?: string;
|
||||
}
|
||||
|
||||
export interface UseLogsExplorerChartConfigResult {
|
||||
config: UPlotConfigBuilder;
|
||||
chartData: uPlot.AlignedData;
|
||||
}
|
||||
|
||||
export function useLogsExplorerChartConfig({
|
||||
data,
|
||||
isLogsExplorerViews = false,
|
||||
isLabelEnabled = true,
|
||||
onDragSelect,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
yAxisUnit,
|
||||
}: UseLogsExplorerChartConfigParams): UseLogsExplorerChartConfigResult {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const { timezone } = useTimezone();
|
||||
|
||||
// getUPlotChartData / buildBaseConfig both consume the legacy query-range payload
|
||||
// shape, so the raw series list is wrapped instead of being plotted directly.
|
||||
const apiResponse = useMemo(
|
||||
() =>
|
||||
({
|
||||
data: { result: data, resultType: '' },
|
||||
}) as unknown as MetricRangePayloadProps,
|
||||
[data],
|
||||
);
|
||||
|
||||
const chartData = useMemo(() => getUPlotChartData(apiResponse), [apiResponse]);
|
||||
|
||||
const config = useMemo(() => {
|
||||
const builder = buildBaseConfig({
|
||||
id: 'logs-explorer-frequency-chart',
|
||||
isDarkMode,
|
||||
onDragSelect,
|
||||
timezone,
|
||||
minTimeScale,
|
||||
maxTimeScale,
|
||||
yAxisUnit,
|
||||
panelType: PANEL_TYPES.BAR,
|
||||
});
|
||||
|
||||
data.forEach((series, index) => {
|
||||
const label = getLabelName(
|
||||
series.metric,
|
||||
series.queryName || '',
|
||||
series.legend || '',
|
||||
);
|
||||
|
||||
const color = isLogsExplorerViews
|
||||
? getColorsForSeverityLabels(label, index)
|
||||
: colors[index % colors.length] || themeColors.red;
|
||||
|
||||
builder.addSeries({
|
||||
scaleKey: 'y',
|
||||
drawStyle: DrawStyle.Bar,
|
||||
// No group-by yields query name "A"; use ' ' not '' so uPlot does not default the label to "Value".
|
||||
label: isLabelEnabled && label.trim() ? label : ' ',
|
||||
lineColor: color,
|
||||
colorMapping: {},
|
||||
isDarkMode,
|
||||
});
|
||||
});
|
||||
|
||||
return builder;
|
||||
}, [
|
||||
data,
|
||||
isDarkMode,
|
||||
isLabelEnabled,
|
||||
isLogsExplorerViews,
|
||||
maxTimeScale,
|
||||
minTimeScale,
|
||||
onDragSelect,
|
||||
timezone,
|
||||
yAxisUnit,
|
||||
]);
|
||||
|
||||
return { config, chartData };
|
||||
}
|
||||
@@ -217,13 +217,6 @@
|
||||
padding: 0px 8px;
|
||||
|
||||
.logs-frequency-chart {
|
||||
.ant-card-body {
|
||||
height: 140px;
|
||||
min-height: 140px;
|
||||
padding: 0 16px 22px 16px;
|
||||
font-family: 'Geist Mono';
|
||||
}
|
||||
|
||||
margin-bottom: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +94,8 @@ function AlertDetails(): JSX.Element {
|
||||
>
|
||||
<div
|
||||
className={classNames('alert-details', { 'alert-details-v2': isV2Alert })}
|
||||
data-testid="alert-details-root"
|
||||
data-schema-version={isV2Alert ? NEW_ALERT_SCHEMA_VERSION : 'v1'}
|
||||
>
|
||||
<AlertBreadcrumb
|
||||
className="alert-details__breadcrumb"
|
||||
|
||||
@@ -117,7 +117,11 @@ function AlertActionButtons({
|
||||
<div className="alert-action-buttons">
|
||||
<Tooltip title={isAlertRuleDisabled ? 'Enable alert' : 'Disable alert'}>
|
||||
{isAlertRuleDisabled !== undefined && (
|
||||
<Switch onChange={toggleAlertRule} value={!isAlertRuleDisabled} />
|
||||
<Switch
|
||||
onChange={toggleAlertRule}
|
||||
value={!isAlertRuleDisabled}
|
||||
testId="alert-actions-toggle"
|
||||
/>
|
||||
)}
|
||||
</Tooltip>
|
||||
<CopyToClipboard textToCopy={window.location.href} />
|
||||
@@ -129,6 +133,7 @@ function AlertActionButtons({
|
||||
<Tooltip title="More options">
|
||||
<Button
|
||||
type="text"
|
||||
data-testid="alert-actions-menu"
|
||||
icon={
|
||||
<Ellipsis
|
||||
size={16}
|
||||
|
||||
@@ -47,21 +47,26 @@ function AlertHeader({ alertDetails }: AlertHeaderProps): JSX.Element {
|
||||
<div className="alert-info__info-wrapper">
|
||||
<div className="top-section">
|
||||
<div className="alert-title-wrapper">
|
||||
<AlertState state={alertRuleState ?? state ?? ''} />
|
||||
<div className="alert-title">
|
||||
<AlertState
|
||||
state={alertRuleState ?? state ?? ''}
|
||||
testId="alert-header-state"
|
||||
/>
|
||||
<div className="alert-title" data-testid="alert-header-title">
|
||||
<LineClampedText text={displayName || ''} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bottom-section">
|
||||
{labels?.severity && <AlertSeverity severity={labels.severity} />}
|
||||
{labels?.severity && (
|
||||
<AlertSeverity severity={labels.severity} testId="alert-header-severity" />
|
||||
)}
|
||||
|
||||
{/* // TODO(shaheer): Get actual data when we are able to get alert firing from state from API */}
|
||||
{/* <AlertStatus
|
||||
status="firing"
|
||||
timestamp={dayjs().subtract(1, 'd').valueOf()}
|
||||
/> */}
|
||||
<AlertLabels labels={labelsWithoutSeverity} />
|
||||
<AlertLabels labels={labelsWithoutSeverity} testId="alert-header-labels" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,14 +6,16 @@ import './AlertLabels.styles.scss';
|
||||
export type AlertLabelsProps = {
|
||||
labels: Record<string, any>;
|
||||
initialCount?: number;
|
||||
testId?: string;
|
||||
};
|
||||
|
||||
function AlertLabels({
|
||||
labels,
|
||||
initialCount = 2,
|
||||
testId,
|
||||
}: AlertLabelsProps): JSX.Element {
|
||||
return (
|
||||
<div className="alert-labels">
|
||||
<div className="alert-labels" data-testid={testId}>
|
||||
<SeeMore initialCount={initialCount} moreLabel="More">
|
||||
{Object.entries(labels).map(([key, value]) => (
|
||||
<KeyValueLabel key={`label-${key}`} badgeKey={key} badgeValue={value} />
|
||||
@@ -25,6 +27,7 @@ function AlertLabels({
|
||||
|
||||
AlertLabels.defaultProps = {
|
||||
initialCount: 2,
|
||||
testId: undefined,
|
||||
};
|
||||
|
||||
export default AlertLabels;
|
||||
|
||||
@@ -32,8 +32,10 @@ const severityConfig: Record<string, Record<string, string | JSX.Element>> = {
|
||||
|
||||
export default function AlertSeverity({
|
||||
severity,
|
||||
testId,
|
||||
}: {
|
||||
severity: string;
|
||||
testId?: string;
|
||||
}): JSX.Element {
|
||||
const severityDetails = useMemo(() => {
|
||||
if (severityConfig[severity]) {
|
||||
@@ -52,9 +54,16 @@ export default function AlertSeverity({
|
||||
};
|
||||
}, [severity]);
|
||||
return (
|
||||
<div className={`alert-severity ${severityDetails.className}`}>
|
||||
<div
|
||||
className={`alert-severity ${severityDetails.className}`}
|
||||
data-testid={testId}
|
||||
>
|
||||
<div className="alert-severity__icon">{severityDetails.icon}</div>
|
||||
<div className="alert-severity__text">{severityDetails.text}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
AlertSeverity.defaultProps = {
|
||||
testId: undefined,
|
||||
};
|
||||
|
||||
@@ -8,11 +8,13 @@ import './AlertState.styles.scss';
|
||||
type AlertStateProps = {
|
||||
state: RuletypesAlertStateDTO | string;
|
||||
showLabel?: boolean;
|
||||
testId?: string;
|
||||
};
|
||||
|
||||
export default function AlertState({
|
||||
state,
|
||||
showLabel,
|
||||
testId,
|
||||
}: AlertStateProps): JSX.Element {
|
||||
let icon;
|
||||
let label;
|
||||
@@ -64,7 +66,7 @@ export default function AlertState({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="alert-state">
|
||||
<div className="alert-state" data-testid={testId}>
|
||||
{icon} {showLabel && <div className="alert-state__label">{label}</div>}
|
||||
</div>
|
||||
);
|
||||
@@ -72,4 +74,5 @@ export default function AlertState({
|
||||
|
||||
AlertState.defaultProps = {
|
||||
showLabel: false,
|
||||
testId: undefined,
|
||||
};
|
||||
|
||||
@@ -127,7 +127,7 @@ export const useRouteTabUtils = (): { routes: TabRoutes[] } => {
|
||||
{
|
||||
Component: EditRules,
|
||||
name: (
|
||||
<div className="tab-item">
|
||||
<div className="tab-item" data-testid="alert-details-tab-overview">
|
||||
<Table size={14} />
|
||||
Overview
|
||||
</div>
|
||||
@@ -138,7 +138,7 @@ export const useRouteTabUtils = (): { routes: TabRoutes[] } => {
|
||||
{
|
||||
Component: AlertHistory,
|
||||
name: (
|
||||
<div className="tab-item">
|
||||
<div className="tab-item" data-testid="alert-details-tab-history">
|
||||
<History size={14} />
|
||||
History
|
||||
<BetaTag />
|
||||
|
||||
@@ -13,6 +13,8 @@ interface Tab {
|
||||
disabled?: boolean;
|
||||
icon?: string | JSX.Element;
|
||||
isBeta?: boolean;
|
||||
/** Optional `data-testid` for the tab button. */
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
interface TimelineTabsProps {
|
||||
@@ -63,6 +65,7 @@ function Tabs2({
|
||||
disabled={tab.disabled}
|
||||
icon={tab.icon}
|
||||
style={{ minWidth: buttonMinWidth }}
|
||||
data-testid={tab.testId}
|
||||
>
|
||||
{tab.label}
|
||||
|
||||
|
||||
@@ -4,13 +4,12 @@ import (
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/featuretypes"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
func (provider *provider) addFlaggerRoutes(router *mux.Router) error {
|
||||
if err := router.Handle("/api/v2/features", handler.New(provider.authzMiddleware.ViewAccess(provider.flaggerHandler.GetFeatures), handler.OpenAPIDef{
|
||||
if err := router.Handle("/api/v2/features", handler.New(provider.authzMiddleware.OpenAccess(provider.flaggerHandler.GetFeatures), handler.OpenAPIDef{
|
||||
ID: "GetFeatures",
|
||||
Tags: []string{"features"},
|
||||
Summary: "Get features",
|
||||
@@ -22,7 +21,7 @@ func (provider *provider) addFlaggerRoutes(router *mux.Router) error {
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
|
||||
SecuritySchemes: newScopedSecuritySchemes(nil),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -82,6 +82,12 @@ func (q *builderQuery[T]) Fingerprint() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// AI trace aggregations qualify and rank traces on whole-window per-trace
|
||||
// values, which do not decompose into cacheable time buckets.
|
||||
if q.queryType == qbtypes.QueryTypeBuilderAI {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Create a deterministic fingerprint for builder queries
|
||||
// This needs to include all fields that affect the query results
|
||||
parts := []string{q.queryType.StringValue()}
|
||||
|
||||
@@ -117,8 +117,7 @@ func TestBuilderQueryFingerprintQueryType(t *testing.T) {
|
||||
}
|
||||
|
||||
assert.True(t, strings.HasPrefix(regular.Fingerprint(), qbtypes.QueryTypeBuilder.StringValue()+"&"))
|
||||
assert.True(t, strings.HasPrefix(ai.Fingerprint(), qbtypes.QueryTypeBuilderAI.StringValue()+"&"))
|
||||
assert.NotEqual(t, regular.Fingerprint(), ai.Fingerprint())
|
||||
assert.Empty(t, ai.Fingerprint())
|
||||
}
|
||||
|
||||
func TestMakeBucketsOrder(t *testing.T) {
|
||||
|
||||
@@ -470,7 +470,7 @@ func (q *querier) resolveMetricMetadata(ctx context.Context, orgID valuer.UUID,
|
||||
continue
|
||||
}
|
||||
// Type is resolved now; validate aggregation compatibility against it.
|
||||
if err := spec.Aggregations[i].ValidateForType(); err != nil {
|
||||
if err := spec.Aggregations[i].ValidateForTypeAndTemporality(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if reducedMetricsSet[spec.Aggregations[i].MetricName] {
|
||||
|
||||
@@ -439,7 +439,7 @@ func (aH *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
|
||||
router.HandleFunc("/api/v2/traces/fields", am.EditAccess(aH.updateTraceField)).Methods(http.MethodPost)
|
||||
|
||||
router.HandleFunc("/api/v1/version", am.OpenAccess(aH.getVersion)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/v1/features", am.ViewAccess(aH.getFeatureFlags)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/v1/features", am.OpenAccess(aH.getFeatureFlags)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/v1/health", am.OpenAccess(aH.getHealth)).Methods(http.MethodGet)
|
||||
|
||||
router.HandleFunc("/api/v1/listErrors", am.ViewAccess(aH.listErrors)).Methods(http.MethodPost)
|
||||
@@ -1497,7 +1497,7 @@ func (aH *APIHandler) getFeatureFlags(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
aH.HandleError(w, err, http.StatusInternalServerError)
|
||||
aH.HandleError(w, err, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -107,8 +107,8 @@ func SynthesizeKeys(field *telemetrytypes.TelemetryFieldKey, value any) []*telem
|
||||
fieldContext = telemetrytypes.FieldContextAttribute
|
||||
}
|
||||
fieldDataType := field.FieldDataType
|
||||
// Resource values are strings; pin the type so operand coercion applies.
|
||||
if fieldContext == telemetrytypes.FieldContextResource &&
|
||||
// Resource and scope values are strings; pin the type so operand coercion applies.
|
||||
if (fieldContext == telemetrytypes.FieldContextResource || fieldContext == telemetrytypes.FieldContextScope) &&
|
||||
fieldDataType == telemetrytypes.FieldDataTypeUnspecified {
|
||||
fieldDataType = telemetrytypes.FieldDataTypeString
|
||||
}
|
||||
|
||||
@@ -56,6 +56,17 @@ func QueryStringToKeysSelectors(query string) []*telemetrytypes.FieldKeySelector
|
||||
FieldDataType: key.FieldDataType,
|
||||
})
|
||||
}
|
||||
|
||||
// todo(tushar): consider reverting changes done to this method in below PR to avoid scope specific checks
|
||||
// https://github.com/SigNoz/signoz/issues/11374
|
||||
if key.FieldContext == telemetrytypes.FieldContextScope {
|
||||
keys = append(keys, &telemetrytypes.FieldKeySelector{
|
||||
Name: key.FieldContext.StringValue() + "." + key.Name,
|
||||
Signal: key.Signal,
|
||||
FieldContext: telemetrytypes.FieldContextUnspecified, // this allows 'scope.' prefix for keys with other context as well
|
||||
FieldDataType: key.FieldDataType,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -72,6 +72,44 @@ func TestQueryToKeys(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
query: `scope.version = '1.0.0'`,
|
||||
expectedKeys: []telemetrytypes.FieldKeySelector{
|
||||
{
|
||||
Name: "version",
|
||||
Signal: telemetrytypes.SignalUnspecified,
|
||||
FieldContext: telemetrytypes.FieldContextScope,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeUnspecified,
|
||||
},
|
||||
{
|
||||
Name: "scope.version",
|
||||
Signal: telemetrytypes.SignalUnspecified,
|
||||
FieldContext: telemetrytypes.FieldContextUnspecified,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeUnspecified,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
// A scope attribute whose own name carries a `scope.` prefix. `scope.prefixed`
|
||||
// normalizes to {prefixed, scope}; the second selector re-adds the prefix so the
|
||||
// metadata fetch can target the attribute's exact key `scope.prefixed` rather than
|
||||
// relying on the broad `%prefixed%` match.
|
||||
query: `scope.prefixed = 'x'`,
|
||||
expectedKeys: []telemetrytypes.FieldKeySelector{
|
||||
{
|
||||
Name: "prefixed",
|
||||
Signal: telemetrytypes.SignalUnspecified,
|
||||
FieldContext: telemetrytypes.FieldContextScope,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeUnspecified,
|
||||
},
|
||||
{
|
||||
Name: "scope.prefixed",
|
||||
Signal: telemetrytypes.SignalUnspecified,
|
||||
FieldContext: telemetrytypes.FieldContextUnspecified,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeUnspecified,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
|
||||
@@ -994,8 +994,8 @@ func TestBuild_TraceList_MultiVariantGateKey(t *testing.T) {
|
||||
assert.Contains(t, got, "mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_number, 'gen_ai.tool.name')")
|
||||
}
|
||||
|
||||
// `trace.` marks a trace-level aggregate; `tracefield.` routes trace-level too but is
|
||||
// not a rewritable alias, so the HAVING rewriter rejects it.
|
||||
// A `trace.`-prefixed aggregate in the filter box and the same condition in the
|
||||
// explicit Having box build the same query; output-only aggregates are rejected.
|
||||
func TestBuild_TraceList_TraceContextPrefix(t *testing.T) {
|
||||
b := newTestBuilder(t)
|
||||
build := func(q qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]) (*qbtypes.Statement, error) {
|
||||
@@ -1003,19 +1003,14 @@ func TestBuild_TraceList_TraceContextPrefix(t *testing.T) {
|
||||
return b.Build(context.Background(), valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeTrace, q, nil)
|
||||
}
|
||||
|
||||
_, err := build(qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
viaTrace, err := build(qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Filter: &qbtypes.Filter{Expression: "trace.output_tokens > 1000"}})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = build(qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Filter: &qbtypes.Filter{Expression: "tracefield.output_tokens > 1000"}})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "Invalid references in `Having` expression: [tracefield.output_tokens]")
|
||||
|
||||
_, err = build(qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Having: &qbtypes.Having{Expression: "tracefield.output_tokens > 1000"}})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "Invalid references in `Having` expression: [tracefield.output_tokens]")
|
||||
viaHaving, err := build(qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Having: &qbtypes.Having{Expression: "trace.output_tokens > 1000"}})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, viaTrace.Query, viaHaving.Query)
|
||||
|
||||
_, err = build(qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Filter: &qbtypes.Filter{Expression: "trace.span_count > 3"}})
|
||||
@@ -1023,7 +1018,8 @@ func TestBuild_TraceList_TraceContextPrefix(t *testing.T) {
|
||||
assert.Contains(t, err.Error(), "cannot be used")
|
||||
}
|
||||
|
||||
// Query variables in a trace-level condition are substituted into the HAVING.
|
||||
// Query variables in a trace-level condition resolve like span filters: bound args,
|
||||
// list/IN handling, dynamic __all__ dropping the condition.
|
||||
func TestBuild_TraceList_VariableInAggregateFilter(t *testing.T) {
|
||||
b := newTestBuilder(t)
|
||||
build := func(expr string, vars map[string]qbtypes.VariableItem) (*qbtypes.Statement, error) {
|
||||
@@ -1035,17 +1031,18 @@ func TestBuild_TraceList_VariableInAggregateFilter(t *testing.T) {
|
||||
}, vars)
|
||||
}
|
||||
|
||||
// scalar variable -> literal in HAVING
|
||||
// scalar variable -> bound arg via the filter pipeline
|
||||
stmt, err := build("trace.output_tokens > $threshold",
|
||||
map[string]qbtypes.VariableItem{"threshold": {Value: 700}})
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, stmt.Query, "HAVING output_tokens > 700")
|
||||
assert.Contains(t, stmt.Query, "HAVING output_tokens > ?")
|
||||
assert.Contains(t, stmt.Args, float64(700))
|
||||
|
||||
// list variable with IN
|
||||
stmt, err = build("trace.llm_call_count IN $counts",
|
||||
map[string]qbtypes.VariableItem{"counts": {Value: []any{1, 2}}})
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, stmt.Query, "HAVING llm_call_count IN")
|
||||
assert.Contains(t, stmt.Query, "HAVING llm_call_count IN (?, ?)")
|
||||
|
||||
// dynamic __all__ -> condition dropped, no HAVING at all
|
||||
stmt, err = build("trace.output_tokens > $threshold",
|
||||
@@ -1053,7 +1050,7 @@ func TestBuild_TraceList_VariableInAggregateFilter(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.NotContains(t, stmt.Query, "HAVING")
|
||||
|
||||
// unresolved variable -> rejected, not compared as a literal
|
||||
// unresolved variable -> rejected, though only as an unknown aggregate today
|
||||
_, err = build("trace.output_tokens > $missing", map[string]qbtypes.VariableItem{"other": {Value: 1}})
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,753 @@
|
||||
package aistatementbuilder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// The builder assumes at least one aggregation; request validation is what enforces it.
|
||||
func TestBuild_Aggregation_NoAggregations_RejectedByRequestValidation(t *testing.T) {
|
||||
for _, rt := range []qbtypes.RequestType{qbtypes.RequestTypeScalar, qbtypes.RequestTypeTimeSeries} {
|
||||
req := qbtypes.QueryRangeRequest{
|
||||
Start: testStartMs,
|
||||
End: testEndMs,
|
||||
RequestType: rt,
|
||||
CompositeQuery: qbtypes.CompositeQuery{
|
||||
Queries: []qbtypes.QueryEnvelope{{
|
||||
Type: qbtypes.QueryTypeBuilderAI,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Name: "A",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
StepInterval: qbtypes.Step{Duration: 60 * time.Second},
|
||||
},
|
||||
}},
|
||||
},
|
||||
}
|
||||
require.ErrorContains(t, req.Validate(), "at least one aggregation is required", rt.StringValue())
|
||||
}
|
||||
}
|
||||
|
||||
// Traces without token spans yield NULL, which the outer avg skips.
|
||||
func TestBuild_FullSQL_Scalar_TraceAgg(t *testing.T) {
|
||||
b := newTestBuilder(t)
|
||||
stmt, err := b.Build(context.Background(), valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeScalar,
|
||||
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Aggregations: []qbtypes.TraceAggregation{{Expression: "avg(trace.output_tokens)"}},
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
assertSQLEqual(t, `
|
||||
WITH __scoped_traces AS (
|
||||
SELECT trace_id,
|
||||
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens
|
||||
FROM signoz_traces.distributed_signoz_index_v3
|
||||
WHERE timestamp >= '1747947419000000000'
|
||||
AND timestamp < '1747983448000000000'
|
||||
AND ts_bucket_start >= 1747945619
|
||||
AND ts_bucket_start <= 1747983448
|
||||
AND (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))
|
||||
GROUP BY trace_id
|
||||
)
|
||||
SELECT avg(output_tokens) AS __result_0
|
||||
FROM __scoped_traces
|
||||
ORDER BY __result_0 DESC
|
||||
SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000
|
||||
`, stmt)
|
||||
}
|
||||
|
||||
// A span-level filter is ANDed into the per-trace scan's WHERE, next to the gate mask.
|
||||
func TestBuild_FullSQL_Scalar_SpanFilter(t *testing.T) {
|
||||
b := newTestBuilder(t)
|
||||
stmt, err := b.Build(context.Background(), valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeScalar,
|
||||
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Aggregations: []qbtypes.TraceAggregation{{Expression: "avg(trace.output_tokens)"}},
|
||||
Filter: &qbtypes.Filter{Expression: "gen_ai.request.model = 'gpt-4o-mini'"},
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
assertSQLEqual(t, `
|
||||
WITH __scoped_traces AS (
|
||||
SELECT trace_id,
|
||||
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens
|
||||
FROM signoz_traces.distributed_signoz_index_v3
|
||||
WHERE timestamp >= '1747947419000000000'
|
||||
AND timestamp < '1747983448000000000'
|
||||
AND ts_bucket_start >= 1747945619
|
||||
AND ts_bucket_start <= 1747983448
|
||||
AND (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))
|
||||
AND (attributes_string['gen_ai.request.model'] = 'gpt-4o-mini' AND mapContains(attributes_string, 'gen_ai.request.model'))
|
||||
GROUP BY trace_id
|
||||
)
|
||||
SELECT avg(output_tokens) AS __result_0
|
||||
FROM __scoped_traces
|
||||
ORDER BY __result_0 DESC
|
||||
SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000
|
||||
`, stmt)
|
||||
}
|
||||
|
||||
// A trace-level filter qualifies first: __qualified holds the trace ids whose
|
||||
// whole-window value passes, and the per-trace scan is constrained to them.
|
||||
func TestBuild_FullSQL_Scalar_TraceFilter(t *testing.T) {
|
||||
b := newTestBuilder(t)
|
||||
stmt, err := b.Build(context.Background(), valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeScalar,
|
||||
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Aggregations: []qbtypes.TraceAggregation{{Expression: "avg(trace.output_tokens)"}},
|
||||
Filter: &qbtypes.Filter{Expression: "trace.output_tokens > 1000"},
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
assertSQLEqual(t, `
|
||||
WITH __qualified AS (
|
||||
SELECT trace_id,
|
||||
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens
|
||||
FROM signoz_traces.distributed_signoz_index_v3
|
||||
WHERE timestamp >= '1747947419000000000'
|
||||
AND timestamp < '1747983448000000000'
|
||||
AND ts_bucket_start >= 1747945619
|
||||
AND ts_bucket_start <= 1747983448
|
||||
AND (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))
|
||||
GROUP BY trace_id
|
||||
HAVING output_tokens > 1000
|
||||
),
|
||||
__scoped_traces AS (
|
||||
SELECT trace_id,
|
||||
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens
|
||||
FROM signoz_traces.distributed_signoz_index_v3
|
||||
WHERE timestamp >= '1747947419000000000'
|
||||
AND timestamp < '1747983448000000000'
|
||||
AND ts_bucket_start >= 1747945619
|
||||
AND ts_bucket_start <= 1747983448
|
||||
AND (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))
|
||||
AND trace_id GLOBAL IN (SELECT trace_id FROM __qualified)
|
||||
GROUP BY trace_id
|
||||
)
|
||||
SELECT avg(output_tokens) AS __result_0
|
||||
FROM __scoped_traces
|
||||
ORDER BY __result_0 DESC
|
||||
SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000
|
||||
`, stmt)
|
||||
}
|
||||
|
||||
// Grouping by an intrinsic: the positional alias keeps `toString(name) AS name` (a cyclic
|
||||
// alias) from forming, and an order key on the dimension resolves to that alias.
|
||||
func TestBuild_FullSQL_Scalar_GroupByIntrinsic(t *testing.T) {
|
||||
b := newTestBuilder(t)
|
||||
stmt, err := b.Build(context.Background(), valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeScalar,
|
||||
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Aggregations: []qbtypes.TraceAggregation{{Expression: "avg(trace.output_tokens)"}},
|
||||
GroupBy: []qbtypes.GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "name"}}},
|
||||
Order: []qbtypes.OrderBy{{Key: qbtypes.OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "name"}}, Direction: qbtypes.OrderDirectionAsc}},
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
assertSQLEqual(t, `
|
||||
WITH __scoped_traces AS (
|
||||
SELECT trace_id,
|
||||
toString(multiIf(name <> '', toString(name), NULL)) AS __GROUP_BY_KEY_0_name,
|
||||
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens
|
||||
FROM signoz_traces.distributed_signoz_index_v3
|
||||
WHERE timestamp >= '1747947419000000000'
|
||||
AND timestamp < '1747983448000000000'
|
||||
AND ts_bucket_start >= 1747945619
|
||||
AND ts_bucket_start <= 1747983448
|
||||
AND (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))
|
||||
GROUP BY trace_id, __GROUP_BY_KEY_0_name
|
||||
)
|
||||
SELECT __GROUP_BY_KEY_0_name, avg(output_tokens) AS __result_0
|
||||
FROM __scoped_traces
|
||||
GROUP BY __GROUP_BY_KEY_0_name
|
||||
ORDER BY __GROUP_BY_KEY_0_name asc
|
||||
SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000
|
||||
`, stmt)
|
||||
}
|
||||
|
||||
// Every dimension at once; the HAVING on the alias is rewritten to __result_0.
|
||||
func TestBuild_FullSQL_Scalar_FullCombo(t *testing.T) {
|
||||
b := newTestBuilder(t)
|
||||
stmt, err := b.Build(context.Background(), valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeScalar,
|
||||
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Aggregations: []qbtypes.TraceAggregation{
|
||||
{Expression: "avg(trace.output_tokens)", Alias: "avg_out"},
|
||||
{Expression: "count(trace.trace_id)"},
|
||||
},
|
||||
Filter: &qbtypes.Filter{Expression: "gen_ai.request.model = 'gpt-4o-mini' AND trace.total_tokens > 100"},
|
||||
GroupBy: []qbtypes.GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "gen_ai.request.model"}}},
|
||||
Having: &qbtypes.Having{Expression: "avg_out > 50"},
|
||||
Order: []qbtypes.OrderBy{{Key: qbtypes.OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "avg_out"}}, Direction: qbtypes.OrderDirectionDesc}},
|
||||
Limit: 5,
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
assertSQLEqual(t, `
|
||||
WITH __qualified AS (
|
||||
SELECT trace_id,
|
||||
coalesce(sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.input_tokens'), toFloat64(attributes_number['gen_ai.usage.input_tokens']), NULL)), 0) + coalesce(sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)), 0) AS total_tokens
|
||||
FROM signoz_traces.distributed_signoz_index_v3
|
||||
WHERE timestamp >= '1747947419000000000'
|
||||
AND timestamp < '1747983448000000000'
|
||||
AND ts_bucket_start >= 1747945619
|
||||
AND ts_bucket_start <= 1747983448
|
||||
AND (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))
|
||||
GROUP BY trace_id
|
||||
HAVING total_tokens > 100
|
||||
),
|
||||
__scoped_traces AS (
|
||||
SELECT trace_id,
|
||||
toString(multiIf(mapContains(attributes_string, 'gen_ai.request.model'), attributes_string['gen_ai.request.model'], NULL)) AS __GROUP_BY_KEY_0_gen_ai.request.model,
|
||||
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens
|
||||
FROM signoz_traces.distributed_signoz_index_v3
|
||||
WHERE timestamp >= '1747947419000000000'
|
||||
AND timestamp < '1747983448000000000'
|
||||
AND ts_bucket_start >= 1747945619
|
||||
AND ts_bucket_start <= 1747983448
|
||||
AND (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))
|
||||
AND (attributes_string['gen_ai.request.model'] = 'gpt-4o-mini' AND mapContains(attributes_string, 'gen_ai.request.model'))
|
||||
AND trace_id GLOBAL IN (SELECT trace_id FROM __qualified)
|
||||
GROUP BY trace_id, __GROUP_BY_KEY_0_gen_ai.request.model
|
||||
)
|
||||
SELECT __GROUP_BY_KEY_0_gen_ai.request.model, avg(output_tokens) AS __result_0, count(trace_id) AS __result_1
|
||||
FROM __scoped_traces
|
||||
GROUP BY __GROUP_BY_KEY_0_gen_ai.request.model
|
||||
HAVING __result_0 > 50
|
||||
ORDER BY __result_0 desc
|
||||
LIMIT 5
|
||||
SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000
|
||||
`, stmt)
|
||||
}
|
||||
|
||||
// Time series: the per-trace scan buckets by span time, the outer aggregation per bucket.
|
||||
func TestBuild_FullSQL_TimeSeries_TraceAgg(t *testing.T) {
|
||||
b := newTestBuilder(t)
|
||||
stmt, err := b.Build(context.Background(), valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeTimeSeries,
|
||||
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
StepInterval: qbtypes.Step{Duration: 60 * time.Second},
|
||||
Aggregations: []qbtypes.TraceAggregation{{Expression: "avg(trace.output_tokens)"}},
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
assertSQLEqual(t, `
|
||||
WITH __scoped_traces AS (
|
||||
SELECT trace_id,
|
||||
toStartOfInterval(timestamp, INTERVAL 60 SECOND) AS ts,
|
||||
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens
|
||||
FROM signoz_traces.distributed_signoz_index_v3
|
||||
WHERE timestamp >= '1747947419000000000'
|
||||
AND timestamp < '1747983448000000000'
|
||||
AND ts_bucket_start >= 1747945619
|
||||
AND ts_bucket_start <= 1747983448
|
||||
AND (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))
|
||||
GROUP BY trace_id, ts
|
||||
)
|
||||
SELECT ts, avg(output_tokens) AS __result_0
|
||||
FROM __scoped_traces
|
||||
GROUP BY ts
|
||||
SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000
|
||||
`, stmt)
|
||||
}
|
||||
|
||||
// A grouped, limited time series ranks groups on unbucketed whole-window values
|
||||
// (__scoped_traces_total), so a non-composable aggregate like avg ranks exactly.
|
||||
func TestBuild_FullSQL_TimeSeries_GroupLimit(t *testing.T) {
|
||||
b := newTestBuilder(t)
|
||||
stmt, err := b.Build(context.Background(), valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeTimeSeries,
|
||||
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
StepInterval: qbtypes.Step{Duration: 60 * time.Second},
|
||||
Aggregations: []qbtypes.TraceAggregation{{Expression: "sum(trace.output_tokens)", Alias: "total_out"}},
|
||||
GroupBy: []qbtypes.GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "gen_ai.request.model"}}},
|
||||
Having: &qbtypes.Having{Expression: "total_out > 500"},
|
||||
Order: []qbtypes.OrderBy{{Key: qbtypes.OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "total_out"}}, Direction: qbtypes.OrderDirectionDesc}},
|
||||
Limit: 3,
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
assertSQLEqual(t, `
|
||||
WITH __scoped_traces_total AS (
|
||||
SELECT trace_id,
|
||||
toString(multiIf(mapContains(attributes_string, 'gen_ai.request.model'), attributes_string['gen_ai.request.model'], NULL)) AS __GROUP_BY_KEY_0_gen_ai.request.model,
|
||||
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens
|
||||
FROM signoz_traces.distributed_signoz_index_v3
|
||||
WHERE timestamp >= '1747947419000000000'
|
||||
AND timestamp < '1747983448000000000'
|
||||
AND ts_bucket_start >= 1747945619
|
||||
AND ts_bucket_start <= 1747983448
|
||||
AND (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))
|
||||
GROUP BY trace_id, __GROUP_BY_KEY_0_gen_ai.request.model
|
||||
),
|
||||
__limit_cte AS (
|
||||
SELECT __GROUP_BY_KEY_0_gen_ai.request.model, sum(output_tokens) AS __result_0
|
||||
FROM __scoped_traces_total
|
||||
GROUP BY __GROUP_BY_KEY_0_gen_ai.request.model
|
||||
ORDER BY __result_0 desc
|
||||
LIMIT 3
|
||||
),
|
||||
__scoped_traces AS (
|
||||
SELECT trace_id,
|
||||
toStartOfInterval(timestamp, INTERVAL 60 SECOND) AS ts,
|
||||
toString(multiIf(mapContains(attributes_string, 'gen_ai.request.model'), attributes_string['gen_ai.request.model'], NULL)) AS __GROUP_BY_KEY_0_gen_ai.request.model,
|
||||
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens
|
||||
FROM signoz_traces.distributed_signoz_index_v3
|
||||
WHERE timestamp >= '1747947419000000000'
|
||||
AND timestamp < '1747983448000000000'
|
||||
AND ts_bucket_start >= 1747945619
|
||||
AND ts_bucket_start <= 1747983448
|
||||
AND (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))
|
||||
AND (toString(multiIf(mapContains(attributes_string, 'gen_ai.request.model'), attributes_string['gen_ai.request.model'], NULL))) GLOBAL IN (SELECT __GROUP_BY_KEY_0_gen_ai.request.model FROM __limit_cte)
|
||||
GROUP BY trace_id, ts, __GROUP_BY_KEY_0_gen_ai.request.model
|
||||
)
|
||||
SELECT ts, __GROUP_BY_KEY_0_gen_ai.request.model, sum(output_tokens) AS __result_0
|
||||
FROM __scoped_traces
|
||||
GROUP BY ts, __GROUP_BY_KEY_0_gen_ai.request.model
|
||||
HAVING __result_0 > 500
|
||||
ORDER BY ts desc
|
||||
SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000
|
||||
`, stmt)
|
||||
}
|
||||
|
||||
// A span-level scalar delegates to the trace builder, constrained by __trace_scope;
|
||||
// the shape is the delegate's own, hence no SETTINGS suffix.
|
||||
func TestBuild_FullSQL_Scalar_SpanAgg_TraceScoped(t *testing.T) {
|
||||
b := newTestBuilder(t)
|
||||
stmt, err := b.Build(context.Background(), valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeScalar,
|
||||
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Aggregations: []qbtypes.TraceAggregation{{Expression: "sum(gen_ai.usage.output_tokens)"}},
|
||||
Filter: &qbtypes.Filter{Expression: "trace.output_tokens > 1000"},
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
assertSQLEqual(t, `
|
||||
WITH __trace_scope AS (
|
||||
SELECT trace_id,
|
||||
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens
|
||||
FROM signoz_traces.distributed_signoz_index_v3
|
||||
WHERE timestamp >= '1747947419000000000'
|
||||
AND timestamp < '1747983448000000000'
|
||||
AND ts_bucket_start >= 1747945619
|
||||
AND ts_bucket_start <= 1747983448
|
||||
AND (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))
|
||||
GROUP BY trace_id
|
||||
HAVING output_tokens > 1000
|
||||
)
|
||||
SELECT sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS __result_0
|
||||
FROM signoz_traces.distributed_signoz_index_v3
|
||||
WHERE trace_id GLOBAL IN (SELECT trace_id FROM __trace_scope)
|
||||
AND (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))
|
||||
AND timestamp >= '1747947419000000000'
|
||||
AND timestamp < '1747983448000000000'
|
||||
AND ts_bucket_start >= 1747945619
|
||||
AND ts_bucket_start <= 1747983448
|
||||
ORDER BY __result_0 DESC
|
||||
`, stmt)
|
||||
}
|
||||
|
||||
// Two group keys make the top-N prune a 2-tuple GLOBAL IN, and the qualification plus
|
||||
// span predicate apply to the ranking scan and the main scan alike.
|
||||
func TestBuild_FullSQL_TimeSeries_GroupLimit_MultiKey(t *testing.T) {
|
||||
b := newTestBuilder(t)
|
||||
stmt, err := b.Build(context.Background(), valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeTimeSeries,
|
||||
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
StepInterval: qbtypes.Step{Duration: 60 * time.Second},
|
||||
Aggregations: []qbtypes.TraceAggregation{
|
||||
{Expression: "sum(trace.output_tokens)"},
|
||||
{Expression: "count(trace.trace_id)"},
|
||||
},
|
||||
Filter: &qbtypes.Filter{Expression: "gen_ai.request.model = 'gpt-4o-mini' AND trace.total_tokens > 100"},
|
||||
GroupBy: []qbtypes.GroupByKey{
|
||||
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "gen_ai.request.model"}},
|
||||
{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "gen_ai.user.id"}},
|
||||
},
|
||||
Limit: 2,
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
assertSQLEqual(t, `
|
||||
WITH __qualified AS (
|
||||
SELECT trace_id,
|
||||
coalesce(sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.input_tokens'), toFloat64(attributes_number['gen_ai.usage.input_tokens']), NULL)), 0) + coalesce(sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)), 0) AS total_tokens
|
||||
FROM signoz_traces.distributed_signoz_index_v3
|
||||
WHERE timestamp >= '1747947419000000000'
|
||||
AND timestamp < '1747983448000000000'
|
||||
AND ts_bucket_start >= 1747945619
|
||||
AND ts_bucket_start <= 1747983448
|
||||
AND (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))
|
||||
GROUP BY trace_id
|
||||
HAVING total_tokens > 100
|
||||
),
|
||||
__scoped_traces_total AS (
|
||||
SELECT trace_id,
|
||||
toString(multiIf(mapContains(attributes_string, 'gen_ai.request.model'), attributes_string['gen_ai.request.model'], NULL)) AS __GROUP_BY_KEY_0_gen_ai.request.model,
|
||||
toString(multiIf(mapContains(attributes_string, 'gen_ai.user.id'), attributes_string['gen_ai.user.id'], NULL)) AS __GROUP_BY_KEY_1_gen_ai.user.id,
|
||||
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens
|
||||
FROM signoz_traces.distributed_signoz_index_v3
|
||||
WHERE timestamp >= '1747947419000000000'
|
||||
AND timestamp < '1747983448000000000'
|
||||
AND ts_bucket_start >= 1747945619
|
||||
AND ts_bucket_start <= 1747983448
|
||||
AND (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))
|
||||
AND (attributes_string['gen_ai.request.model'] = 'gpt-4o-mini' AND mapContains(attributes_string, 'gen_ai.request.model'))
|
||||
AND trace_id GLOBAL IN (SELECT trace_id FROM __qualified)
|
||||
GROUP BY trace_id, __GROUP_BY_KEY_0_gen_ai.request.model, __GROUP_BY_KEY_1_gen_ai.user.id
|
||||
),
|
||||
__limit_cte AS (
|
||||
SELECT __GROUP_BY_KEY_0_gen_ai.request.model, __GROUP_BY_KEY_1_gen_ai.user.id, sum(output_tokens) AS __result_0, count(trace_id) AS __result_1
|
||||
FROM __scoped_traces_total
|
||||
GROUP BY __GROUP_BY_KEY_0_gen_ai.request.model, __GROUP_BY_KEY_1_gen_ai.user.id
|
||||
ORDER BY __result_0 DESC
|
||||
LIMIT 2
|
||||
),
|
||||
__scoped_traces AS (
|
||||
SELECT trace_id,
|
||||
toStartOfInterval(timestamp, INTERVAL 60 SECOND) AS ts,
|
||||
toString(multiIf(mapContains(attributes_string, 'gen_ai.request.model'), attributes_string['gen_ai.request.model'], NULL)) AS __GROUP_BY_KEY_0_gen_ai.request.model,
|
||||
toString(multiIf(mapContains(attributes_string, 'gen_ai.user.id'), attributes_string['gen_ai.user.id'], NULL)) AS __GROUP_BY_KEY_1_gen_ai.user.id,
|
||||
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens
|
||||
FROM signoz_traces.distributed_signoz_index_v3
|
||||
WHERE timestamp >= '1747947419000000000'
|
||||
AND timestamp < '1747983448000000000'
|
||||
AND ts_bucket_start >= 1747945619
|
||||
AND ts_bucket_start <= 1747983448
|
||||
AND (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))
|
||||
AND (attributes_string['gen_ai.request.model'] = 'gpt-4o-mini' AND mapContains(attributes_string, 'gen_ai.request.model'))
|
||||
AND trace_id GLOBAL IN (SELECT trace_id FROM __qualified)
|
||||
AND (toString(multiIf(mapContains(attributes_string, 'gen_ai.request.model'), attributes_string['gen_ai.request.model'], NULL)), toString(multiIf(mapContains(attributes_string, 'gen_ai.user.id'), attributes_string['gen_ai.user.id'], NULL))) GLOBAL IN (SELECT __GROUP_BY_KEY_0_gen_ai.request.model, __GROUP_BY_KEY_1_gen_ai.user.id FROM __limit_cte)
|
||||
GROUP BY trace_id, ts, __GROUP_BY_KEY_0_gen_ai.request.model, __GROUP_BY_KEY_1_gen_ai.user.id
|
||||
)
|
||||
SELECT ts, __GROUP_BY_KEY_0_gen_ai.request.model, __GROUP_BY_KEY_1_gen_ai.user.id, sum(output_tokens) AS __result_0, count(trace_id) AS __result_1
|
||||
FROM __scoped_traces
|
||||
GROUP BY ts, __GROUP_BY_KEY_0_gen_ai.request.model, __GROUP_BY_KEY_1_gen_ai.user.id
|
||||
SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000
|
||||
`, stmt)
|
||||
}
|
||||
|
||||
// A time-series limit without group-by has nothing to rank: it is ignored, matching
|
||||
// the trace builder — the query equals its unlimited form.
|
||||
func TestBuild_TimeSeries_LimitWithoutGroupByIgnored(t *testing.T) {
|
||||
b := newTestBuilder(t)
|
||||
build := func(limit int) *qbtypes.Statement {
|
||||
stmt, err := b.Build(context.Background(), valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeTimeSeries,
|
||||
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
StepInterval: qbtypes.Step{Duration: 60 * time.Second},
|
||||
Aggregations: []qbtypes.TraceAggregation{{Expression: "avg(trace.output_tokens)"}},
|
||||
Limit: limit,
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
return stmt
|
||||
}
|
||||
assert.Equal(t, build(0).Query, build(5).Query)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Behavior / branch tests not covered by the goldens above
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Mixing span- and trace-level aggregations across one query is rejected.
|
||||
func TestBuild_Aggregation_MixedDomainsRejected(t *testing.T) {
|
||||
b := newTestBuilder(t)
|
||||
_, err := b.Build(context.Background(), valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeScalar,
|
||||
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Aggregations: []qbtypes.TraceAggregation{
|
||||
{Expression: "avg(trace.output_tokens)"},
|
||||
{Expression: "sum(gen_ai.usage.output_tokens)"},
|
||||
},
|
||||
}, nil)
|
||||
require.ErrorContains(t, err, "cannot be mixed")
|
||||
}
|
||||
|
||||
// Output-only aggregates are rejected in trace-level filters on the aggregation
|
||||
// path too (the raw and trace-list paths are covered elsewhere).
|
||||
func TestBuild_Aggregation_OutputOnlyFilterRejected(t *testing.T) {
|
||||
b := newTestBuilder(t)
|
||||
_, err := b.Build(context.Background(), valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeScalar,
|
||||
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Aggregations: []qbtypes.TraceAggregation{{Expression: "count()"}},
|
||||
Filter: &qbtypes.Filter{Expression: "trace.span_count > 3"},
|
||||
}, nil)
|
||||
require.ErrorContains(t, err, `aggregate "span_count" cannot be used`)
|
||||
}
|
||||
|
||||
// Trace-level columns are rejected as group-by keys; order keys never reach the builder,
|
||||
// since request validation only admits group keys and aggregation aliases/expressions.
|
||||
func TestBuild_Aggregation_GroupByOrderValidation(t *testing.T) {
|
||||
b := newTestBuilder(t)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := b.Build(ctx, valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeScalar,
|
||||
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Aggregations: []qbtypes.TraceAggregation{{Expression: "avg(trace.output_tokens)"}},
|
||||
GroupBy: []qbtypes.GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "trace.llm_call_count"}}},
|
||||
}, nil)
|
||||
require.ErrorContains(t, err, `grouping by trace-level aggregate "trace.llm_call_count" is not supported`)
|
||||
|
||||
req := qbtypes.QueryRangeRequest{
|
||||
Start: testStartMs,
|
||||
End: testEndMs,
|
||||
RequestType: qbtypes.RequestTypeScalar,
|
||||
CompositeQuery: qbtypes.CompositeQuery{
|
||||
Queries: []qbtypes.QueryEnvelope{{
|
||||
Type: qbtypes.QueryTypeBuilderAI,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Name: "A",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Aggregations: []qbtypes.TraceAggregation{{Expression: "avg(trace.output_tokens)"}},
|
||||
Order: []qbtypes.OrderBy{{Key: qbtypes.OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "trace.total_tokens"}}, Direction: qbtypes.OrderDirectionDesc}},
|
||||
},
|
||||
}},
|
||||
},
|
||||
}
|
||||
require.ErrorContains(t, req.Validate(), "invalid order by key")
|
||||
|
||||
_, err = b.Build(ctx, valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeScalar,
|
||||
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Aggregations: []qbtypes.TraceAggregation{{Expression: "avg(trace.output_tokens)", Alias: "avg_out"}},
|
||||
Order: []qbtypes.OrderBy{{Key: qbtypes.OrderByKey{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "avg_out"}}, Direction: qbtypes.OrderDirectionAsc}},
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Variables in trace-level conditions resolve as bound args; a dynamic __all__ drops the
|
||||
// condition, and an unresolved $var is rejected only as an unknown aggregate today.
|
||||
func TestBuild_FullSQL_Aggregation_VariablesInTraceFilter(t *testing.T) {
|
||||
b := newTestBuilder(t)
|
||||
ctx := context.Background()
|
||||
|
||||
q := qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Aggregations: []qbtypes.TraceAggregation{{Expression: "avg(trace.output_tokens)"}},
|
||||
Filter: &qbtypes.Filter{Expression: "trace.output_tokens > $threshold"},
|
||||
}
|
||||
stmt, err := b.Build(ctx, valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeScalar, q,
|
||||
map[string]qbtypes.VariableItem{"threshold": {Type: qbtypes.TextBoxVariableType, Value: float64(1000)}})
|
||||
require.NoError(t, err)
|
||||
assertSQLEqual(t, `
|
||||
WITH __qualified AS (
|
||||
SELECT trace_id,
|
||||
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens
|
||||
FROM signoz_traces.distributed_signoz_index_v3
|
||||
WHERE timestamp >= '1747947419000000000'
|
||||
AND timestamp < '1747983448000000000'
|
||||
AND ts_bucket_start >= 1747945619
|
||||
AND ts_bucket_start <= 1747983448
|
||||
AND (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))
|
||||
GROUP BY trace_id
|
||||
HAVING output_tokens > 1000
|
||||
),
|
||||
__scoped_traces AS (
|
||||
SELECT trace_id,
|
||||
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens
|
||||
FROM signoz_traces.distributed_signoz_index_v3
|
||||
WHERE timestamp >= '1747947419000000000'
|
||||
AND timestamp < '1747983448000000000'
|
||||
AND ts_bucket_start >= 1747945619
|
||||
AND ts_bucket_start <= 1747983448
|
||||
AND (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))
|
||||
AND trace_id GLOBAL IN (SELECT trace_id FROM __qualified)
|
||||
GROUP BY trace_id
|
||||
)
|
||||
SELECT avg(output_tokens) AS __result_0
|
||||
FROM __scoped_traces
|
||||
ORDER BY __result_0 DESC
|
||||
SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000
|
||||
`, stmt)
|
||||
|
||||
// an unresolved $var is only rejected as an unknown aggregate today; a targeted
|
||||
// "unknown variable" error is a separate concern
|
||||
_, err = b.Build(ctx, valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeScalar, q, nil)
|
||||
require.ErrorContains(t, err, `aggregate "$threshold" cannot be used`)
|
||||
|
||||
// __all__ drops the condition: the query equals its unfiltered form
|
||||
stmt, err = b.Build(ctx, valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeScalar, q,
|
||||
map[string]qbtypes.VariableItem{"threshold": {Type: qbtypes.DynamicVariableType, Value: "__all__"}})
|
||||
require.NoError(t, err)
|
||||
unfiltered := q
|
||||
unfiltered.Filter = nil
|
||||
want, err := b.Build(ctx, valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeScalar, unfiltered, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, want.Query, stmt.Query)
|
||||
|
||||
// list variables render as IN with bound args; the scan selects only trace_id
|
||||
// since no aggregation touches a per-trace column
|
||||
stmt, err = b.Build(ctx, valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeScalar,
|
||||
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Aggregations: []qbtypes.TraceAggregation{{Expression: "count(trace.trace_id)"}},
|
||||
Filter: &qbtypes.Filter{Expression: "trace.llm_call_count IN $counts"},
|
||||
}, map[string]qbtypes.VariableItem{
|
||||
"counts": {Type: qbtypes.QueryVariableType, Value: []any{float64(1), float64(2)}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assertSQLEqual(t, `
|
||||
WITH __qualified AS (
|
||||
SELECT trace_id,
|
||||
countIf(mapContains(attributes_string, 'gen_ai.request.model')) AS llm_call_count
|
||||
FROM signoz_traces.distributed_signoz_index_v3
|
||||
WHERE timestamp >= '1747947419000000000'
|
||||
AND timestamp < '1747983448000000000'
|
||||
AND ts_bucket_start >= 1747945619
|
||||
AND ts_bucket_start <= 1747983448
|
||||
AND (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))
|
||||
GROUP BY trace_id
|
||||
HAVING llm_call_count IN (1, 2)
|
||||
),
|
||||
__scoped_traces AS (
|
||||
SELECT trace_id
|
||||
FROM signoz_traces.distributed_signoz_index_v3
|
||||
WHERE timestamp >= '1747947419000000000'
|
||||
AND timestamp < '1747983448000000000'
|
||||
AND ts_bucket_start >= 1747945619
|
||||
AND ts_bucket_start <= 1747983448
|
||||
AND (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))
|
||||
AND trace_id GLOBAL IN (SELECT trace_id FROM __qualified)
|
||||
GROUP BY trace_id
|
||||
)
|
||||
SELECT count(trace_id) AS __result_0
|
||||
FROM __scoped_traces
|
||||
ORDER BY __result_0 DESC
|
||||
SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000
|
||||
`, stmt)
|
||||
}
|
||||
|
||||
// Resource conditions on the native path: the __resource_filter CTE prunes the
|
||||
// qualification scan and the per-trace scan by fingerprint.
|
||||
func TestBuild_FullSQL_Aggregation_ResourceFilter_Native(t *testing.T) {
|
||||
b := newTestBuilder(t)
|
||||
stmt, err := b.Build(context.Background(), valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeScalar,
|
||||
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Aggregations: []qbtypes.TraceAggregation{{Expression: "avg(trace.output_tokens)"}},
|
||||
Filter: &qbtypes.Filter{Expression: "service.name = 'api' AND trace.output_tokens > 1000"},
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
assertSQLEqual(t, `
|
||||
WITH __resource_filter AS (
|
||||
SELECT fingerprint
|
||||
FROM signoz_traces.distributed_traces_v3_resource
|
||||
WHERE (simpleJSONExtractString(labels, 'service.name') = 'api' AND labels LIKE '%service.name%' AND labels LIKE '%service.name":"api%')
|
||||
AND seen_at_ts_bucket_start >= 1747945619
|
||||
AND seen_at_ts_bucket_start <= 1747983448
|
||||
GROUP BY fingerprint
|
||||
),
|
||||
__qualified AS (
|
||||
SELECT trace_id,
|
||||
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens
|
||||
FROM signoz_traces.distributed_signoz_index_v3
|
||||
WHERE timestamp >= '1747947419000000000'
|
||||
AND timestamp < '1747983448000000000'
|
||||
AND ts_bucket_start >= 1747945619
|
||||
AND ts_bucket_start <= 1747983448
|
||||
AND (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))
|
||||
AND resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter)
|
||||
GROUP BY trace_id
|
||||
HAVING output_tokens > 1000
|
||||
),
|
||||
__scoped_traces AS (
|
||||
SELECT trace_id,
|
||||
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens
|
||||
FROM signoz_traces.distributed_signoz_index_v3
|
||||
WHERE timestamp >= '1747947419000000000'
|
||||
AND timestamp < '1747983448000000000'
|
||||
AND ts_bucket_start >= 1747945619
|
||||
AND ts_bucket_start <= 1747983448
|
||||
AND (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))
|
||||
AND resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter)
|
||||
AND trace_id GLOBAL IN (SELECT trace_id FROM __qualified)
|
||||
GROUP BY trace_id
|
||||
)
|
||||
SELECT avg(output_tokens) AS __result_0
|
||||
FROM __scoped_traces
|
||||
ORDER BY __result_0 DESC
|
||||
SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000
|
||||
`, stmt)
|
||||
}
|
||||
|
||||
// On the delegated path __trace_scope and the main query share one __resource_filter
|
||||
// CTE, so the resource table is scanned once.
|
||||
func TestBuild_FullSQL_Aggregation_ResourceFilter_Delegated(t *testing.T) {
|
||||
b := newTestBuilder(t)
|
||||
stmt, err := b.Build(context.Background(), valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeScalar,
|
||||
qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Aggregations: []qbtypes.TraceAggregation{{Expression: "sum(gen_ai.usage.output_tokens)"}},
|
||||
Filter: &qbtypes.Filter{Expression: "service.name = 'api' AND trace.output_tokens > 1000"},
|
||||
}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
assertSQLEqual(t, `
|
||||
WITH __resource_filter AS (
|
||||
SELECT fingerprint
|
||||
FROM signoz_traces.distributed_traces_v3_resource
|
||||
WHERE ((simpleJSONExtractString(labels, 'service.name') = 'api' AND labels LIKE '%service.name%' AND labels LIKE '%service.name":"api%'))
|
||||
AND seen_at_ts_bucket_start >= 1747945619
|
||||
AND seen_at_ts_bucket_start <= 1747983448
|
||||
GROUP BY fingerprint
|
||||
),
|
||||
__trace_scope AS (
|
||||
SELECT trace_id,
|
||||
sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS output_tokens
|
||||
FROM signoz_traces.distributed_signoz_index_v3
|
||||
WHERE timestamp >= '1747947419000000000'
|
||||
AND timestamp < '1747983448000000000'
|
||||
AND ts_bucket_start >= 1747945619
|
||||
AND ts_bucket_start <= 1747983448
|
||||
AND (mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))
|
||||
AND resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter)
|
||||
GROUP BY trace_id
|
||||
HAVING output_tokens > 1000
|
||||
)
|
||||
SELECT sum(multiIf(mapContains(attributes_number, 'gen_ai.usage.output_tokens'), toFloat64(attributes_number['gen_ai.usage.output_tokens']), NULL)) AS __result_0
|
||||
FROM signoz_traces.distributed_signoz_index_v3
|
||||
WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter)
|
||||
AND trace_id GLOBAL IN (SELECT trace_id FROM __trace_scope)
|
||||
AND (((mapContains(attributes_string, 'gen_ai.request.model') OR mapContains(attributes_string, 'gen_ai.tool.name') OR mapContains(attributes_string, 'gen_ai.agent.name'))) AND ((multiIf(resource.service.name IS NOT NULL, resource.service.name::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) = 'api' AND multiIf(resource.service.name IS NOT NULL, resource.service.name::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL)))
|
||||
AND timestamp >= '1747947419000000000'
|
||||
AND timestamp < '1747983448000000000'
|
||||
AND ts_bucket_start >= 1747945619
|
||||
AND ts_bucket_start <= 1747983448
|
||||
ORDER BY __result_0 DESC
|
||||
`, stmt)
|
||||
}
|
||||
|
||||
// rate() divides by the window (scalar) / step (series). Per AggreFuncMap it counts
|
||||
// per-trace rows per second; it does not sum the column.
|
||||
func TestBuild_Aggregation_RateDividesByInterval(t *testing.T) {
|
||||
b := newTestBuilder(t)
|
||||
ctx := context.Background()
|
||||
q := qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
Aggregations: []qbtypes.TraceAggregation{{Expression: "rate(trace.llm_call_count)"}},
|
||||
}
|
||||
|
||||
stmt, err := b.Build(ctx, valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeScalar, q, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, stmt.Query, "count(llm_call_count)/36029 AS __result_0") // (end-start) seconds
|
||||
|
||||
q.StepInterval = qbtypes.Step{Duration: 60 * time.Second}
|
||||
stmt, err = b.Build(ctx, valuer.UUID{}, testStartMs, testEndMs, qbtypes.RequestTypeTimeSeries, q, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, stmt.Query, "count(llm_call_count)/60 AS __result_0")
|
||||
|
||||
// a sub-second window clamps the divisor instead of truncating it to zero
|
||||
stmt, err = b.Build(ctx, valuer.UUID{}, testStartMs, testStartMs+500, qbtypes.RequestTypeScalar, q, nil)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, stmt.Query, "count(llm_call_count)/1 AS __result_0")
|
||||
}
|
||||
@@ -428,20 +428,24 @@ func (b *StatementBuilder) buildTemporalAggDeltaFastPath(
|
||||
sb.SelectMore(fmt.Sprintf("`%s`", GroupByColumnAlias(i, g.Name)))
|
||||
}
|
||||
|
||||
aggCol, err := metricstelemetryschema.AggregationColumnForSamplesTable(
|
||||
samplesTable, query.Aggregations[0].Temporality, query.Aggregations[0].TimeAggregation,
|
||||
)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if query.Aggregations[0].TimeAggregation == metrictypes.TimeAggregationRate {
|
||||
// TODO(srikanthccv): should it be step interval or use [start_time_unix_nano](https://github.com/open-telemetry/opentelemetry-proto/blob/d3fb76d70deb0874692bd0ebe03148580d85f3bb/opentelemetry/proto/metrics/v1/metrics.proto#L400C11-L400C31)?
|
||||
aggCol = fmt.Sprintf("%s/%d", aggCol, stepSec)
|
||||
}
|
||||
|
||||
var aggCol string
|
||||
if query.Aggregations[0].SpaceAggregation.IsPercentile() &&
|
||||
query.Aggregations[0].Type == metrictypes.ExpHistogramType {
|
||||
// merging sketches already spans every series in the step, so neither a
|
||||
// samples-table value column nor the rate divisor applies
|
||||
aggCol = fmt.Sprintf("quantilesDDMerge(0.01, %f)(sketch)[1]", query.Aggregations[0].SpaceAggregation.Percentile())
|
||||
} else {
|
||||
col, err := metricstelemetryschema.AggregationColumnForSamplesTable(
|
||||
samplesTable, query.Aggregations[0].Temporality, query.Aggregations[0].TimeAggregation,
|
||||
)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
aggCol = col
|
||||
if query.Aggregations[0].TimeAggregation == metrictypes.TimeAggregationRate {
|
||||
// TODO(srikanthccv): should it be step interval or use [start_time_unix_nano](https://github.com/open-telemetry/opentelemetry-proto/blob/d3fb76d70deb0874692bd0ebe03148580d85f3bb/opentelemetry/proto/metrics/v1/metrics.proto#L400C11-L400C31)?
|
||||
aggCol = fmt.Sprintf("%s/%d", aggCol, stepSec)
|
||||
}
|
||||
}
|
||||
|
||||
sb.SelectMore(fmt.Sprintf("%s AS value", aggCol))
|
||||
|
||||
@@ -126,6 +126,64 @@ func TestStatementBuilder(t *testing.T) {
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
name: "test_exp_histogram_percentile_delta",
|
||||
requestType: qbtypes.RequestTypeTimeSeries,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
|
||||
Aggregations: []qbtypes.MetricAggregation{
|
||||
{
|
||||
MetricName: "signoz_latency",
|
||||
Type: metrictypes.ExpHistogramType,
|
||||
Temporality: metrictypes.Delta,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationPercentile95,
|
||||
},
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{
|
||||
{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "service.name",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __spatial_aggregation_cte AS (SELECT toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(30)) AS ts, `__GROUP_BY_KEY_0_service.name`, quantilesDDMerge(0.01, 0.950000)(sketch)[1] AS value FROM signoz_metrics.distributed_exp_hist AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'service.name') AS `__GROUP_BY_KEY_0_service.name` FROM signoz_metrics.time_series_v4_6hrs WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) GROUP BY fingerprint, `__GROUP_BY_KEY_0_service.name`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY ts, `__GROUP_BY_KEY_0_service.name`) SELECT * FROM __spatial_aggregation_cte ORDER BY `__GROUP_BY_KEY_0_service.name`, ts",
|
||||
Args: []any{"signoz_latency", uint64(1747936800000), uint64(1747983420000), "delta", "signoz_latency", uint64(1747947390000), uint64(1747983420000)},
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
// the sketch merge spans the whole step, so `rate` must not add a /step divisor
|
||||
name: "test_exp_histogram_percentile_delta_rate_time_aggregation",
|
||||
requestType: qbtypes.RequestTypeTimeSeries,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
|
||||
Signal: telemetrytypes.SignalMetrics,
|
||||
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
|
||||
Aggregations: []qbtypes.MetricAggregation{
|
||||
{
|
||||
MetricName: "signoz_latency",
|
||||
Type: metrictypes.ExpHistogramType,
|
||||
Temporality: metrictypes.Delta,
|
||||
TimeAggregation: metrictypes.TimeAggregationRate,
|
||||
SpaceAggregation: metrictypes.SpaceAggregationPercentile95,
|
||||
},
|
||||
},
|
||||
GroupBy: []qbtypes.GroupByKey{
|
||||
{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "service.name",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __spatial_aggregation_cte AS (SELECT toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(30)) AS ts, `__GROUP_BY_KEY_0_service.name`, quantilesDDMerge(0.01, 0.950000)(sketch)[1] AS value FROM signoz_metrics.distributed_exp_hist AS points INNER JOIN (SELECT fingerprint, JSONExtractString(labels, 'service.name') AS `__GROUP_BY_KEY_0_service.name` FROM signoz_metrics.time_series_v4_6hrs WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli <= ? AND LOWER(temporality) LIKE LOWER(?) GROUP BY fingerprint, `__GROUP_BY_KEY_0_service.name`) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint WHERE metric_name IN (?) AND unix_milli >= ? AND unix_milli < ? GROUP BY ts, `__GROUP_BY_KEY_0_service.name`) SELECT * FROM __spatial_aggregation_cte ORDER BY `__GROUP_BY_KEY_0_service.name`, ts",
|
||||
Args: []any{"signoz_latency", uint64(1747936800000), uint64(1747983420000), "delta", "signoz_latency", uint64(1747947390000), uint64(1747983420000)},
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
name: "test_histogram_percentile1",
|
||||
requestType: qbtypes.RequestTypeTimeSeries,
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
@@ -19,7 +18,6 @@ import (
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
qbvariables "github.com/SigNoz/signoz/pkg/variables"
|
||||
"github.com/huandu/go-sqlbuilder"
|
||||
)
|
||||
|
||||
@@ -117,6 +115,8 @@ func (b *scopedTraceStatementBuilder) Build(
|
||||
return b.buildTraceListQuery(ctx, orgID, querybuilder.ToNanoSecs(start), querybuilder.ToNanoSecs(end), query, variables)
|
||||
case qbtypes.RequestTypeRaw:
|
||||
return b.buildDelegated(ctx, orgID, start, end, requestType, query, variables)
|
||||
case qbtypes.RequestTypeScalar, qbtypes.RequestTypeTimeSeries:
|
||||
return b.buildAggregation(ctx, orgID, start, end, requestType, query, variables)
|
||||
default:
|
||||
return nil, ErrUnsupportedRequestType
|
||||
}
|
||||
@@ -145,6 +145,63 @@ func (b *scopedTraceStatementBuilder) buildDelegated(
|
||||
return b.traceStmtBuilder.Build(ctx, orgID, start, end, requestType, gated, variables)
|
||||
}
|
||||
|
||||
// traceScopedStatementBuilder is the delegate's optional capability of constraining a
|
||||
// query to a set of trace ids (implemented by the traces statement builder).
|
||||
// traceScopeResource is the __resource_filter CTE traceScope's predicate references,
|
||||
// shared with the delegate's own resource filter so the table is scanned once.
|
||||
type traceScopedStatementBuilder interface {
|
||||
BuildTraceScoped(ctx context.Context, orgID valuer.UUID, start, end uint64, requestType qbtypes.RequestType, query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation], variables map[string]qbtypes.VariableItem, traceScope, traceScopeResource *qbtypes.Statement) (*qbtypes.Statement, error)
|
||||
}
|
||||
|
||||
// buildDelegatedAggregation serves span-level scalar/time-series through the standard
|
||||
// trace builder, with the gate ANDed into the span-level filter part; a trace-level
|
||||
// part becomes a qualification the delegate constrains trace_id by.
|
||||
func (b *scopedTraceStatementBuilder) buildDelegatedAggregation(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
start, end uint64,
|
||||
requestType qbtypes.RequestType,
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
) (*qbtypes.Statement, error) {
|
||||
var spanExpr, traceExpr string
|
||||
var err error
|
||||
if query.Filter != nil && strings.TrimSpace(query.Filter.Expression) != "" {
|
||||
spanExpr, traceExpr, err = querybuilder.SplitFilterForAggregates(query.Filter.Expression, b.aggregateAliasSet())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
gate := b.scope.FilterExpression
|
||||
expr := gate
|
||||
if strings.TrimSpace(spanExpr) != "" {
|
||||
expr = fmt.Sprintf("(%s) AND (%s)", gate, spanExpr)
|
||||
}
|
||||
|
||||
// shallow copy; only Filter is replaced, caller's query untouched
|
||||
gated := query
|
||||
gated.Filter = &qbtypes.Filter{Expression: expr}
|
||||
|
||||
if strings.TrimSpace(traceExpr) == "" {
|
||||
return b.traceStmtBuilder.Build(ctx, orgID, start, end, requestType, gated, variables)
|
||||
}
|
||||
|
||||
scoped, ok := b.traceStmtBuilder.(traceScopedStatementBuilder)
|
||||
if !ok {
|
||||
return nil, errors.NewInternalf(errors.CodeInternal, "trace statement builder does not support trace-scoped queries")
|
||||
}
|
||||
scope, scopeResource, err := b.buildQualifiedStatement(ctx, orgID, querybuilder.ToNanoSecs(start), querybuilder.ToNanoSecs(end), traceExpr, query, variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if scope == nil {
|
||||
// every trace-level condition was dropped by variable resolution
|
||||
return b.traceStmtBuilder.Build(ctx, orgID, start, end, requestType, gated, variables)
|
||||
}
|
||||
return scoped.BuildTraceScoped(ctx, orgID, start, end, requestType, gated, variables, scope, scopeResource)
|
||||
}
|
||||
|
||||
// buildTraceListQuery wires the CTE pipeline (start/end are nanoseconds):
|
||||
// matched (windowed, mask-pruned top-N trace_ids) → ranked (their [start,end] from
|
||||
// the summary table) → buckets (ts_bucket_start prune) → enrichment (every per-trace
|
||||
@@ -166,9 +223,13 @@ func (b *scopedTraceStatementBuilder) buildTraceListQuery(
|
||||
limit = 100
|
||||
}
|
||||
|
||||
filterExpr := ""
|
||||
if query.Filter != nil {
|
||||
filterExpr = query.Filter.Expression
|
||||
}
|
||||
// Condition args bind into the builder an expression is embedded in, so the
|
||||
// matched and enrichment passes each resolve against their own builder.
|
||||
keys, err := b.fetchKeys(ctx, orgID)
|
||||
keys, err := b.fetchKeys(ctx, orgID, spanFilterSelectors(filterExpr)...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -186,23 +247,17 @@ func (b *scopedTraceStatementBuilder) buildTraceListQuery(
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
orderableSet := orderableAliasSet(resolved)
|
||||
filterableSet := filterableAliasSet(resolved)
|
||||
|
||||
resourceFrag, resourceArgs, resourcePred, err := b.maybeAttachResourceFilter(ctx, orgID, query, start, end, variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fp, err := b.splitFilter(ctx, orgID, query, b.aggregateAliasSet(), filterableSet, start, end, variables, matchedSB)
|
||||
fp, err := b.splitFilter(ctx, orgID, query, b.aggregateAliasSet(), keys, start, end, variables, matchedSB)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
matchedFrag, matchedArgs, err := b.buildMatchedCTE(matchedSB, start, end, startBucket, endBucket, resolved, orders, orderableSet, filterableSet, maskExpr, fp, resourcePred, limit, query.Offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
matchedFrag, matchedArgs := b.buildMatchedCTE(matchedSB, start, end, startBucket, endBucket, resolved, orders, maskExpr, fp, resourcePred, limit, query.Offset)
|
||||
rankedFrag, rankedArgs := b.buildRankedCTE(start, end)
|
||||
|
||||
adj := querybuilder.BucketAdjustment // 30-min bucket width in seconds
|
||||
@@ -258,9 +313,10 @@ func (b *scopedTraceStatementBuilder) maybeAttachResourceFilter(
|
||||
"resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter)", nil
|
||||
}
|
||||
|
||||
func (b *scopedTraceStatementBuilder) fetchKeys(ctx context.Context, orgID valuer.UUID) (map[string][]*telemetrytypes.TelemetryFieldKey, error) {
|
||||
func (b *scopedTraceStatementBuilder) fetchKeys(ctx context.Context, orgID valuer.UUID, extra ...*telemetrytypes.FieldKeySelector) (map[string][]*telemetrytypes.TelemetryFieldKey, error) {
|
||||
fields := b.resolverFieldKeys()
|
||||
selectors := make([]*telemetrytypes.FieldKeySelector, 0, len(fields))
|
||||
selectors := make([]*telemetrytypes.FieldKeySelector, 0, len(fields)+len(extra))
|
||||
selectors = append(selectors, extra...)
|
||||
for _, k := range fields {
|
||||
selectors = append(selectors, &telemetrytypes.FieldKeySelector{
|
||||
Name: k.Name,
|
||||
@@ -329,10 +385,9 @@ func (b *scopedTraceStatementBuilder) resolveMask(ctx context.Context, orgID val
|
||||
}
|
||||
|
||||
type resolvedColumn struct {
|
||||
alias string
|
||||
expr string
|
||||
orderable bool
|
||||
filterable bool
|
||||
alias string
|
||||
expr string
|
||||
orderable bool
|
||||
}
|
||||
|
||||
func (b *scopedTraceStatementBuilder) resolveColumns(ctx context.Context, orgID valuer.UUID, start, end uint64, cols *columnResolver, preds *predicateResolver) ([]resolvedColumn, error) {
|
||||
@@ -342,7 +397,7 @@ func (b *scopedTraceStatementBuilder) resolveColumns(ctx context.Context, orgID
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, resolvedColumn{alias: c.Alias, expr: expr, orderable: c.Orderable, filterable: c.Filterable})
|
||||
out = append(out, resolvedColumn{alias: c.Alias, expr: expr, orderable: c.Orderable})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -384,29 +439,30 @@ func (b *scopedTraceStatementBuilder) resolveListOrders(order []qbtypes.OrderBy,
|
||||
return orders, nil
|
||||
}
|
||||
|
||||
// filterParts is the user filter split into a span-level predicate and a trace-level
|
||||
// HAVING expression.
|
||||
// filterParts is the user filter split into a span-level predicate and the resolved
|
||||
// trace-level HAVING (nil when there is none).
|
||||
type filterParts struct {
|
||||
spanPred string
|
||||
hasSpanFilter bool
|
||||
havingExpr string
|
||||
having *traceHaving
|
||||
warnings []string
|
||||
warningsURL string
|
||||
}
|
||||
|
||||
// splitFilter splits query.Filter into a span-level predicate (args bound into sb)
|
||||
// and a trace-level HAVING (explicit query.Having ANDed on), then validates the
|
||||
// trace-level part against the matched-pass aggregates.
|
||||
func (b *scopedTraceStatementBuilder) splitFilter(ctx context.Context, orgID valuer.UUID, query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation], classifySet, filterableSet map[string]struct{}, start, end uint64, variables map[string]qbtypes.VariableItem, sb *sqlbuilder.SelectBuilder) (filterParts, error) {
|
||||
// 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) {
|
||||
var fp filterParts
|
||||
havingExpr := ""
|
||||
if query.Filter != nil && strings.TrimSpace(query.Filter.Expression) != "" {
|
||||
spanExpr, traceExpr, err := querybuilder.SplitFilterForAggregates(query.Filter.Expression, classifySet)
|
||||
if err != nil {
|
||||
return fp, err
|
||||
}
|
||||
fp.havingExpr = traceExpr
|
||||
havingExpr = traceExpr
|
||||
if strings.TrimSpace(spanExpr) != "" {
|
||||
pred, warnings, url, err := b.resolveSpanPredicate(ctx, orgID, start, end, spanExpr, variables, sb)
|
||||
pred, warnings, url, err := b.resolveSpanPredicate(ctx, orgID, start, end, spanExpr, keys, variables, sb)
|
||||
if err != nil {
|
||||
return fp, err
|
||||
}
|
||||
@@ -419,37 +475,23 @@ func (b *scopedTraceStatementBuilder) splitFilter(ctx context.Context, orgID val
|
||||
}
|
||||
}
|
||||
if query.Having != nil && strings.TrimSpace(query.Having.Expression) != "" {
|
||||
if fp.havingExpr != "" {
|
||||
fp.havingExpr = fmt.Sprintf("(%s) AND (%s)", fp.havingExpr, query.Having.Expression)
|
||||
if havingExpr != "" {
|
||||
havingExpr = fmt.Sprintf("(%s) AND (%s)", havingExpr, query.Having.Expression)
|
||||
} else {
|
||||
fp.havingExpr = query.Having.Expression
|
||||
havingExpr = query.Having.Expression
|
||||
}
|
||||
}
|
||||
// the HAVING is a plain text rewrite, so substitute variables here
|
||||
if strings.TrimSpace(fp.havingExpr) != "" && len(variables) > 0 {
|
||||
replaced, err := qbvariables.ReplaceVariablesInExpression(fp.havingExpr, variables)
|
||||
if err != nil {
|
||||
return fp, err
|
||||
}
|
||||
fp.havingExpr = replaced
|
||||
}
|
||||
if err := validateAggregateFilter(fp.havingExpr, filterableSet); err != nil {
|
||||
having, err := b.resolveTraceHaving(ctx, havingExpr, variables, sb)
|
||||
if err != nil {
|
||||
return fp, err
|
||||
}
|
||||
fp.having = having
|
||||
return fp, nil
|
||||
}
|
||||
|
||||
// resolveSpanPredicate resolves a span-level filter expression to a bare boolean
|
||||
// predicate, args bound into sb.
|
||||
func (b *scopedTraceStatementBuilder) resolveSpanPredicate(ctx context.Context, orgID valuer.UUID, start, end uint64, expr string, variables map[string]qbtypes.VariableItem, sb *sqlbuilder.SelectBuilder) (string, []string, string, error) {
|
||||
selectors := querybuilder.QueryStringToKeysSelectors(expr)
|
||||
for i := range selectors {
|
||||
selectors[i].Signal = telemetrytypes.SignalTraces
|
||||
}
|
||||
keys, _, err := b.metadataStore.GetKeysMulti(ctx, orgID, querybuilder.ExpandKeySelectorsForFamilies(ctx, orgID, b.fl, selectors))
|
||||
if err != nil {
|
||||
return "", nil, "", err
|
||||
}
|
||||
// predicate, args bound into sb; keys must cover the expression's selectors.
|
||||
func (b *scopedTraceStatementBuilder) resolveSpanPredicate(ctx context.Context, orgID valuer.UUID, start, end uint64, expr string, keys map[string][]*telemetrytypes.TelemetryFieldKey, variables map[string]qbtypes.VariableItem, sb *sqlbuilder.SelectBuilder) (string, []string, string, error) {
|
||||
prepared, err := querybuilder.PrepareWhereClause(expr, querybuilder.FilterExprVisitorOpts{
|
||||
Context: ctx,
|
||||
OrgID: orgID,
|
||||
@@ -478,8 +520,8 @@ func (b *scopedTraceStatementBuilder) resolveSpanPredicate(ctx context.Context,
|
||||
// span filter + HAVING + ORDER BY + LIMIT/OFFSET, selecting only the aliases ORDER BY
|
||||
// / HAVING reference. Expressions carry $n markers bound to sb, so each can appear
|
||||
// several times and every occurrence resolves to the same arg.
|
||||
func (b *scopedTraceStatementBuilder) buildMatchedCTE(sb *sqlbuilder.SelectBuilder, start, end, startBucket, endBucket uint64, resolved []resolvedColumn, orders []listOrder, orderableSet, filterableSet map[string]struct{}, maskExpr string, fp filterParts, resourcePred string, limit, offset int) (string, []any, error) {
|
||||
needed := neededMatchedAliases(orders, fp.havingExpr, orderableSet)
|
||||
func (b *scopedTraceStatementBuilder) buildMatchedCTE(sb *sqlbuilder.SelectBuilder, start, end, startBucket, endBucket uint64, resolved []resolvedColumn, orders []listOrder, maskExpr string, fp filterParts, resourcePred string, limit, offset int) (string, []any) {
|
||||
needed := neededMatchedAliases(orders, fp.having)
|
||||
selects := []string{"trace_id"}
|
||||
for _, rc := range resolved {
|
||||
if _, ok := needed[rc.alias]; !ok {
|
||||
@@ -516,22 +558,8 @@ func (b *scopedTraceStatementBuilder) buildMatchedCTE(sb *sqlbuilder.SelectBuild
|
||||
having = append(having, "countIf("+maskExpr+") > 0")
|
||||
having = append(having, "countIf("+fp.spanPred+") > 0")
|
||||
}
|
||||
if strings.TrimSpace(fp.havingExpr) != "" {
|
||||
// the rewriter matches raw key text, so map the trace. form alongside the bare name
|
||||
columnMap := make(map[string]string, len(filterableSet)*2)
|
||||
for a := range filterableSet {
|
||||
columnMap[a] = quoteAlias(a)
|
||||
columnMap[telemetrytypes.FieldContextTrace.StringValue()+"."+a] = quoteAlias(a)
|
||||
}
|
||||
hv, err := querybuilder.NewHavingExpressionRewriter().Rewrite(fp.havingExpr, columnMap)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if hv != "" {
|
||||
// escape user text so a literal $ isn't read as an arg marker; the countIf
|
||||
// entries hold live $n markers and must stay unescaped
|
||||
having = append(having, sqlbuilder.Escape(hv))
|
||||
}
|
||||
if fp.having != nil {
|
||||
having = append(having, fp.having.pred)
|
||||
}
|
||||
if len(having) > 0 {
|
||||
sb.Having(strings.Join(having, " AND "))
|
||||
@@ -544,7 +572,7 @@ func (b *scopedTraceStatementBuilder) buildMatchedCTE(sb *sqlbuilder.SelectBuild
|
||||
}
|
||||
|
||||
sql, args := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
return fmt.Sprintf("matched AS (%s)", sql), args, nil
|
||||
return fmt.Sprintf("matched AS (%s)", sql), args
|
||||
}
|
||||
|
||||
// buildRankedCTE builds `ranked`: [start,end] bounds per matched trace from the
|
||||
@@ -585,8 +613,9 @@ func (b *scopedTraceStatementBuilder) buildEnrichmentSelect(sb *sqlbuilder.Selec
|
||||
return sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
}
|
||||
|
||||
// aggregateAliasSet is every trace-level column alias, used to classify filter keys;
|
||||
// SpanLevel columns are filtered span-level, so skip them.
|
||||
// aggregateAliasSet recognises trace-level keys — display-only aliases included, so one
|
||||
// gets a targeted error instead of falling through as a span attribute (what a predicate
|
||||
// may actually use is filterableColumnSet). SpanLevel columns are filtered span-level.
|
||||
func (b *scopedTraceStatementBuilder) aggregateAliasSet() map[string]struct{} {
|
||||
set := make(map[string]struct{}, len(b.scope.Columns))
|
||||
for _, c := range b.scope.Columns {
|
||||
@@ -597,70 +626,36 @@ func (b *scopedTraceStatementBuilder) aggregateAliasSet() map[string]struct{} {
|
||||
return set
|
||||
}
|
||||
|
||||
// orderableAliasSet is the subset of aliases computable in the matched pass.
|
||||
func orderableAliasSet(resolved []resolvedColumn) map[string]struct{} {
|
||||
set := make(map[string]struct{})
|
||||
for _, rc := range resolved {
|
||||
if rc.orderable {
|
||||
set[rc.alias] = struct{}{}
|
||||
}
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
// filterableAliasSet is the subset of aliases usable in the trace-level filter.
|
||||
func filterableAliasSet(resolved []resolvedColumn) map[string]struct{} {
|
||||
set := make(map[string]struct{})
|
||||
for _, rc := range resolved {
|
||||
if rc.filterable {
|
||||
set[rc.alias] = struct{}{}
|
||||
}
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
// neededMatchedAliases is the minimal alias set the matched pass must select: those
|
||||
// in ORDER BY plus those in the aggregate HAVING.
|
||||
func neededMatchedAliases(orders []listOrder, havingExpr string, orderableSet map[string]struct{}) map[string]struct{} {
|
||||
// in ORDER BY plus those the resolved trace-level HAVING touches.
|
||||
func neededMatchedAliases(orders []listOrder, having *traceHaving) map[string]struct{} {
|
||||
needed := make(map[string]struct{})
|
||||
for _, o := range orders {
|
||||
needed[o.alias] = struct{}{}
|
||||
}
|
||||
for _, name := range traceAggregateNames(havingExpr) {
|
||||
if _, ok := orderableSet[name]; ok {
|
||||
if having != nil {
|
||||
for name := range having.used {
|
||||
needed[name] = struct{}{}
|
||||
}
|
||||
}
|
||||
return needed
|
||||
}
|
||||
|
||||
// traceAggregateNames extracts the aggregate names a trace-level HAVING references;
|
||||
// only unspecified- and trace-context selectors name aggregates.
|
||||
func traceAggregateNames(havingExpr string) []string {
|
||||
var names []string
|
||||
for _, sel := range querybuilder.QueryStringToKeysSelectors(havingExpr) {
|
||||
if sel.FieldContext == telemetrytypes.FieldContextUnspecified || sel.FieldContext == telemetrytypes.FieldContextTrace {
|
||||
names = append(names, sel.Name)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// validateAggregateFilter rejects a trace-level filter referencing an aggregate that
|
||||
// is not filterable.
|
||||
// validateAggregateFilter rejects filters on aggregates that are not filterable
|
||||
// (e.g. span_count) upfront, since inside the where-clause visitor the error would
|
||||
// surface only as a detail of a combined one. Only unspecified- and trace-context
|
||||
// selectors name aggregates.
|
||||
func validateAggregateFilter(havingExpr string, filterableSet map[string]struct{}) error {
|
||||
if strings.TrimSpace(havingExpr) == "" {
|
||||
return nil
|
||||
}
|
||||
allowed := make([]string, 0, len(filterableSet))
|
||||
for a := range filterableSet {
|
||||
allowed = append(allowed, a)
|
||||
}
|
||||
sort.Strings(allowed)
|
||||
for _, name := range traceAggregateNames(havingExpr) {
|
||||
if _, ok := filterableSet[name]; !ok {
|
||||
for _, sel := range querybuilder.QueryStringToKeysSelectors(havingExpr) {
|
||||
if sel.FieldContext != telemetrytypes.FieldContextUnspecified && sel.FieldContext != telemetrytypes.FieldContextTrace {
|
||||
continue
|
||||
}
|
||||
if _, ok := filterableSet[sel.Name]; !ok {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"aggregate %q cannot be used in the trace-list filter; filterable aggregates: %s", name, strings.Join(allowed, ", "))
|
||||
"aggregate %q cannot be used in a trace-level filter; filterable aggregates: %s", sel.Name, strings.Join(sortedAliases(filterableSet), ", "))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -675,6 +670,19 @@ func orderClause(orders []listOrder) []string {
|
||||
return append(out, "trace_id DESC")
|
||||
}
|
||||
|
||||
// spanFilterSelectors are the metadata selectors for every key a filter expression
|
||||
// references, for batching into a single GetKeysMulti fetch.
|
||||
func spanFilterSelectors(expr string) []*telemetrytypes.FieldKeySelector {
|
||||
if strings.TrimSpace(expr) == "" {
|
||||
return nil
|
||||
}
|
||||
selectors := querybuilder.QueryStringToKeysSelectors(expr)
|
||||
for i := range selectors {
|
||||
selectors[i].Signal = telemetrytypes.SignalTraces
|
||||
}
|
||||
return selectors
|
||||
}
|
||||
|
||||
// quoteAlias backticks an alias containing characters special to the SQL builder.
|
||||
func quoteAlias(alias string) string {
|
||||
if strings.ContainsAny(alias, ".$`") {
|
||||
|
||||
@@ -0,0 +1,783 @@
|
||||
package scopedtracesstatementbuilder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
chparser "github.com/AfterShip/clickhouse-sql-parser/parser"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/telemetryschema/tracestelemetryschema"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/huandu/go-sqlbuilder"
|
||||
)
|
||||
|
||||
// The per-trace values these aggregations read are window-clipped and span-filtered,
|
||||
// unlike the list's enrichment pass over every span of the whole trace, so the same
|
||||
// column reads differently in each.
|
||||
|
||||
// traceAggregation is one aggregation rewritten to run over the per-trace scan.
|
||||
type traceAggregation struct {
|
||||
expr string // rewritten SQL over the per-trace column aliases
|
||||
used map[string]struct{} // per-trace aliases referenced
|
||||
isRate bool
|
||||
}
|
||||
|
||||
// buildAggregation routes by aggregation domain: bare keys delegate to the standard
|
||||
// trace builder, trace.-prefixed aggregates run over the per-trace scan.
|
||||
func (b *scopedTraceStatementBuilder) buildAggregation(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
start, end uint64,
|
||||
requestType qbtypes.RequestType,
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
) (*qbtypes.Statement, error) {
|
||||
traceAggs, err := b.classifyAggregations(query.Aggregations)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := b.validateGroupBy(query); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(traceAggs) == 0 {
|
||||
return b.buildDelegatedAggregation(ctx, orgID, start, end, requestType, query, variables)
|
||||
}
|
||||
return b.buildTraceAggregationQuery(ctx, orgID, querybuilder.ToNanoSecs(start), querybuilder.ToNanoSecs(end), requestType, query, variables, traceAggs)
|
||||
}
|
||||
|
||||
// classifyAggregations returns the rewritten trace-domain aggregations, nil when all
|
||||
// are span-domain; mixing the two domains is rejected.
|
||||
func (b *scopedTraceStatementBuilder) classifyAggregations(aggs []qbtypes.TraceAggregation) ([]traceAggregation, error) {
|
||||
// permission, not recognition: unknown names are reported against exactly this set
|
||||
traceCols := b.orderableColumnSet()
|
||||
var out []traceAggregation
|
||||
spanCount := 0
|
||||
for _, agg := range aggs {
|
||||
ta, isTrace, err := rewriteTraceAggregation(agg.Expression, traceCols)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if isTrace {
|
||||
out = append(out, *ta)
|
||||
} else {
|
||||
spanCount++
|
||||
}
|
||||
}
|
||||
if len(out) > 0 && spanCount > 0 {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"span-level and trace-level (trace.) aggregations cannot be mixed in one query")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// orderableColumnSet is what a trace-level aggregation may use;
|
||||
// recognising a key as trace-level is aggregateAliasSet's job.
|
||||
func (b *scopedTraceStatementBuilder) orderableColumnSet() map[string]struct{} {
|
||||
set := make(map[string]struct{})
|
||||
for _, c := range b.scope.Columns {
|
||||
if c.Orderable {
|
||||
set[c.Alias] = struct{}{}
|
||||
}
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
// filterableColumnSet is what a trace-level filter predicate may use.
|
||||
func (b *scopedTraceStatementBuilder) filterableColumnSet() map[string]struct{} {
|
||||
set := make(map[string]struct{})
|
||||
for _, c := range b.scope.Columns {
|
||||
if c.Filterable {
|
||||
set[c.Alias] = struct{}{}
|
||||
}
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
// validateGroupBy rejects trace-level columns as group-by keys with a targeted error
|
||||
// (not the field mapper's generic "field not found"). Order keys need no check here:
|
||||
// request validation only admits group keys and aggregation aliases/expressions.
|
||||
func (b *scopedTraceStatementBuilder) validateGroupBy(query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]) error {
|
||||
// recognition, not permission: a display-only alias must be named here to be rejected
|
||||
// rather than reaching the field mapper as a span attribute
|
||||
aliases := b.aggregateAliasSet()
|
||||
for _, gb := range query.GroupBy {
|
||||
key := gb.TelemetryFieldKey
|
||||
key.Normalize()
|
||||
// a bare name may be a span column sharing the alias (duration_nano, timestamp)
|
||||
if key.FieldContext != telemetrytypes.FieldContextTrace {
|
||||
continue
|
||||
}
|
||||
if _, ok := aliases[key.Name]; ok {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"grouping by trace-level aggregate %q is not supported; group by span attributes instead (e.g. service.name)", gb.Name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// rewriteTraceAggregation rewrites an aggregation over trace.-prefixed columns to run
|
||||
// on the per-trace scan (trace.output_tokens → output_tokens, functions mapped via
|
||||
// AggreFuncMap); a pure span-level expression returns isTrace=false for the delegate.
|
||||
func rewriteTraceAggregation(expr string, traceCols map[string]struct{}) (*traceAggregation, bool, error) {
|
||||
p := chparser.NewParser("SELECT " + expr)
|
||||
stmts, err := p.ParseStmts()
|
||||
if err != nil {
|
||||
return nil, false, errors.WrapInvalidInputf(err, errors.CodeInvalidInput, "failed to parse aggregation expression %q", expr)
|
||||
}
|
||||
if len(stmts) == 0 {
|
||||
return nil, false, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid aggregation expression %q", expr)
|
||||
}
|
||||
sel, ok := stmts[0].(*chparser.SelectQuery)
|
||||
if !ok || len(sel.SelectItems) == 0 {
|
||||
return nil, false, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid aggregation expression %q", expr)
|
||||
}
|
||||
|
||||
v := &traceAggVisitor{traceCols: traceCols, used: make(map[string]struct{})}
|
||||
if err := sel.SelectItems[0].Accept(v); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if !v.hasTrace {
|
||||
return nil, false, nil
|
||||
}
|
||||
if v.hasSpan {
|
||||
return nil, false, errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"aggregation %q mixes trace-level (trace.) and span-level columns; use one domain per aggregation", expr)
|
||||
}
|
||||
// the interval divides the rendered expression as a whole, so a second aggregation
|
||||
// alongside the rate would be divided too
|
||||
if v.isRate && v.aggCount > 1 {
|
||||
return nil, false, errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"aggregation %q combines a rate with another aggregation; the rate interval would divide both, so give each its own aggregation", expr)
|
||||
}
|
||||
return &traceAggregation{expr: chparser.Format(sel.SelectItems[0]), used: v.used, isRate: v.isRate}, true, nil
|
||||
}
|
||||
|
||||
// traceAggVisitor classifies column references and rewrites trace.-prefixed ones in
|
||||
// place; the ancestor stack tells a column identifier from a path segment, function
|
||||
// name, or alias, and rejects trace. columns inside *If combinators.
|
||||
type traceAggVisitor struct {
|
||||
chparser.DefaultASTVisitor
|
||||
traceCols map[string]struct{}
|
||||
used map[string]struct{}
|
||||
stack []chparser.Expr
|
||||
aggCount int
|
||||
hasTrace bool
|
||||
hasSpan bool
|
||||
isRate bool
|
||||
}
|
||||
|
||||
func (v *traceAggVisitor) Enter(expr chparser.Expr) { v.stack = append(v.stack, expr) }
|
||||
func (v *traceAggVisitor) Leave(expr chparser.Expr) { v.stack = v.stack[:len(v.stack)-1] }
|
||||
|
||||
// parent is the node enclosing the one currently being visited (the visited node
|
||||
// itself is the stack top).
|
||||
func (v *traceAggVisitor) parent() chparser.Expr {
|
||||
if len(v.stack) < 2 {
|
||||
return nil
|
||||
}
|
||||
return v.stack[len(v.stack)-2]
|
||||
}
|
||||
|
||||
func (v *traceAggVisitor) enclosingCombinator() (string, bool) {
|
||||
for _, e := range v.stack {
|
||||
fn, ok := e.(*chparser.FunctionExpr)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if agg, known := querybuilder.AggreFuncMap[valuer.NewString(strings.ToLower(fn.Name.Name))]; known && agg.FuncCombinator {
|
||||
return fn.Name.Name, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// enclosingAggregate walks the ancestor stack; AggreFuncMap holds only aggregates and
|
||||
// VisitFunctionExpr rejects any name missing from it, so a known name is enough.
|
||||
func (v *traceAggVisitor) enclosingAggregate() bool {
|
||||
for _, e := range v.stack {
|
||||
fn, ok := e.(*chparser.FunctionExpr)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, known := querybuilder.AggreFuncMap[valuer.NewString(strings.ToLower(fn.Name.Name))]; known {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// VisitPath classifies a dotted reference (trace.output_tokens); trace-level ones are
|
||||
// rewritten in place to the bare per-trace alias.
|
||||
func (v *traceAggVisitor) VisitPath(p *chparser.Path) error {
|
||||
col, isTrace := traceColumnFromPath(p)
|
||||
if !isTrace {
|
||||
v.hasSpan = true
|
||||
return nil
|
||||
}
|
||||
if err := v.acceptTraceColumn(chparser.Format(p), col); err != nil {
|
||||
return err
|
||||
}
|
||||
p.Fields = p.Fields[len(p.Fields)-1:]
|
||||
p.Fields[0].Name = col
|
||||
return nil
|
||||
}
|
||||
|
||||
// VisitIdent classifies a plain identifier (a backquoted `trace.output_tokens` is
|
||||
// trace-level); path segments, function names, and aliases are structural, not columns.
|
||||
func (v *traceAggVisitor) VisitIdent(i *chparser.Ident) error {
|
||||
switch parent := v.parent().(type) {
|
||||
case *chparser.Path:
|
||||
return nil // segments are classified whole by VisitPath
|
||||
case *chparser.FunctionExpr:
|
||||
if parent.Name == i {
|
||||
return nil
|
||||
}
|
||||
case *chparser.ColumnExpr:
|
||||
if parent.Alias == i {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
key := telemetrytypes.GetFieldKeyFromKeyText(i.Name)
|
||||
if key.FieldContext != telemetrytypes.FieldContextTrace || key.Name == "" {
|
||||
v.hasSpan = true
|
||||
return nil
|
||||
}
|
||||
if err := v.acceptTraceColumn(i.Name, key.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
i.Name = key.Name
|
||||
return nil
|
||||
}
|
||||
|
||||
func (v *traceAggVisitor) acceptTraceColumn(ref, col string) error {
|
||||
if name, in := v.enclosingCombinator(); in {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"%q over trace-level (trace.) columns is not supported; put the trace-level condition in the filter expression instead", name)
|
||||
}
|
||||
// trace_id is always selected by the per-trace scan (count(trace.trace_id)
|
||||
// counts traces); everything else must be a scope column.
|
||||
if col != "trace_id" {
|
||||
if _, known := v.traceCols[col]; !known {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"unknown trace-level aggregation column %q; usable columns: %s", ref, strings.Join(sortedAliases(v.traceCols), ", "))
|
||||
}
|
||||
v.used[col] = struct{}{}
|
||||
}
|
||||
// ungrouped, a bare per-trace column would make the outer SELECT emit one row per
|
||||
// trace instead of one aggregated row
|
||||
if !v.enclosingAggregate() {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"trace-level column %q must be inside an aggregation function (e.g. avg(%s))", ref, ref)
|
||||
}
|
||||
v.hasTrace = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// VisitFunctionExpr validates and maps the function name. Children were already
|
||||
// visited (post-order), so classification is complete for this subtree.
|
||||
func (v *traceAggVisitor) VisitFunctionExpr(fn *chparser.FunctionExpr) error {
|
||||
name := strings.ToLower(fn.Name.Name)
|
||||
aggFunc, ok := querybuilder.AggreFuncMap[valuer.NewString(name)]
|
||||
if !ok {
|
||||
return errors.NewInvalidInputf(errors.CodeInvalidInput, "unrecognized function: %s", name)
|
||||
}
|
||||
if fn.Params != nil && fn.Params.Items != nil && len(fn.Params.Items.Items) > 0 && aggFunc.FuncCombinator {
|
||||
// combinator predicates over span columns stay span-level (countIf(has_error=true))
|
||||
v.hasSpan = true
|
||||
return nil
|
||||
}
|
||||
fn.Name.Name = aggFunc.FuncName
|
||||
v.aggCount++
|
||||
if aggFunc.Rate {
|
||||
v.isRate = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// traceColumnFromPath returns the per-trace column a dotted reference names
|
||||
// (trace.output_tokens -> output_tokens, trace.a.b -> a.b).
|
||||
func traceColumnFromPath(p *chparser.Path) (string, bool) {
|
||||
key := telemetrytypes.GetFieldKeyFromKeyText(chparser.Format(p))
|
||||
if key.FieldContext != telemetrytypes.FieldContextTrace || key.Name == "" {
|
||||
return "", false
|
||||
}
|
||||
return key.Name, true
|
||||
}
|
||||
|
||||
func sortedAliases(set map[string]struct{}) []string {
|
||||
out := make([]string, 0, len(set))
|
||||
for a := range set {
|
||||
out = append(out, a)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Qualification + per-trace scan
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// buildQualifiedStatement selects the trace ids whose window-clipped aggregates satisfy
|
||||
// the trace-level filter. The second statement (nil without resource conditions) is the
|
||||
// __resource_filter CTE the scope's predicate references; the embedder emits it exactly
|
||||
// once, shared with its own resource filter. start/end are ns; both statements are nil
|
||||
// when variable resolution dropped every condition.
|
||||
func (b *scopedTraceStatementBuilder) buildQualifiedStatement(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
start, end uint64,
|
||||
traceExpr string,
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
) (*qbtypes.Statement, *qbtypes.Statement, error) {
|
||||
keys, err := b.fetchKeys(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
maskExpr, resolved, err := b.resolveFor(ctx, orgID, start, end, keys, sb)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
having, err := b.resolveTraceHaving(ctx, traceExpr, variables, sb)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if having == nil {
|
||||
return nil, nil, nil
|
||||
}
|
||||
// nil when the filter has no resource-attribute conditions
|
||||
resourceStmt, err := b.resourceFilterStmtBuilder.Build(ctx, orgID, start, end, qbtypes.RequestTypeRaw, query, variables)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
var resourcePred string
|
||||
if resourceStmt != nil {
|
||||
resourcePred = "resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter)"
|
||||
}
|
||||
sql, args := b.buildPerTraceScan(sb, start, end, resolved, maskExpr, perTraceScanOpts{
|
||||
needed: having.used,
|
||||
havingPred: having.pred,
|
||||
resourcePred: resourcePred,
|
||||
})
|
||||
return &qbtypes.Statement{Query: sql, Args: args}, resourceStmt, nil
|
||||
}
|
||||
|
||||
// groupColumn holds a resolved, arg-free span-attribute expression.
|
||||
type groupColumn struct {
|
||||
alias string
|
||||
expr string
|
||||
}
|
||||
|
||||
// groupByColumnAlias prefixes the i-th group-by dimension so the alias cannot shadow the
|
||||
// span column its expression reads; the querier (stripKeyAlias) strips it back off.
|
||||
func groupByColumnAlias(i int, name string) string {
|
||||
return fmt.Sprintf("__GROUP_BY_KEY_%d_%s", i, name)
|
||||
}
|
||||
|
||||
// orderColumn is the SQL identifier a non-aggregation order key sorts by: the
|
||||
// positional alias when the key names a group-by dimension, else the key itself.
|
||||
func orderColumn(orderKey string, groupBy []qbtypes.GroupByKey) string {
|
||||
for i := range groupBy {
|
||||
if groupBy[i].Name == orderKey {
|
||||
return groupByColumnAlias(i, groupBy[i].Name)
|
||||
}
|
||||
}
|
||||
return orderKey
|
||||
}
|
||||
|
||||
// perTraceScanOpts parametrize one windowed, mask-pruned GROUP BY trace_id scan.
|
||||
// All expressions are already resolved against the scan's builder.
|
||||
type perTraceScanOpts struct {
|
||||
stepSeconds int64 // >0 → bucket per-trace values by time (ts column)
|
||||
groupCols []groupColumn
|
||||
needed map[string]struct{} // per-trace aliases to select
|
||||
spanPred string // resolved span-level filter, ANDed per span
|
||||
resourcePred string // resource-fingerprint prune (CTE reference or inline subquery)
|
||||
qualified bool // constrain to __qualified
|
||||
limitPred string // top-N group prune (GLOBAL IN __limit_cte)
|
||||
havingPred string // resolved HAVING predicate over the selected aliases
|
||||
}
|
||||
|
||||
func (b *scopedTraceStatementBuilder) buildPerTraceScan(sb *sqlbuilder.SelectBuilder, start, end uint64, resolved []resolvedColumn, maskExpr string, o perTraceScanOpts) (string, []any) {
|
||||
startBucket := start/querybuilder.NsToSeconds - querybuilder.BucketAdjustment
|
||||
endBucket := end / querybuilder.NsToSeconds
|
||||
|
||||
selects := []string{"trace_id"}
|
||||
if o.stepSeconds > 0 {
|
||||
selects = append(selects, fmt.Sprintf("toStartOfInterval(timestamp, INTERVAL %d SECOND) AS ts", o.stepSeconds))
|
||||
}
|
||||
for _, gc := range o.groupCols {
|
||||
selects = append(selects, fmt.Sprintf("toString(%s) AS `%s`", gc.expr, gc.alias))
|
||||
}
|
||||
for _, rc := range resolved {
|
||||
if _, ok := o.needed[rc.alias]; !ok {
|
||||
continue
|
||||
}
|
||||
selects = append(selects, rc.expr+" AS "+quoteAlias(rc.alias))
|
||||
}
|
||||
sb.Select(selects...)
|
||||
sb.From(fmt.Sprintf("%s.%s", tracestelemetryschema.DBName, tracestelemetryschema.SpanIndexV3TableName))
|
||||
|
||||
where := []string{
|
||||
sb.GE("timestamp", fmt.Sprintf("%d", start)),
|
||||
sb.L("timestamp", fmt.Sprintf("%d", end)),
|
||||
sb.GE("ts_bucket_start", startBucket),
|
||||
sb.LE("ts_bucket_start", endBucket),
|
||||
maskExpr,
|
||||
}
|
||||
if strings.TrimSpace(o.spanPred) != "" {
|
||||
where = append(where, o.spanPred)
|
||||
}
|
||||
if o.resourcePred != "" {
|
||||
where = append(where, o.resourcePred)
|
||||
}
|
||||
if o.qualified {
|
||||
where = append(where, "trace_id GLOBAL IN (SELECT trace_id FROM __qualified)")
|
||||
}
|
||||
if o.limitPred != "" {
|
||||
where = append(where, o.limitPred)
|
||||
}
|
||||
sb.Where(where...)
|
||||
|
||||
groupBy := []string{"trace_id"}
|
||||
if o.stepSeconds > 0 {
|
||||
groupBy = append(groupBy, "ts")
|
||||
}
|
||||
for _, gc := range o.groupCols {
|
||||
groupBy = append(groupBy, "`"+gc.alias+"`")
|
||||
}
|
||||
sb.GroupBy(groupBy...)
|
||||
if strings.TrimSpace(o.havingPred) != "" {
|
||||
sb.Having(o.havingPred)
|
||||
}
|
||||
return sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
}
|
||||
|
||||
// groupBySelectors are the metadata selectors for the group-by keys, for batching
|
||||
// into a single GetKeysMulti fetch.
|
||||
func groupBySelectors(groupBy []qbtypes.GroupByKey) []*telemetrytypes.FieldKeySelector {
|
||||
selectors := make([]*telemetrytypes.FieldKeySelector, 0, len(groupBy))
|
||||
for i := range groupBy {
|
||||
selectors = append(selectors, &telemetrytypes.FieldKeySelector{
|
||||
Name: groupBy[i].Name,
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: groupBy[i].FieldContext,
|
||||
FieldDataType: groupBy[i].FieldDataType,
|
||||
SelectorMatchType: telemetrytypes.FieldSelectorMatchTypeExact,
|
||||
})
|
||||
}
|
||||
return selectors
|
||||
}
|
||||
|
||||
// resolveGroupColumns resolves group-by keys through the field mapper for selection
|
||||
// inside the per-trace scan; keys must cover the group-by selectors.
|
||||
func (b *scopedTraceStatementBuilder) resolveGroupColumns(ctx context.Context, orgID valuer.UUID, start, end uint64, groupBy []qbtypes.GroupByKey, keys map[string][]*telemetrytypes.TelemetryFieldKey) ([]groupColumn, error) {
|
||||
if len(groupBy) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
out := make([]groupColumn, 0, len(groupBy))
|
||||
for i := range groupBy {
|
||||
expr, err := b.fm.ColumnExpressionFor(ctx, orgID, start, end, &groupBy[i].TelemetryFieldKey, telemetrytypes.FieldDataTypeString, keys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, groupColumn{alias: groupByColumnAlias(i, groupBy[i].Name), expr: sqlbuilder.Escape(expr)})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Native trace-domain aggregation query
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// scanContext is one per-scan resolution: a fresh builder with the mask, columns,
|
||||
// span predicate, and optionally the trace-level HAVING resolved against it.
|
||||
type scanContext struct {
|
||||
sb *sqlbuilder.SelectBuilder
|
||||
maskExpr string
|
||||
resolved []resolvedColumn
|
||||
spanPred string
|
||||
having *traceHaving
|
||||
warnings []string
|
||||
warnURL string
|
||||
}
|
||||
|
||||
func (b *scopedTraceStatementBuilder) newScanContext(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
start, end uint64,
|
||||
keys map[string][]*telemetrytypes.TelemetryFieldKey,
|
||||
spanExpr, traceExpr string,
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
) (*scanContext, error) {
|
||||
sc := &scanContext{sb: sqlbuilder.NewSelectBuilder()}
|
||||
var err error
|
||||
sc.maskExpr, sc.resolved, err = b.resolveFor(ctx, orgID, start, end, keys, sc.sb)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(spanExpr) != "" {
|
||||
pred, warns, url, err := b.resolveSpanPredicate(ctx, orgID, start, end, spanExpr, keys, variables, sc.sb)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sc.spanPred, sc.warnings, sc.warnURL = pred, warns, url
|
||||
}
|
||||
if strings.TrimSpace(traceExpr) != "" {
|
||||
sc.having, err = b.resolveTraceHaving(ctx, traceExpr, variables, sc.sb)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return sc, nil
|
||||
}
|
||||
|
||||
// buildTraceAggregationQuery aggregates over the per-trace scan: __qualified (when the
|
||||
// filter has a trace-level part) → __scoped_traces → outer aggregation. start/end are ns.
|
||||
func (b *scopedTraceStatementBuilder) buildTraceAggregationQuery(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
start, end uint64,
|
||||
requestType qbtypes.RequestType,
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
traceAggs []traceAggregation,
|
||||
) (*qbtypes.Statement, error) {
|
||||
var spanExpr, traceExpr string
|
||||
var err error
|
||||
if query.Filter != nil && strings.TrimSpace(query.Filter.Expression) != "" {
|
||||
// the broad set so a condition on a display-only alias still lands in the
|
||||
// trace-level part, where resolveTraceHaving rejects it by name
|
||||
spanExpr, traceExpr, err = querybuilder.SplitFilterForAggregates(query.Filter.Expression, b.aggregateAliasSet())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
keys, err := b.fetchKeys(ctx, orgID, append(spanFilterSelectors(spanExpr), groupBySelectors(query.GroupBy)...)...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resourceFrag, resourceArgs, resourcePred, err := b.maybeAttachResourceFilter(ctx, orgID, query, start, end, variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var cteFragments []string
|
||||
var cteArgs [][]any
|
||||
if resourceFrag != "" {
|
||||
cteFragments = append(cteFragments, resourceFrag)
|
||||
cteArgs = append(cteArgs, resourceArgs)
|
||||
}
|
||||
|
||||
// __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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if qsc.having != nil {
|
||||
qsql, qargs := b.buildPerTraceScan(qsc.sb, start, end, qsc.resolved, qsc.maskExpr, perTraceScanOpts{
|
||||
needed: qsc.having.used,
|
||||
havingPred: qsc.having.pred,
|
||||
resourcePred: resourcePred,
|
||||
})
|
||||
cteFragments = append(cteFragments, fmt.Sprintf("__qualified AS (%s)", qsql))
|
||||
cteArgs = append(cteArgs, qargs)
|
||||
qualified = true
|
||||
}
|
||||
}
|
||||
|
||||
groupCols, err := b.resolveGroupColumns(ctx, orgID, start, end, query.GroupBy, keys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
groupNames := make([]string, 0, len(groupCols))
|
||||
for _, gc := range groupCols {
|
||||
groupNames = append(groupNames, "`"+gc.alias+"`")
|
||||
}
|
||||
|
||||
needed := make(map[string]struct{})
|
||||
for _, ta := range traceAggs {
|
||||
for a := range ta.used {
|
||||
needed[a] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// a window or step under one second would truncate to a zero divisor
|
||||
windowSeconds := max((end-start)/querybuilder.NsToSeconds, 1)
|
||||
stepSeconds := int64(0)
|
||||
rateInterval := windowSeconds
|
||||
if requestType == qbtypes.RequestTypeTimeSeries {
|
||||
stepSeconds = int64(query.StepInterval.Seconds())
|
||||
rateInterval = max(uint64(stepSeconds), 1)
|
||||
}
|
||||
|
||||
// outer aggregation over the per-trace rows
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
selects := []string{}
|
||||
if stepSeconds > 0 {
|
||||
selects = append(selects, "ts")
|
||||
}
|
||||
selects = append(selects, groupNames...)
|
||||
for i, ta := range traceAggs {
|
||||
selects = append(selects, fmt.Sprintf("%s AS __result_%d", ta.rendered(rateInterval), i))
|
||||
}
|
||||
sb.Select(selects...)
|
||||
sb.From("__scoped_traces")
|
||||
|
||||
// grouped, limited time series → rank groups on whole-window per-trace values
|
||||
// (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)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
totalSQL, totalArgs := b.buildPerTraceScan(tsc.sb, start, end, tsc.resolved, tsc.maskExpr, perTraceScanOpts{
|
||||
groupCols: groupCols,
|
||||
needed: needed,
|
||||
spanPred: tsc.spanPred,
|
||||
resourcePred: resourcePred,
|
||||
qualified: qualified,
|
||||
})
|
||||
cteFragments = append(cteFragments, fmt.Sprintf("__scoped_traces_total AS (%s)", totalSQL))
|
||||
cteArgs = append(cteArgs, totalArgs)
|
||||
|
||||
limitSQL, limitArgs := outerLimitSQL(query, traceAggs, groupNames, windowSeconds)
|
||||
cteFragments = append(cteFragments, fmt.Sprintf("__limit_cte AS (%s)", limitSQL))
|
||||
cteArgs = append(cteArgs, limitArgs)
|
||||
|
||||
exprs := make([]string, 0, len(groupCols))
|
||||
for _, gc := range groupCols {
|
||||
exprs = append(exprs, "toString("+gc.expr+")")
|
||||
}
|
||||
limitPred = fmt.Sprintf("(%s) GLOBAL IN (SELECT %s FROM __limit_cte)",
|
||||
strings.Join(exprs, ", "), strings.Join(groupNames, ", "))
|
||||
}
|
||||
|
||||
msc, err := b.newScanContext(ctx, orgID, start, end, keys, spanExpr, "", variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
perTraceSQL, perTraceArgs := b.buildPerTraceScan(msc.sb, start, end, msc.resolved, msc.maskExpr, perTraceScanOpts{
|
||||
stepSeconds: stepSeconds,
|
||||
groupCols: groupCols,
|
||||
needed: needed,
|
||||
spanPred: msc.spanPred,
|
||||
resourcePred: resourcePred,
|
||||
qualified: qualified,
|
||||
limitPred: limitPred,
|
||||
})
|
||||
cteFragments = append(cteFragments, fmt.Sprintf("__scoped_traces AS (%s)", perTraceSQL))
|
||||
cteArgs = append(cteArgs, perTraceArgs)
|
||||
|
||||
groupBys := []string{}
|
||||
if stepSeconds > 0 {
|
||||
groupBys = append(groupBys, "ts")
|
||||
}
|
||||
groupBys = append(groupBys, groupNames...)
|
||||
if len(groupBys) > 0 {
|
||||
sb.GroupBy(groupBys...)
|
||||
}
|
||||
|
||||
if query.Having != nil && strings.TrimSpace(query.Having.Expression) != "" {
|
||||
rewritten, err := querybuilder.NewHavingExpressionRewriter().RewriteForTraces(query.Having.Expression, query.Aggregations)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sb.Having(sqlbuilder.Escape(rewritten))
|
||||
}
|
||||
|
||||
if requestType == qbtypes.RequestTypeTimeSeries {
|
||||
if len(query.Order) != 0 {
|
||||
for _, orderBy := range query.Order {
|
||||
if _, ok := traceAggOrderIndex(orderBy, query); !ok {
|
||||
sb.OrderBy(fmt.Sprintf("`%s` %s", orderColumn(orderBy.Key.Name, query.GroupBy), orderBy.Direction.StringValue()))
|
||||
}
|
||||
}
|
||||
sb.OrderBy("ts desc")
|
||||
}
|
||||
} else {
|
||||
for _, orderBy := range query.Order {
|
||||
if idx, ok := traceAggOrderIndex(orderBy, query); ok {
|
||||
sb.OrderBy(fmt.Sprintf("__result_%d %s", idx, orderBy.Direction.StringValue()))
|
||||
} else {
|
||||
sb.OrderBy(fmt.Sprintf("`%s` %s", orderColumn(orderBy.Key.Name, query.GroupBy), orderBy.Direction.StringValue()))
|
||||
}
|
||||
}
|
||||
if len(query.Order) == 0 {
|
||||
sb.OrderBy("__result_0 DESC")
|
||||
}
|
||||
if query.Limit > 0 {
|
||||
sb.Limit(query.Limit)
|
||||
}
|
||||
}
|
||||
|
||||
mainSQL, mainArgs := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
finalSQL := querybuilder.CombineCTEs(cteFragments) + mainSQL + " SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000"
|
||||
finalArgs := querybuilder.PrependArgs(cteArgs, mainArgs)
|
||||
|
||||
return &qbtypes.Statement{
|
||||
Query: finalSQL,
|
||||
Args: finalArgs,
|
||||
Warnings: msc.warnings,
|
||||
WarningsDocURL: msc.warnURL,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// rendered divides a rate aggregation by the interval (step for time series, window
|
||||
// length for scalar); the divisor applies to the whole expression, which holds only
|
||||
// because a rate must be the sole aggregation.
|
||||
func (ta traceAggregation) rendered(rateInterval uint64) string {
|
||||
if ta.isRate {
|
||||
return fmt.Sprintf("%s/%d", ta.expr, rateInterval)
|
||||
}
|
||||
return ta.expr
|
||||
}
|
||||
|
||||
// outerLimitSQL ranks groups on whole-window per-trace values, so a non-composable
|
||||
// aggregate (avg) ranks exactly rather than over bucketed rows.
|
||||
func outerLimitSQL(query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation], traceAggs []traceAggregation, groupNames []string, windowSeconds uint64) (string, []any) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
selects := append([]string{}, groupNames...)
|
||||
for i, ta := range traceAggs {
|
||||
selects = append(selects, fmt.Sprintf("%s AS __result_%d", ta.rendered(windowSeconds), i))
|
||||
}
|
||||
sb.Select(selects...)
|
||||
sb.From("__scoped_traces_total")
|
||||
sb.GroupBy(groupNames...)
|
||||
for _, orderBy := range query.Order {
|
||||
if idx, ok := traceAggOrderIndex(orderBy, query); ok {
|
||||
sb.OrderBy(fmt.Sprintf("__result_%d %s", idx, orderBy.Direction.StringValue()))
|
||||
} else {
|
||||
sb.OrderBy(fmt.Sprintf("`%s` %s", orderColumn(orderBy.Key.Name, query.GroupBy), orderBy.Direction.StringValue()))
|
||||
}
|
||||
}
|
||||
if len(query.Order) == 0 {
|
||||
sb.OrderBy("__result_0 DESC")
|
||||
}
|
||||
sb.Limit(query.Limit)
|
||||
return sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
}
|
||||
|
||||
// traceAggOrderIndex reports whether an order key refers to the i-th aggregation
|
||||
// (by alias, expression, or index), mirroring the trace builder.
|
||||
func traceAggOrderIndex(k qbtypes.OrderBy, q qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]) (int, bool) {
|
||||
for i, agg := range q.Aggregations {
|
||||
if k.Key.Name == agg.Alias ||
|
||||
k.Key.Name == agg.Expression ||
|
||||
k.Key.Name == fmt.Sprintf("%d", i) {
|
||||
return i, true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package scopedtracesstatementbuilder
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestRewriteTraceAggregation(t *testing.T) {
|
||||
cols := map[string]struct{}{
|
||||
"input_tokens": {}, "output_tokens": {}, "total_tokens": {}, "llm_call_count": {}, "max_llm_latency_ns": {},
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
expr string
|
||||
isTrace bool
|
||||
want string // rewritten expr, only checked when isTrace
|
||||
used []string
|
||||
wantErr string
|
||||
}{
|
||||
{name: "avg trace col", expr: "avg(trace.output_tokens)", isTrace: true, want: "avg(output_tokens)", used: []string{"output_tokens"}},
|
||||
{name: "sum trace col", expr: "sum(trace.total_tokens)", isTrace: true, want: "sum(total_tokens)", used: []string{"total_tokens"}},
|
||||
{name: "count traces", expr: "count(trace.trace_id)", isTrace: true, want: "count(trace_id)"},
|
||||
{name: "p90 trace col", expr: "p90(trace.max_llm_latency_ns)", isTrace: true, want: "quantile(0.90)(max_llm_latency_ns)", used: []string{"max_llm_latency_ns"}},
|
||||
{name: "arithmetic between trace cols", expr: "avg(trace.output_tokens + trace.input_tokens)", isTrace: true, want: "avg(output_tokens + input_tokens)", used: []string{"output_tokens", "input_tokens"}},
|
||||
{name: "arithmetic with constant", expr: "sum(trace.output_tokens * 1.5)", isTrace: true, want: "sum(output_tokens * 1.5)", used: []string{"output_tokens"}},
|
||||
{name: "ratio of two aggregations", expr: "sum(trace.output_tokens)/count(trace.trace_id)", isTrace: true, want: "sum(output_tokens) / count(trace_id)", used: []string{"output_tokens"}},
|
||||
{name: "backquoted trace col", expr: "avg(`trace.output_tokens`)", isTrace: true, want: "avg(`output_tokens`)", used: []string{"output_tokens"}},
|
||||
{name: "bare count is span-level", expr: "count()", isTrace: false},
|
||||
{name: "span attribute is span-level", expr: "sum(gen_ai.usage.output_tokens)", isTrace: false},
|
||||
{name: "countIf span predicate is span-level", expr: "countIf(has_error = true)", isTrace: false},
|
||||
{name: "mixed domains in one expression", expr: "sum(trace.output_tokens) + sum(gen_ai.usage.input_tokens)", wantErr: "mixes trace-level"},
|
||||
{name: "mixed domains in one function", expr: "sum(trace.output_tokens + gen_ai.usage.input_tokens)", wantErr: "mixes trace-level"},
|
||||
{name: "output-only column rejected", expr: "avg(trace.span_count)", wantErr: "unknown trace-level aggregation column"},
|
||||
{name: "unknown column rejected", expr: "avg(trace.bogus)", wantErr: "unknown trace-level aggregation column"},
|
||||
// a dotted column keeps every segment after the prefix, so it is reported whole
|
||||
{name: "multi segment column rejected by full name", expr: "avg(trace.service.name)", wantErr: `"trace.service.name"`},
|
||||
{name: "bare trace identifier is span-level", expr: "avg(trace)", isTrace: false},
|
||||
{name: "countIf over trace col rejected", expr: "countIf(trace.output_tokens > 1000)", wantErr: "not supported"},
|
||||
{name: "bare trace col rejected", expr: "trace.output_tokens", wantErr: "must be inside an aggregation function"},
|
||||
{name: "backquoted bare trace col rejected", expr: "`trace.output_tokens`", wantErr: "must be inside an aggregation function"},
|
||||
{name: "bare trace_id rejected", expr: "trace.trace_id", wantErr: "must be inside an aggregation function"},
|
||||
{name: "arithmetic outside an aggregation rejected", expr: "trace.output_tokens + trace.input_tokens", wantErr: "must be inside an aggregation function"},
|
||||
{name: "trace col beside an aggregation rejected", expr: "sum(trace.output_tokens) + trace.input_tokens", wantErr: "must be inside an aggregation function"},
|
||||
{name: "aggregation scaled by a constant", expr: "sum(trace.output_tokens) * 2", isTrace: true, want: "sum(output_tokens) * 2", used: []string{"output_tokens"}},
|
||||
{name: "rate over traces", expr: "rate(trace.trace_id)", isTrace: true, want: "count(trace_id)"},
|
||||
{name: "rate_sum trace col", expr: "rate_sum(trace.output_tokens)", isTrace: true, want: "sum(output_tokens)", used: []string{"output_tokens"}},
|
||||
// the interval divides the whole rendered expression, so a second aggregation
|
||||
// alongside a rate would be divided too
|
||||
{name: "rate mixed with another aggregation rejected", expr: "rate(trace.trace_id) + avg(trace.output_tokens)", wantErr: "combines a rate with another aggregation"},
|
||||
{name: "ratio of two rates rejected", expr: "rate_sum(trace.output_tokens)/rate_sum(trace.input_tokens)", wantErr: "combines a rate with another aggregation"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ta, isTrace, err := rewriteTraceAggregation(tc.expr, cols)
|
||||
if tc.wantErr != "" {
|
||||
require.ErrorContains(t, err, tc.wantErr)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tc.isTrace, isTrace)
|
||||
if !tc.isTrace {
|
||||
return
|
||||
}
|
||||
assert.Equal(t, tc.want, ta.expr)
|
||||
for _, u := range tc.used {
|
||||
assert.Contains(t, ta.used, u)
|
||||
}
|
||||
assert.Len(t, ta.used, len(tc.used))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package scopedtracesstatementbuilder
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
qbvariables "github.com/SigNoz/signoz/pkg/variables"
|
||||
"github.com/huandu/go-sqlbuilder"
|
||||
)
|
||||
|
||||
// traceHaving is the resolved trace-level filter part: a HAVING predicate over the
|
||||
// per-trace aliases plus the aliases it references (so scans select only those).
|
||||
type traceHaving struct {
|
||||
pred string
|
||||
used map[string]struct{}
|
||||
}
|
||||
|
||||
// resolveTraceHaving runs a trace-level filter through the standard where-clause
|
||||
// pipeline against the per-trace aliases, so operators, bound args, and __all__ behave
|
||||
// as in span filters. Returns nil when nothing is left to filter; args bind into sb.
|
||||
func (b *scopedTraceStatementBuilder) resolveTraceHaving(ctx context.Context, expr string, variables map[string]qbtypes.VariableItem, sb *sqlbuilder.SelectBuilder) (*traceHaving, error) {
|
||||
if strings.TrimSpace(expr) == "" {
|
||||
return nil, nil //nolint:nilnil
|
||||
}
|
||||
// replaced before validation so variable literals are not mistaken for aggregate
|
||||
// names; an unresolved $var is left in place and fails validation as an unknown one
|
||||
if len(variables) > 0 {
|
||||
replaced, err := qbvariables.ReplaceVariablesInExpression(expr, variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
expr = replaced
|
||||
if strings.TrimSpace(expr) == "" {
|
||||
return nil, nil //nolint:nilnil
|
||||
}
|
||||
}
|
||||
allowed := b.filterableColumnSet()
|
||||
// upfront targeted errors; the visitor folds them into a combined "Found N errors"
|
||||
if err := validateAggregateFilter(expr, allowed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// both spellings resolve here: the key parser strips the trace. prefix into
|
||||
// FieldContextTrace, which matches this entry's context
|
||||
fieldKeys := make(map[string][]*telemetrytypes.TelemetryFieldKey, len(allowed))
|
||||
for alias := range allowed {
|
||||
key := &telemetrytypes.TelemetryFieldKey{Name: alias, FieldContext: telemetrytypes.FieldContextTrace}
|
||||
fieldKeys[alias] = []*telemetrytypes.TelemetryFieldKey{key}
|
||||
}
|
||||
|
||||
cb := &aliasConditionBuilder{allowed: allowed, used: make(map[string]struct{})}
|
||||
prepared, err := querybuilder.PrepareWhereClause(expr, querybuilder.FilterExprVisitorOpts{
|
||||
Context: ctx,
|
||||
Logger: b.logger,
|
||||
ConditionBuilder: cb,
|
||||
FieldKeys: fieldKeys,
|
||||
Variables: variables,
|
||||
Builder: sb,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if prepared.IsEmpty() {
|
||||
return nil, nil //nolint:nilnil
|
||||
}
|
||||
return &traceHaving{pred: prepared.Expr, used: cb.used}, nil
|
||||
}
|
||||
|
||||
// aliasConditionBuilder renders filter conditions directly against the per-trace
|
||||
// aliases, recording the ones it touches; a key resolving to no alias is an error.
|
||||
type aliasConditionBuilder struct {
|
||||
allowed map[string]struct{}
|
||||
used map[string]struct{}
|
||||
}
|
||||
|
||||
var _ qbtypes.ConditionBuilder = (*aliasConditionBuilder)(nil)
|
||||
|
||||
func (c *aliasConditionBuilder) ConditionFor(
|
||||
_ context.Context,
|
||||
_ valuer.UUID,
|
||||
_, _ uint64,
|
||||
key *telemetrytypes.TelemetryFieldKey,
|
||||
keys map[string][]*telemetrytypes.TelemetryFieldKey,
|
||||
_ qbtypes.ConditionBuilderOptions,
|
||||
op qbtypes.FilterOperator,
|
||||
value any,
|
||||
sb *sqlbuilder.SelectBuilder,
|
||||
) ([]string, []string, error) {
|
||||
matching := keys[key.Name]
|
||||
if len(matching) == 0 {
|
||||
return nil, nil, errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"aggregate %q cannot be used in a trace-level filter; filterable aggregates: %s",
|
||||
key.Name, strings.Join(sortedAliases(c.allowed), ", "))
|
||||
}
|
||||
alias := matching[0].Name
|
||||
c.used[alias] = struct{}{}
|
||||
col := quoteAlias(alias)
|
||||
|
||||
var cond string
|
||||
switch op {
|
||||
case qbtypes.FilterOperatorEqual:
|
||||
cond = sb.E(col, value)
|
||||
case qbtypes.FilterOperatorNotEqual:
|
||||
cond = sb.NE(col, value)
|
||||
case qbtypes.FilterOperatorGreaterThan:
|
||||
cond = sb.G(col, value)
|
||||
case qbtypes.FilterOperatorGreaterThanOrEq:
|
||||
cond = sb.GE(col, value)
|
||||
case qbtypes.FilterOperatorLessThan:
|
||||
cond = sb.L(col, value)
|
||||
case qbtypes.FilterOperatorLessThanOrEq:
|
||||
cond = sb.LE(col, value)
|
||||
case qbtypes.FilterOperatorIn, qbtypes.FilterOperatorNotIn:
|
||||
values, ok := value.([]any)
|
||||
if !ok {
|
||||
values = []any{value}
|
||||
}
|
||||
if op == qbtypes.FilterOperatorIn {
|
||||
cond = sb.In(col, values...)
|
||||
} else {
|
||||
cond = sb.NotIn(col, values...)
|
||||
}
|
||||
case qbtypes.FilterOperatorBetween, qbtypes.FilterOperatorNotBetween:
|
||||
values, ok := value.([]any)
|
||||
if !ok || len(values) != 2 {
|
||||
return nil, nil, errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"between on trace-level aggregate %q requires exactly two values", alias)
|
||||
}
|
||||
if op == qbtypes.FilterOperatorBetween {
|
||||
cond = sb.Between(col, values[0], values[1])
|
||||
} else {
|
||||
cond = sb.NotBetween(col, values[0], values[1])
|
||||
}
|
||||
default:
|
||||
return nil, nil, errors.NewInvalidInputf(errors.CodeInvalidInput,
|
||||
"trace-level aggregate %q supports only comparison operators (=, !=, <, <=, >, >=, in, between)", alias)
|
||||
}
|
||||
return []string{cond}, nil, nil
|
||||
}
|
||||
@@ -33,6 +33,12 @@ type traceQueryStatementBuilder struct {
|
||||
aggExprRewriter qbtypes.AggExprRewriter
|
||||
fl flagger.Flagger
|
||||
skipResourceFingerprintEnabled bool
|
||||
// traceScope, set only on the per-call copy made by BuildTraceScoped, constrains
|
||||
// queries to spans whose trace_id is in the __trace_scope CTE.
|
||||
traceScope *qbtypes.Statement
|
||||
// traceScopeResource is the __resource_filter CTE traceScope's predicate references,
|
||||
// emitted only when this builder's own resource filter did not already emit it.
|
||||
traceScopeResource *qbtypes.Statement
|
||||
}
|
||||
|
||||
var _ qbtypes.StatementBuilder[qbtypes.TraceAggregation] = (*traceQueryStatementBuilder)(nil)
|
||||
@@ -97,6 +103,41 @@ func NewTraceQueryStatementBuilder(
|
||||
}
|
||||
}
|
||||
|
||||
// BuildTraceScoped is Build constrained to trace_ids selected by traceScope; the
|
||||
// receiver is copied so the shared builder stays stateless.
|
||||
func (b *traceQueryStatementBuilder) BuildTraceScoped(
|
||||
ctx context.Context,
|
||||
orgID valuer.UUID,
|
||||
start uint64,
|
||||
end uint64,
|
||||
requestType qbtypes.RequestType,
|
||||
query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation],
|
||||
variables map[string]qbtypes.VariableItem,
|
||||
traceScope, traceScopeResource *qbtypes.Statement,
|
||||
) (*qbtypes.Statement, error) {
|
||||
scoped := *b
|
||||
scoped.traceScope = traceScope
|
||||
scoped.traceScopeResource = traceScopeResource
|
||||
return scoped.Build(ctx, orgID, start, end, requestType, query, variables)
|
||||
}
|
||||
|
||||
// attachTraceScope adds the trace-scope condition to sb and returns the CTE fragments
|
||||
// + args to prepend; resourceEmitted reports whether the query already carries the
|
||||
// __resource_filter CTE, so the scope's copy is emitted only when it does not.
|
||||
func (b *traceQueryStatementBuilder) attachTraceScope(sb *sqlbuilder.SelectBuilder, resourceEmitted bool) ([]string, [][]any) {
|
||||
if b.traceScope == nil {
|
||||
return nil, nil
|
||||
}
|
||||
sb.Where("trace_id GLOBAL IN (SELECT trace_id FROM __trace_scope)")
|
||||
var frags []string
|
||||
var args [][]any
|
||||
if b.traceScopeResource != nil && !resourceEmitted {
|
||||
frags = append(frags, fmt.Sprintf("__resource_filter AS (%s)", b.traceScopeResource.Query))
|
||||
args = append(args, b.traceScopeResource.Args)
|
||||
}
|
||||
return append(frags, fmt.Sprintf("__trace_scope AS (%s)", b.traceScope.Query)), append(args, b.traceScope.Args)
|
||||
}
|
||||
|
||||
// Build builds a SQL query for traces based on the given parameters.
|
||||
func (b *traceQueryStatementBuilder) Build(
|
||||
ctx context.Context,
|
||||
@@ -163,31 +204,15 @@ func getKeySelectors(query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation])
|
||||
}
|
||||
|
||||
for idx := range query.GroupBy {
|
||||
groupBy := query.GroupBy[idx]
|
||||
keySelectors = append(keySelectors, &telemetrytypes.FieldKeySelector{
|
||||
Name: groupBy.Name,
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: groupBy.FieldContext,
|
||||
FieldDataType: groupBy.FieldDataType,
|
||||
})
|
||||
keySelectors = append(keySelectors, keySelectorsForField(query.GroupBy[idx].TelemetryFieldKey)...)
|
||||
}
|
||||
|
||||
for idx := range query.SelectFields {
|
||||
keySelectors = append(keySelectors, &telemetrytypes.FieldKeySelector{
|
||||
Name: query.SelectFields[idx].Name,
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: query.SelectFields[idx].FieldContext,
|
||||
FieldDataType: query.SelectFields[idx].FieldDataType,
|
||||
})
|
||||
keySelectors = append(keySelectors, keySelectorsForField(query.SelectFields[idx])...)
|
||||
}
|
||||
|
||||
for idx := range query.Order {
|
||||
keySelectors = append(keySelectors, &telemetrytypes.FieldKeySelector{
|
||||
Name: query.Order[idx].Key.Name,
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: query.Order[idx].Key.FieldContext,
|
||||
FieldDataType: query.Order[idx].Key.FieldDataType,
|
||||
})
|
||||
keySelectors = append(keySelectors, keySelectorsForField(query.Order[idx].Key.TelemetryFieldKey)...)
|
||||
}
|
||||
|
||||
for idx := range keySelectors {
|
||||
@@ -198,6 +223,26 @@ func getKeySelectors(query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation])
|
||||
return keySelectors
|
||||
}
|
||||
|
||||
func keySelectorsForField(key telemetrytypes.TelemetryFieldKey) []*telemetrytypes.FieldKeySelector {
|
||||
selectors := []*telemetrytypes.FieldKeySelector{
|
||||
{
|
||||
Name: key.Name,
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: key.FieldContext,
|
||||
FieldDataType: key.FieldDataType,
|
||||
},
|
||||
}
|
||||
if key.FieldContext != telemetrytypes.FieldContextUnspecified {
|
||||
selectors = append(selectors, &telemetrytypes.FieldKeySelector{
|
||||
Name: key.FieldContext.StringValue() + "." + key.Name,
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextUnspecified,
|
||||
FieldDataType: key.FieldDataType,
|
||||
})
|
||||
}
|
||||
return selectors
|
||||
}
|
||||
|
||||
// mergeDeprecatedTraceKeys prepends deprecated intrinsic/calculated trace field
|
||||
// definitions to the keys map. We do this during statement building, not at
|
||||
// metadata fetch time, because:
|
||||
@@ -269,20 +314,14 @@ func adjustTraceKey(key *telemetrytypes.TelemetryFieldKey, keys map[string][]*te
|
||||
|
||||
For example: trace_id (intrinsic), response_status_code (calculated).
|
||||
*/
|
||||
// Resolve against the context-qualified name first, then the bare name since that can be instrinsic field e.g. scope.name.
|
||||
var isIntrinsicOrCalculatedField bool
|
||||
var intrinsicOrCalculatedField telemetrytypes.TelemetryFieldKey
|
||||
if _, ok := tracestelemetryschema.IntrinsicFields[key.Name]; ok {
|
||||
isIntrinsicOrCalculatedField = true
|
||||
intrinsicOrCalculatedField = tracestelemetryschema.IntrinsicFields[key.Name]
|
||||
} else if _, ok := tracestelemetryschema.CalculatedFields[key.Name]; ok {
|
||||
isIntrinsicOrCalculatedField = true
|
||||
intrinsicOrCalculatedField = tracestelemetryschema.CalculatedFields[key.Name]
|
||||
} else if _, ok := tracestelemetryschema.IntrinsicFieldsDeprecated[key.Name]; ok {
|
||||
isIntrinsicOrCalculatedField = true
|
||||
intrinsicOrCalculatedField = tracestelemetryschema.IntrinsicFieldsDeprecated[key.Name]
|
||||
} else if _, ok := tracestelemetryschema.CalculatedFieldsDeprecated[key.Name]; ok {
|
||||
isIntrinsicOrCalculatedField = true
|
||||
intrinsicOrCalculatedField = tracestelemetryschema.CalculatedFieldsDeprecated[key.Name]
|
||||
if key.FieldContext != telemetrytypes.FieldContextUnspecified {
|
||||
intrinsicOrCalculatedField, isIntrinsicOrCalculatedField = lookupIntrinsicOrCalculatedField(key.FieldContext.StringValue() + "." + key.Name)
|
||||
}
|
||||
if !isIntrinsicOrCalculatedField {
|
||||
intrinsicOrCalculatedField, isIntrinsicOrCalculatedField = lookupIntrinsicOrCalculatedField(key.Name)
|
||||
}
|
||||
|
||||
if isIntrinsicOrCalculatedField {
|
||||
@@ -294,6 +333,24 @@ func adjustTraceKey(key *telemetrytypes.TelemetryFieldKey, keys map[string][]*te
|
||||
return actions
|
||||
}
|
||||
|
||||
// lookupIntrinsicOrCalculatedField returns the intrinsic or calculated field registered under
|
||||
// name, across the current and deprecated tables.
|
||||
func lookupIntrinsicOrCalculatedField(name string) (telemetrytypes.TelemetryFieldKey, bool) {
|
||||
if f, ok := tracestelemetryschema.IntrinsicFields[name]; ok {
|
||||
return f, true
|
||||
}
|
||||
if f, ok := tracestelemetryschema.CalculatedFields[name]; ok {
|
||||
return f, true
|
||||
}
|
||||
if f, ok := tracestelemetryschema.IntrinsicFieldsDeprecated[name]; ok {
|
||||
return f, true
|
||||
}
|
||||
if f, ok := tracestelemetryschema.CalculatedFieldsDeprecated[name]; ok {
|
||||
return f, true
|
||||
}
|
||||
return telemetrytypes.TelemetryFieldKey{}, false
|
||||
}
|
||||
|
||||
// buildListQuery builds a query for list panel type.
|
||||
func (b *traceQueryStatementBuilder) buildListQuery(
|
||||
ctx context.Context,
|
||||
@@ -521,6 +578,11 @@ func (b *traceQueryStatementBuilder) buildTimeSeriesQuery(
|
||||
cteArgs = append(cteArgs, args)
|
||||
}
|
||||
|
||||
if scopeFrags, scopeArgs := b.attachTraceScope(sb, frag != ""); len(scopeFrags) > 0 {
|
||||
cteFragments = append(cteFragments, scopeFrags...)
|
||||
cteArgs = append(cteArgs, scopeArgs...)
|
||||
}
|
||||
|
||||
sb.SelectMore(fmt.Sprintf(
|
||||
"toStartOfInterval(timestamp, INTERVAL %d SECOND) AS ts",
|
||||
int64(query.StepInterval.Seconds()),
|
||||
@@ -681,6 +743,13 @@ func (b *traceQueryStatementBuilder) buildScalarQuery(
|
||||
cteArgs = append(cteArgs, args)
|
||||
}
|
||||
|
||||
// skipResourceCTE means this scalar is embedded as a CTE of a time-series query,
|
||||
// which has already emitted the __trace_scope fragment — add only the condition.
|
||||
if scopeFrags, scopeArgs := b.attachTraceScope(sb, frag != ""); len(scopeFrags) > 0 && !skipResourceCTE {
|
||||
cteFragments = append(cteFragments, scopeFrags...)
|
||||
cteArgs = append(cteArgs, scopeArgs...)
|
||||
}
|
||||
|
||||
allAggChArgs := []any{}
|
||||
|
||||
fieldNames := make([]string, 0, len(query.GroupBy))
|
||||
|
||||
@@ -374,6 +374,94 @@ func TestStatementBuilder(t *testing.T) {
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
name: "scope.name filter and group by",
|
||||
requestType: qbtypes.RequestTypeTimeSeries,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
|
||||
Aggregations: []qbtypes.TraceAggregation{
|
||||
{
|
||||
Expression: "count()",
|
||||
},
|
||||
},
|
||||
Filter: &qbtypes.Filter{
|
||||
Expression: "scope.name = 'opentelemetry-io'",
|
||||
},
|
||||
Limit: 10,
|
||||
GroupBy: []qbtypes.GroupByKey{
|
||||
{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "scope.name",
|
||||
FieldContext: telemetrytypes.FieldContextScope,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __limit_cte AS (SELECT toString(multiIf(scope.name::String <> '', scope.name::String, NULL)) AS `__GROUP_BY_KEY_0_scope.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE (scope.name::String = ? AND scope.name::String <> '') AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY `__GROUP_BY_KEY_0_scope.name` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(timestamp, INTERVAL 30 SECOND) AS ts, toString(multiIf(scope.name::String <> '', scope.name::String, NULL)) AS `__GROUP_BY_KEY_0_scope.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE (scope.name::String = ? AND scope.name::String <> '') AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND (`__GROUP_BY_KEY_0_scope.name`) GLOBAL IN (SELECT `__GROUP_BY_KEY_0_scope.name` FROM __limit_cte) GROUP BY ts, `__GROUP_BY_KEY_0_scope.name`",
|
||||
Args: []any{"opentelemetry-io", "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10, "opentelemetry-io", "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448)},
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
name: "scope.version filter with scope.name group by",
|
||||
requestType: qbtypes.RequestTypeTimeSeries,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
|
||||
Aggregations: []qbtypes.TraceAggregation{
|
||||
{
|
||||
Expression: "count()",
|
||||
},
|
||||
},
|
||||
Filter: &qbtypes.Filter{
|
||||
Expression: "scope.version = '1.0.0'",
|
||||
},
|
||||
Limit: 10,
|
||||
GroupBy: []qbtypes.GroupByKey{
|
||||
{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "scope.name",
|
||||
FieldContext: telemetrytypes.FieldContextScope,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __limit_cte AS (SELECT toString(multiIf(scope.name::String <> '', scope.name::String, NULL)) AS `__GROUP_BY_KEY_0_scope.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE (scope.version::String = ? AND scope.version::String <> '') AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY `__GROUP_BY_KEY_0_scope.name` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(timestamp, INTERVAL 30 SECOND) AS ts, toString(multiIf(scope.name::String <> '', scope.name::String, NULL)) AS `__GROUP_BY_KEY_0_scope.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE (scope.version::String = ? AND scope.version::String <> '') AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND (`__GROUP_BY_KEY_0_scope.name`) GLOBAL IN (SELECT `__GROUP_BY_KEY_0_scope.name` FROM __limit_cte) GROUP BY ts, `__GROUP_BY_KEY_0_scope.name`",
|
||||
Args: []any{"1.0.0", "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10, "1.0.0", "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448)},
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
name: "scope.version filter only (no scope field in group by)",
|
||||
requestType: qbtypes.RequestTypeTimeSeries,
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
|
||||
Aggregations: []qbtypes.TraceAggregation{
|
||||
{
|
||||
Expression: "count()",
|
||||
},
|
||||
},
|
||||
Filter: &qbtypes.Filter{
|
||||
Expression: "scope.version = '1.0.0'",
|
||||
},
|
||||
Limit: 10,
|
||||
GroupBy: []qbtypes.GroupByKey{
|
||||
{
|
||||
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "service.name",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "WITH __limit_cte AS (SELECT toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `__GROUP_BY_KEY_0_service.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE (scope.version::String = ? AND scope.version::String <> '') AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? GROUP BY `__GROUP_BY_KEY_0_service.name` ORDER BY __result_0 DESC LIMIT ?) SELECT toStartOfInterval(timestamp, INTERVAL 30 SECOND) AS ts, toString(multiIf(multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) IS NOT NULL, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL), NULL)) AS `__GROUP_BY_KEY_0_service.name`, count() AS __result_0 FROM signoz_traces.distributed_signoz_index_v3 WHERE (scope.version::String = ? AND scope.version::String <> '') AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? AND (`__GROUP_BY_KEY_0_service.name`) GLOBAL IN (SELECT `__GROUP_BY_KEY_0_service.name` FROM __limit_cte) GROUP BY ts, `__GROUP_BY_KEY_0_service.name`",
|
||||
Args: []any{"1.0.0", "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10, "1.0.0", "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448)},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
fl := flaggertest.New(t)
|
||||
@@ -800,6 +888,143 @@ func TestStatementBuilderListQueryWithCorruptData(t *testing.T) {
|
||||
},
|
||||
expectedErr: nil,
|
||||
},
|
||||
{
|
||||
name: "List query with scope filter only (no scope in select or group by)",
|
||||
requestType: qbtypes.RequestTypeRaw,
|
||||
keysMap: map[string][]*telemetrytypes.TelemetryFieldKey{
|
||||
"scope.version": {
|
||||
{
|
||||
Name: "scope.version",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextScope,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
},
|
||||
},
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
|
||||
Filter: &qbtypes.Filter{
|
||||
Expression: "scope.version = '1.0.0'",
|
||||
},
|
||||
Limit: 10,
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "SELECT timestamp AS `__SELECT_KEY_0_timestamp`, trace_id AS `__SELECT_KEY_1_trace_id`, span_id AS `__SELECT_KEY_2_span_id`, trace_state AS `__SELECT_KEY_3_trace_state`, parent_span_id AS `__SELECT_KEY_4_parent_span_id`, flags AS `__SELECT_KEY_5_flags`, name AS `__SELECT_KEY_6_name`, kind AS `__SELECT_KEY_7_kind`, kind_string AS `__SELECT_KEY_8_kind_string`, duration_nano AS `__SELECT_KEY_9_duration_nano`, status_code AS `__SELECT_KEY_10_status_code`, status_message AS `__SELECT_KEY_11_status_message`, status_code_string AS `__SELECT_KEY_12_status_code_string`, events AS `__SELECT_KEY_13_events`, links AS `__SELECT_KEY_14_links`, response_status_code AS `__SELECT_KEY_15_response_status_code`, external_http_url AS `__SELECT_KEY_16_external_http_url`, http_url AS `__SELECT_KEY_17_http_url`, external_http_method AS `__SELECT_KEY_18_external_http_method`, http_method AS `__SELECT_KEY_19_http_method`, http_host AS `__SELECT_KEY_20_http_host`, db_name AS `__SELECT_KEY_21_db_name`, db_operation AS `__SELECT_KEY_22_db_operation`, has_error AS `__SELECT_KEY_23_has_error`, is_remote AS `__SELECT_KEY_24_is_remote`, attributes_string, attributes_number, attributes_bool, resources_string FROM signoz_traces.distributed_signoz_index_v3 WHERE (scope.version::String = ? AND scope.version::String <> '') AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
|
||||
Args: []any{"1.0.0", "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
|
||||
},
|
||||
},
|
||||
{
|
||||
// Regression test: scope.version in selectFields with no metadata (isColumn=true filters it out)
|
||||
// must still produce scope.version::String, not scope.attributes.version::String
|
||||
name: "scope.version in selectFields only, no metadata (intrinsic field fallback)",
|
||||
requestType: qbtypes.RequestTypeRaw,
|
||||
keysMap: map[string][]*telemetrytypes.TelemetryFieldKey{},
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
|
||||
Filter: &qbtypes.Filter{},
|
||||
SelectFields: []telemetrytypes.TelemetryFieldKey{
|
||||
{Name: "scope.version", FieldContext: telemetrytypes.FieldContextUnspecified},
|
||||
},
|
||||
Limit: 10,
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "SELECT timestamp AS `__SELECT_KEY_0_timestamp`, trace_id AS `__SELECT_KEY_1_trace_id`, span_id AS `__SELECT_KEY_2_span_id`, multiIf(scope.version::String <> '', scope.version::String, NULL) AS `__SELECT_KEY_3_scope.version` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
|
||||
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
|
||||
},
|
||||
},
|
||||
{
|
||||
// A scope attribute whose own name literally carries a `scope.` prefix (`scope.prefixed`,
|
||||
// normalized to {prefixed, scope}) resolves to that attribute in a SELECT even without a
|
||||
// filter: getKeySelectors emits the reconstructed `scope.prefixed` selector so the metadata
|
||||
// fetch surfaces it and AdjustKey recovers the full name. Without it the `scope.` prefix is
|
||||
// lost and it wrongly reads `scope.attributes.prefixed`.
|
||||
name: "scope-prefixed attribute in selectFields resolves without a filter",
|
||||
requestType: qbtypes.RequestTypeRaw,
|
||||
keysMap: map[string][]*telemetrytypes.TelemetryFieldKey{
|
||||
"scope.prefixed": {
|
||||
{
|
||||
Name: "scope.prefixed",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextScope,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
},
|
||||
},
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
|
||||
Filter: &qbtypes.Filter{},
|
||||
SelectFields: []telemetrytypes.TelemetryFieldKey{
|
||||
{Name: "prefixed", FieldContext: telemetrytypes.FieldContextScope},
|
||||
},
|
||||
Limit: 10,
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "SELECT timestamp AS `__SELECT_KEY_0_timestamp`, trace_id AS `__SELECT_KEY_1_trace_id`, span_id AS `__SELECT_KEY_2_span_id`, multiIf(scope.attributes.`scope.prefixed` IS NOT NULL, scope.attributes.`scope.prefixed`::String, NULL) AS `__SELECT_KEY_3_scope.prefixed` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
|
||||
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
|
||||
},
|
||||
},
|
||||
{
|
||||
// A scope-context key whose name matches a declared scope path resolves to that
|
||||
// declared path (scope.name), not the span `name` column and not an undeclared
|
||||
// scope attribute. getTracesKeys surfaces the declared path as an intrinsic key
|
||||
// (metadata.go), which shadows the same-named span intrinsic.
|
||||
name: "scope-context name resolves to the declared scope path",
|
||||
requestType: qbtypes.RequestTypeRaw,
|
||||
keysMap: map[string][]*telemetrytypes.TelemetryFieldKey{
|
||||
"scope.name": {
|
||||
{
|
||||
Name: "scope.name",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextScope,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
},
|
||||
"name": {
|
||||
{
|
||||
Name: "name",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextSpan,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
},
|
||||
},
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
|
||||
Filter: &qbtypes.Filter{},
|
||||
SelectFields: []telemetrytypes.TelemetryFieldKey{
|
||||
{Name: "name", FieldContext: telemetrytypes.FieldContextScope},
|
||||
},
|
||||
Limit: 10,
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "SELECT timestamp AS `__SELECT_KEY_0_timestamp`, trace_id AS `__SELECT_KEY_1_trace_id`, span_id AS `__SELECT_KEY_2_span_id`, multiIf(scope.name::String <> '', scope.name::String, NULL) AS `__SELECT_KEY_3_name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
|
||||
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
|
||||
},
|
||||
},
|
||||
{
|
||||
// span.scope.name (span context, name "scope.name") resolves to the declared
|
||||
// scope path scope.name, not a span attribute literally named scope.name.
|
||||
name: "span-context scope.name in selectFields resolves to the declared scope path",
|
||||
requestType: qbtypes.RequestTypeRaw,
|
||||
keysMap: map[string][]*telemetrytypes.TelemetryFieldKey{},
|
||||
query: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
StepInterval: qbtypes.Step{Duration: 30 * time.Second},
|
||||
Filter: &qbtypes.Filter{},
|
||||
SelectFields: []telemetrytypes.TelemetryFieldKey{
|
||||
{Name: "scope.name", FieldContext: telemetrytypes.FieldContextSpan},
|
||||
},
|
||||
Limit: 10,
|
||||
},
|
||||
expected: qbtypes.Statement{
|
||||
Query: "SELECT timestamp AS `__SELECT_KEY_0_timestamp`, trace_id AS `__SELECT_KEY_1_trace_id`, span_id AS `__SELECT_KEY_2_span_id`, multiIf(scope.name::String <> '', scope.name::String, NULL) AS `__SELECT_KEY_3_scope.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
|
||||
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
|
||||
@@ -180,7 +180,7 @@ func (t *telemetryMetaStore) getTracesKeys(ctx context.Context, fieldKeySelector
|
||||
`CASE
|
||||
// WHEN tagType = 'spanfield' THEN 1
|
||||
WHEN tagType = 'resource' THEN 2
|
||||
// WHEN tagType = 'scope' THEN 3
|
||||
WHEN tagType = 'scope' THEN 3
|
||||
WHEN tagType = 'tag' THEN 4
|
||||
ELSE 5
|
||||
END as priority`,
|
||||
|
||||
@@ -391,6 +391,96 @@ func TestConditionForResourceWithEvolution(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestConditionForScopeIntrinsicFields covers the scope.name/scope.version intrinsic
|
||||
// fields against the "scope" JSON column. These are *declared* String paths on that
|
||||
// column, so a row without a scope reads as ” and never NULL: presence must be an
|
||||
// empty-string check, since "IS NOT NULL" would hold for every row. That also rules
|
||||
// out treating them as nested attribute keys under scope.attributes, which are
|
||||
// undeclared (Dynamic) paths and genuinely NULL when absent.
|
||||
func TestConditionForScopeIntrinsicFields(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
fm := NewFieldMapper(flaggertest.New(t))
|
||||
conditionBuilder := NewConditionBuilder(fm, flaggertest.New(t))
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
key telemetrytypes.TelemetryFieldKey
|
||||
operator qbtypes.FilterOperator
|
||||
value any
|
||||
expectedSQL string
|
||||
}{
|
||||
{
|
||||
name: "Equal - scope.name",
|
||||
key: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "scope.name",
|
||||
FieldContext: telemetrytypes.FieldContextScope,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
operator: qbtypes.FilterOperatorEqual,
|
||||
value: "io.signoz.payment",
|
||||
expectedSQL: "(scope.name::String = ? AND scope.name::String <> '')",
|
||||
},
|
||||
{
|
||||
name: "Equal - scope.version",
|
||||
key: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "scope.version",
|
||||
FieldContext: telemetrytypes.FieldContextScope,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
operator: qbtypes.FilterOperatorEqual,
|
||||
value: "2.3.1",
|
||||
expectedSQL: "(scope.version::String = ? AND scope.version::String <> '')",
|
||||
},
|
||||
{
|
||||
name: "Exists - scope.name",
|
||||
key: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "scope.name",
|
||||
FieldContext: telemetrytypes.FieldContextScope,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
operator: qbtypes.FilterOperatorExists,
|
||||
value: nil,
|
||||
expectedSQL: "scope.name::String <> ''",
|
||||
},
|
||||
{
|
||||
name: "NotExists - scope.version",
|
||||
key: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "scope.version",
|
||||
FieldContext: telemetrytypes.FieldContextScope,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
operator: qbtypes.FilterOperatorNotExists,
|
||||
value: nil,
|
||||
expectedSQL: "scope.version::String = ''",
|
||||
},
|
||||
{
|
||||
// `scope.attribute.name` (normalized to {attribute.name, scope}) addresses the scope
|
||||
// attribute named `name` — the declared `scope.name` path is never reached this way.
|
||||
name: "Equal - scope.attribute.name reaches the named scope attribute",
|
||||
key: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "attribute.name",
|
||||
FieldContext: telemetrytypes.FieldContextScope,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
operator: qbtypes.FilterOperatorEqual,
|
||||
value: "io.signoz.checkout",
|
||||
expectedSQL: "(scope.attributes.`name`::String = ? AND scope.attributes.`name` IS NOT NULL)",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
conds, _, err := conditionBuilder.ConditionFor(ctx, valuer.UUID{}, 0, 0, &tc.key, map[string][]*telemetrytypes.TelemetryFieldKey{tc.key.Name: {&tc.key}}, qbtypes.ConditionBuilderOptions{}, tc.operator, tc.value, sb)
|
||||
require.NoError(t, err)
|
||||
sb.Where(conds...)
|
||||
sql, _ := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
assert.Contains(t, sql, tc.expectedSQL)
|
||||
assert.NotContains(t, sql, "scope.`scope.", "must not double-prefix the scope JSON path")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestConditionForSynthesizedKeys covers the KeyNotFound fallback: when a
|
||||
// referenced attribute key has no metadata match, the builder synthesizes key(s) from
|
||||
// user input and queries anyway, emitting a warning instead of failing.
|
||||
@@ -414,6 +504,20 @@ func TestConditionForSynthesizedKeys(t *testing.T) {
|
||||
assert.Contains(t, args, "timeout")
|
||||
})
|
||||
|
||||
t.Run("scope context with no metadata -> scope attribute", func(t *testing.T) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
key := telemetrytypes.TelemetryFieldKey{Name: "custom.attr", FieldContext: telemetrytypes.FieldContextScope}
|
||||
conds, warnings, err := cb.ConditionFor(ctx, valuer.UUID{}, 0, 0, &key, noMatches, qbtypes.ConditionBuilderOptions{}, qbtypes.FilterOperatorEqual, "v", sb)
|
||||
assert.NoError(t, err, "an undeclared scope attribute must still be filterable")
|
||||
assert.NotEmpty(t, warnings)
|
||||
sb.Where(conds...)
|
||||
sql, _ := sb.BuildWithFlavor(sqlbuilder.ClickHouse)
|
||||
assert.Contains(t, sql, "scope.attributes.`custom.attr`")
|
||||
// `scope.` can be part of the attribute's own name, so the literal spelling is a
|
||||
// candidate too — the caller ORs the two.
|
||||
assert.Contains(t, sql, "scope.attributes.`scope.custom.attr`")
|
||||
})
|
||||
|
||||
t.Run("bare key with number operand -> attribute number", func(t *testing.T) {
|
||||
sb := sqlbuilder.NewSelectBuilder()
|
||||
key := telemetrytypes.TelemetryFieldKey{Name: "http.status"}
|
||||
|
||||
@@ -121,6 +121,20 @@ var (
|
||||
FieldContext: telemetrytypes.FieldContextSpan,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
"scope.name": {
|
||||
Name: "scope.name",
|
||||
Description: "Instrumentation scope name",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextScope,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
"scope.version": {
|
||||
Name: "scope.version",
|
||||
Description: "Instrumentation scope version",
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextScope,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
}
|
||||
IntrinsicFieldsDeprecated = map[string]telemetrytypes.TelemetryFieldKey{
|
||||
"traceID": {
|
||||
|
||||
@@ -53,6 +53,7 @@ var (
|
||||
ValueType: schema.ColumnTypeString,
|
||||
}},
|
||||
"resource": {Name: "resource", Type: schema.JSONColumnType{}},
|
||||
"scope": {Name: "scope", Type: schema.JSONColumnType{}},
|
||||
|
||||
"events": {Name: "events", Type: schema.ArrayColumnType{
|
||||
ElementType: schema.ColumnTypeString,
|
||||
@@ -181,7 +182,7 @@ func (m *fieldMapper) getColumn(
|
||||
case telemetrytypes.FieldContextResource:
|
||||
return []*schema.Column{indexV3Columns["resource"], indexV3Columns["resources_string"]}, nil
|
||||
case telemetrytypes.FieldContextScope:
|
||||
return []*schema.Column{}, qbtypes.ErrColumnNotFound
|
||||
return []*schema.Column{indexV3Columns["scope"]}, nil
|
||||
case telemetrytypes.FieldContextAttribute:
|
||||
switch key.FieldDataType {
|
||||
case telemetrytypes.FieldDataTypeString:
|
||||
@@ -292,14 +293,25 @@ func (m *fieldMapper) resolveColumnExprs(
|
||||
|
||||
switch column.Type.GetType() {
|
||||
case schema.ColumnTypeEnumJSON:
|
||||
// json is only supported for resource context as of now
|
||||
if key.FieldContext != telemetrytypes.FieldContextResource {
|
||||
return nil, nil, nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "only resource context fields are supported for json columns, got %s", key.FieldContext.String)
|
||||
}
|
||||
// have to add ::string as clickHouse throws an error :- data types Variant/Dynamic are not allowed in GROUP BY
|
||||
// once clickHouse dependency is updated, we need to check if we can remove it.
|
||||
exprs = append(exprs, fmt.Sprintf("%s.`%s`::String", columnName, key.Name))
|
||||
existExprs = append(existExprs, fmt.Sprintf("%s.`%s` IS NOT NULL", columnName, key.Name))
|
||||
switch key.FieldContext {
|
||||
case telemetrytypes.FieldContextResource:
|
||||
exprs = append(exprs, fmt.Sprintf("%s.`%s`::String", columnName, key.Name))
|
||||
existExprs = append(existExprs, fmt.Sprintf("%s.`%s` IS NOT NULL", columnName, key.Name))
|
||||
case telemetrytypes.FieldContextScope:
|
||||
if f, ok := IntrinsicFields[key.Name]; ok && f.FieldContext == telemetrytypes.FieldContextScope {
|
||||
// declared String paths on the scope column read '' for the missing case
|
||||
exprs = append(exprs, fmt.Sprintf("%s::String", key.Name))
|
||||
existExprs = append(existExprs, fmt.Sprintf("%s <> ''", key.Name))
|
||||
} else {
|
||||
attributeName := strings.TrimPrefix(key.Name, "attribute.") // literal "attribute" prefix in attribute keys needs double prefix
|
||||
exprs = append(exprs, fmt.Sprintf("%s.attributes.%s::String", columnName, querybuilder.ClickHouseIdentifier(attributeName)))
|
||||
existExprs = append(existExprs, fmt.Sprintf("%s.attributes.%s IS NOT NULL", columnName, querybuilder.ClickHouseIdentifier(attributeName)))
|
||||
}
|
||||
default:
|
||||
return nil, nil, nil, errors.NewInternalf(errors.CodeInternal, "only resource and scope context fields are supported for json columns, got %s", key.FieldContext.String)
|
||||
}
|
||||
case schema.ColumnTypeEnumString,
|
||||
schema.ColumnTypeEnumUInt64,
|
||||
schema.ColumnTypeEnumUInt32,
|
||||
@@ -341,20 +353,6 @@ func (m *fieldMapper) resolveColumnExprs(
|
||||
return exprs, existExprs, columns, nil
|
||||
}
|
||||
|
||||
// logicalForResolvedColumn upgrades a directly-resolvable key (the FieldFor
|
||||
// probe succeeded) to its family when the metadata map proves membership;
|
||||
// otherwise the key stays a single-member logical field.
|
||||
func (m *fieldMapper) logicalForResolvedColumn(ctx context.Context, orgID valuer.UUID, field *telemetrytypes.TelemetryFieldKey, keys map[string][]*telemetrytypes.TelemetryFieldKey) *telemetrytypes.LogicalField {
|
||||
for _, logical := range querybuilder.MatchingLogicalFields(ctx, orgID, m.fl, field, keys) {
|
||||
if logical.IsFamily() &&
|
||||
logical.FieldContext == field.FieldContext &&
|
||||
(field.FieldDataType == telemetrytypes.FieldDataTypeUnspecified || logical.FieldDataType == field.FieldDataType) {
|
||||
return logical
|
||||
}
|
||||
}
|
||||
return telemetrytypes.SingleLogicalField(field.Name, field)
|
||||
}
|
||||
|
||||
// upgradeToFamilies swaps single-member candidates for their family when the
|
||||
// metadata map proves membership. Candidate order and every non-family
|
||||
// candidate stay exactly as the legacy flow produced them; sibling candidates
|
||||
@@ -419,9 +417,11 @@ func (m *fieldMapper) ColumnExpressionFor(
|
||||
var candidates []*telemetrytypes.LogicalField
|
||||
switch _, err := m.FieldFor(ctx, orgID, startNs, endNs, field); {
|
||||
case err == nil:
|
||||
// A directly-resolvable key upgrades to its family when the metadata
|
||||
// map proves membership; otherwise it stays single-member.
|
||||
candidates = []*telemetrytypes.LogicalField{m.logicalForResolvedColumn(ctx, orgID, field, keys)}
|
||||
// Every match from metadata is kept, similar to the filter path.
|
||||
candidates = querybuilder.MatchingLogicalFields(ctx, orgID, m.fl, field, keys)
|
||||
if len(candidates) == 0 {
|
||||
candidates = []*telemetrytypes.LogicalField{telemetrytypes.SingleLogicalField(field.Name, field)}
|
||||
}
|
||||
case errors.Is(err, qbtypes.ErrColumnNotFound):
|
||||
// The legacy candidate flow, unchanged: column (when the bare name is
|
||||
// one) plus metadata matches, else synthesized type-variant keys. The
|
||||
@@ -595,15 +595,37 @@ func (m *fieldMapper) CandidateKeys(ctx context.Context, _ valuer.UUID, field *t
|
||||
// honored as-is: the stripped name lives in the attribute maps
|
||||
stripped := telemetrytypes.NewTelemetryFieldKey(field.Name, telemetrytypes.FieldContextUnspecified, field.FieldDataType)
|
||||
return querybuilder.SynthesizeKeys(stripped, value)
|
||||
case telemetrytypes.FieldContextAttribute, telemetrytypes.FieldContextResource:
|
||||
case telemetrytypes.FieldContextAttribute, telemetrytypes.FieldContextResource, telemetrytypes.FieldContextScope:
|
||||
// strict context honored as-is: stripped interpretation first, literal spelling second
|
||||
literal := telemetrytypes.NewTelemetryFieldKey(field.FieldContext.StringValue()+"."+field.Name, field.FieldContext, field.FieldDataType)
|
||||
return append(querybuilder.SynthesizeKeys(field, value), querybuilder.SynthesizeKeys(literal, value)...)
|
||||
}
|
||||
// contexts that don't exist on spans (log, body, scope, …) have nothing to synthesize
|
||||
// contexts that don't exist on spans (log, body, …) have nothing to synthesize
|
||||
return nil
|
||||
}
|
||||
|
||||
// scopeJSONExistsExpression renders the existence predicate for the scope JSON column, the one
|
||||
// signal-specific case the generic querybuilder.ExistsExpression must not carry.
|
||||
func scopeJSONExistsExpression(key *telemetrytypes.TelemetryFieldKey, fieldExpression string, exists bool) (string, bool) {
|
||||
if key.FieldContext != telemetrytypes.FieldContextScope {
|
||||
return "", false
|
||||
}
|
||||
// Declared String paths are non-Nullable (absent reads '' not NULL).
|
||||
if f, ok := IntrinsicFields[key.Name]; ok && f.FieldContext == telemetrytypes.FieldContextScope {
|
||||
if exists {
|
||||
return fieldExpression + " <> ''", true
|
||||
}
|
||||
return fieldExpression + " = ''", true
|
||||
}
|
||||
// Scope attribute: the value expression casts the JSON path to String, which folds a missing
|
||||
// key's NULL to '', so presence must test the raw path — drop the ::String cast.
|
||||
path := strings.TrimSuffix(fieldExpression, "::String")
|
||||
if exists {
|
||||
return path + " IS NOT NULL", true
|
||||
}
|
||||
return path + " IS NULL", true
|
||||
}
|
||||
|
||||
// ExistsFor implements the per-key existence primitive of qbtypes.FieldMapper.
|
||||
func (m *fieldMapper) ExistsFor(
|
||||
ctx context.Context,
|
||||
@@ -620,5 +642,8 @@ func (m *fieldMapper) ExistsFor(
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if expr, ok := scopeJSONExistsExpression(key, fieldExpression, exists); ok {
|
||||
return expr, nil
|
||||
}
|
||||
return querybuilder.ExistsExpression(columns, key, tsStart, tsEnd, fieldExpression, exists)
|
||||
}
|
||||
|
||||
@@ -84,6 +84,45 @@ func TestGetFieldKeyName(t *testing.T) {
|
||||
expectedResult: "multiIf(resource.`deployment.environment` IS NOT NULL, resource.`deployment.environment`::String, `resource_string_deployment$$environment_exists`, `resource_string_deployment$$environment`, NULL)",
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
name: "Scope field - scope.name",
|
||||
key: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "scope.name",
|
||||
FieldContext: telemetrytypes.FieldContextScope,
|
||||
},
|
||||
expectedResult: "scope.name::String",
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
name: "Scope field - scope.version",
|
||||
key: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "scope.version",
|
||||
FieldContext: telemetrytypes.FieldContextScope,
|
||||
},
|
||||
expectedResult: "scope.version::String",
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
name: "Scope field - custom attribute",
|
||||
key: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "custom.attr",
|
||||
FieldContext: telemetrytypes.FieldContextScope,
|
||||
},
|
||||
expectedResult: "scope.attributes.`custom.attr`::String",
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
// `scope.attribute.name` normalizes to {attribute.name, scope}; the literal
|
||||
// `attribute.` prefix is dropped so it addresses the scope attribute named `name`
|
||||
// (which the declared `scope.name` path deliberately does not).
|
||||
name: "Scope field - attribute prefix addresses the named scope attribute",
|
||||
key: telemetrytypes.TelemetryFieldKey{
|
||||
Name: "attribute.name",
|
||||
FieldContext: telemetrytypes.FieldContextScope,
|
||||
},
|
||||
expectedResult: "scope.attributes.`name`::String",
|
||||
expectedError: nil,
|
||||
},
|
||||
{
|
||||
// Query like `attribute.attribute_string:string` should resolve to `attributes_string['attribute_string']`.
|
||||
name: "Attribute key whose name collides with contextual map column resolves as a map lookup",
|
||||
@@ -304,3 +343,99 @@ func TestColumnExpressionForTimestampAttributeCollision(t *testing.T) {
|
||||
assert.Contains(t, result, "attributes_number['timestamp']")
|
||||
})
|
||||
}
|
||||
|
||||
// TestColumnExpressionForScopeDeclaredPath covers select-side resolution of scope names that
|
||||
// collide with a declared scope path. A short name under scope context (or the bare
|
||||
// `scope.<x>` spelling that normalizes to it) names both homes and coalesces them when
|
||||
// metadata knows a same-named scope attribute, and binds to the declared path alone when it
|
||||
// does not. The full `scope.<x>` name under explicit scope context addresses the declared
|
||||
// path alone; the explicit `scope.attribute.` prefix addresses the attribute alone.
|
||||
func TestColumnExpressionForScopeDeclaredPath(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
scopeKey := func(name string) *telemetrytypes.TelemetryFieldKey {
|
||||
return &telemetrytypes.TelemetryFieldKey{
|
||||
Name: name,
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
FieldContext: telemetrytypes.FieldContextScope,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
}
|
||||
}
|
||||
declaredOnly := map[string][]*telemetrytypes.TelemetryFieldKey{
|
||||
"scope.name": {scopeKey("scope.name")},
|
||||
"scope.version": {scopeKey("scope.version")},
|
||||
}
|
||||
withAttr := map[string][]*telemetrytypes.TelemetryFieldKey{
|
||||
"scope.name": {scopeKey("scope.name")},
|
||||
"scope.version": {scopeKey("scope.version")},
|
||||
"name": {scopeKey("name")},
|
||||
"version": {scopeKey("version")},
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
key telemetrytypes.TelemetryFieldKey
|
||||
keys map[string][]*telemetrytypes.TelemetryFieldKey
|
||||
expectedResult string
|
||||
}{
|
||||
{
|
||||
name: "short name under scope context binds to the declared path",
|
||||
key: telemetrytypes.TelemetryFieldKey{Name: "version", FieldContext: telemetrytypes.FieldContextScope},
|
||||
keys: declaredOnly,
|
||||
expectedResult: "multiIf(scope.version::String <> '', scope.version::String, NULL)",
|
||||
},
|
||||
{
|
||||
name: "full scope.version name under scope context addresses the declared path alone",
|
||||
key: telemetrytypes.TelemetryFieldKey{Name: "scope.version", FieldContext: telemetrytypes.FieldContextScope},
|
||||
keys: withAttr,
|
||||
expectedResult: "multiIf(scope.version::String <> '', scope.version::String, NULL)",
|
||||
},
|
||||
{
|
||||
name: "full scope.name name under scope context addresses the declared path alone",
|
||||
key: telemetrytypes.TelemetryFieldKey{Name: "scope.name", FieldContext: telemetrytypes.FieldContextScope},
|
||||
keys: withAttr,
|
||||
expectedResult: "multiIf(scope.name::String <> '', scope.name::String, NULL)",
|
||||
},
|
||||
{
|
||||
// `scope.attribute.name` normalizes to {attribute.name, scope}; the `attribute.`
|
||||
// prefix is dropped so it addresses the scope attribute named `name` — the only
|
||||
// way to reach it, since `scope.name` is reserved for the declared path.
|
||||
name: "attribute prefix reaches the named scope attribute",
|
||||
key: telemetrytypes.TelemetryFieldKey{Name: "attribute.name", FieldContext: telemetrytypes.FieldContextScope},
|
||||
keys: withAttr,
|
||||
expectedResult: "multiIf(scope.attributes.`name` IS NOT NULL, scope.attributes.`name`::String, NULL)",
|
||||
},
|
||||
{
|
||||
// the caller supplied the context, so `scope.` is part of the name rather than a
|
||||
// prefix to strip: this addresses a scope attribute literally named
|
||||
// `scope.testing.env`, not the attribute `testing.env`
|
||||
name: "explicit context keeps a scope-prefixed name intact",
|
||||
key: telemetrytypes.TelemetryFieldKey{Name: "scope.testing.env", FieldContext: telemetrytypes.FieldContextScope},
|
||||
keys: declaredOnly,
|
||||
expectedResult: "multiIf(scope.attributes.`scope.testing.env` IS NOT NULL, scope.attributes.`scope.testing.env`::String, NULL)",
|
||||
},
|
||||
{
|
||||
// metadata knows both homes under this name, so the short spelling coalesces
|
||||
// them instead of being rejected as ambiguous
|
||||
name: "short name coalesces a known scope attribute with the declared path",
|
||||
key: telemetrytypes.TelemetryFieldKey{Name: "name", FieldContext: telemetrytypes.FieldContextScope},
|
||||
keys: withAttr,
|
||||
expectedResult: "multiIf(scope.attributes.`name` IS NOT NULL, toString(scope.attributes.`name`::String), scope.name::String <> '', toString(scope.name::String), NULL)",
|
||||
},
|
||||
{
|
||||
name: "short version coalesces a known scope attribute with the declared path",
|
||||
key: telemetrytypes.TelemetryFieldKey{Name: "version", FieldContext: telemetrytypes.FieldContextScope},
|
||||
keys: withAttr,
|
||||
expectedResult: "multiIf(scope.attributes.`version` IS NOT NULL, toString(scope.attributes.`version`::String), scope.version::String <> '', toString(scope.version::String), NULL)",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
fm := NewFieldMapper(flaggertest.New(t))
|
||||
result, err := fm.ColumnExpressionFor(ctx, valuer.UUID{}, 0, 0, &tc.key, telemetrytypes.FieldDataTypeUnspecified, tc.keys)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.expectedResult, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +113,20 @@ func BuildCompleteFieldKeyMap(releaseTime time.Time) map[string][]*telemetrytype
|
||||
FieldDataType: telemetrytypes.FieldDataTypeBool,
|
||||
},
|
||||
},
|
||||
"scope.name": {
|
||||
{
|
||||
Name: "scope.name",
|
||||
FieldContext: telemetrytypes.FieldContextScope,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
},
|
||||
"scope.version": {
|
||||
{
|
||||
Name: "scope.version",
|
||||
FieldContext: telemetrytypes.FieldContextScope,
|
||||
FieldDataType: telemetrytypes.FieldDataTypeString,
|
||||
},
|
||||
},
|
||||
// both spellings of an enabled semantic-convention family
|
||||
"deployment.environment.name": {
|
||||
{
|
||||
|
||||
@@ -114,8 +114,8 @@ func (d *DashboardSpec) validatePanels() error {
|
||||
return err
|
||||
}
|
||||
panelKind := panel.Spec.Plugin.Kind
|
||||
if len(panel.Spec.Queries) != 1 {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s.spec.queries: panel must have one query, found %d", path, len(panel.Spec.Queries))
|
||||
if err := validatePanelQueryCount(panel.Spec.Queries, panelKind, path); err != nil {
|
||||
return err
|
||||
}
|
||||
allowed := allowedQueryKinds[panelKind]
|
||||
for qi, q := range panel.Spec.Queries {
|
||||
@@ -127,6 +127,22 @@ func (d *DashboardSpec) validatePanels() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
if panelKind.rendersWithoutQuery() {
|
||||
if len(queries) != 0 {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s.spec.queries: panel kind %q renders without a query and must have queries: [], found %d", path, panelKind, len(queries))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if len(queries) != 1 {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s.spec.queries: panel must have one query, found %d", path, len(queries))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DashboardSpec) validateQuery(qi int, q Query, panelKind PanelPluginKind, path string, allowed []QueryPluginKind) error {
|
||||
queryPath := fmt.Sprintf("%s.spec.queries[%d].spec.plugin", path, qi)
|
||||
if err := validateQueryAllowedForPanel(q.Spec.Plugin, allowed, panelKind, queryPath); err != nil {
|
||||
|
||||
@@ -1085,7 +1085,7 @@ func TestInvalidatePanelWithoutQueries(t *testing.T) {
|
||||
}`)
|
||||
_, err := unmarshalDashboard(data)
|
||||
require.Error(t, err, "expected panel-without-queries to be rejected")
|
||||
assert.Contains(t, err.Error(), "panel must have one query")
|
||||
assert.Contains(t, err.Error(), "spec.queries: is required and must not be null")
|
||||
}
|
||||
|
||||
func TestInvalidatePanelWithEmptyQueriesArray(t *testing.T) {
|
||||
@@ -1135,6 +1135,115 @@ func TestInvalidatePanelWithMultipleDirectQueries(t *testing.T) {
|
||||
assert.Contains(t, err.Error(), "panel must have one query")
|
||||
}
|
||||
|
||||
func TestValidateTextPanel(t *testing.T) {
|
||||
wrapPanel := func(panelSpec string) []byte {
|
||||
return []byte(`{
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {"kind": "signoz/TextPanel", "spec": ` + panelSpec + `},
|
||||
"queries": []
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`)
|
||||
}
|
||||
|
||||
t.Run("fully specified text panel validates", func(t *testing.T) {
|
||||
d, err := unmarshalDashboard(wrapPanel(`{
|
||||
"mode": "markdown",
|
||||
"text": "# Runbook\n\nSee the [oncall doc](https://example.com).",
|
||||
"presentation": {"textAlign": "center", "verticalAlign": "bottom", "background": "transparent"}
|
||||
}`))
|
||||
require.NoError(t, err, "expected a fully specified text panel to validate")
|
||||
|
||||
spec, ok := d.Panels["p1"].Spec.Plugin.Spec.(*TextPanelSpec)
|
||||
require.True(t, ok, "expected the panel spec to decode as *TextPanelSpec")
|
||||
assert.Equal(t, TextModeMarkdown, spec.Mode)
|
||||
assert.Equal(t, "# Runbook\n\nSee the [oncall doc](https://example.com).", spec.Text)
|
||||
assert.Equal(t, TextAlignCenter, spec.Presentation.TextAlign)
|
||||
assert.Equal(t, VerticalAlignBottom, spec.Presentation.VerticalAlign)
|
||||
assert.Equal(t, PanelBackgroundTransparent, spec.Presentation.Background)
|
||||
})
|
||||
|
||||
t.Run("omitted fields marshal back as their defaults", func(t *testing.T) {
|
||||
d, err := unmarshalDashboard(wrapPanel(`{}`))
|
||||
require.NoError(t, err, "expected an empty text panel spec to validate")
|
||||
|
||||
out, err := json.Marshal(d.Panels["p1"].Spec.Plugin.Spec)
|
||||
require.NoError(t, err, "marshalling the decoded text panel spec")
|
||||
assert.JSONEq(t, `{
|
||||
"mode": "markdown",
|
||||
"text": "",
|
||||
"presentation": {"textAlign": "left", "verticalAlign": "top", "background": "solid"}
|
||||
}`, string(out))
|
||||
})
|
||||
|
||||
t.Run("a text panel carrying a query is rejected", func(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {"kind": "signoz/TextPanel", "spec": {"text": "hi"}},
|
||||
"queries": [{"kind": "time_series", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {"name": "A", "signal": "metrics"}}}}]
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`)
|
||||
_, err := unmarshalDashboard(data)
|
||||
require.Error(t, err, "expected a text panel with a query to be rejected")
|
||||
assert.Contains(t, err.Error(), "renders without a query and must have queries: [], found 1")
|
||||
})
|
||||
|
||||
t.Run("a text panel with null queries is rejected", func(t *testing.T) {
|
||||
data := []byte(`{
|
||||
"variables": [],
|
||||
"panels": {
|
||||
"p1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"links": [],
|
||||
"plugin": {"kind": "signoz/TextPanel", "spec": {"text": "hi"}},
|
||||
"queries": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"layouts": []
|
||||
}`)
|
||||
_, err := unmarshalDashboard(data)
|
||||
require.Error(t, err, "expected a text panel with null queries to be rejected")
|
||||
assert.Contains(t, err.Error(), "spec.queries: is required and must not be null")
|
||||
})
|
||||
|
||||
t.Run("unknown enum values are rejected", func(t *testing.T) {
|
||||
for field, spec := range map[string]string{
|
||||
"mode": `{"mode": "html"}`,
|
||||
"textAlign": `{"presentation": {"textAlign": "justify"}}`,
|
||||
"verticalAlign": `{"presentation": {"verticalAlign": "middle"}}`,
|
||||
"background": `{"presentation": {"background": "blurred"}}`,
|
||||
} {
|
||||
_, err := unmarshalDashboard(wrapPanel(spec))
|
||||
assert.Error(t, err, "expected an unknown %s value to be rejected", field)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown spec fields are rejected", func(t *testing.T) {
|
||||
_, err := unmarshalDashboard(wrapPanel(`{"markdown": "hi"}`))
|
||||
assert.Error(t, err, "expected an unknown text panel spec field to be rejected")
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateRequiredFields(t *testing.T) {
|
||||
wrapVariable := func(pluginKind, pluginSpec string) string {
|
||||
return `{
|
||||
|
||||
@@ -35,6 +35,7 @@ func (PanelPlugin) PrepareJSONSchema(s *jsonschema.Schema) error {
|
||||
string(PanelKindTable): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec"),
|
||||
string(PanelKindHistogram): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec"),
|
||||
string(PanelKindList): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec"),
|
||||
string(PanelKindText): schemaRef("DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec"),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -65,6 +66,7 @@ func (PanelPlugin) JSONSchemaOneOf() []any {
|
||||
PanelPluginVariant[TablePanelSpec]{Kind: string(PanelKindTable)},
|
||||
PanelPluginVariant[HistogramPanelSpec]{Kind: string(PanelKindHistogram)},
|
||||
PanelPluginVariant[ListPanelSpec]{Kind: string(PanelKindList)},
|
||||
PanelPluginVariant[TextPanelSpec]{Kind: string(PanelKindText)},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,6 +230,7 @@ var (
|
||||
PanelKindTable: func() any { return new(TablePanelSpec) },
|
||||
PanelKindHistogram: func() any { return new(HistogramPanelSpec) },
|
||||
PanelKindList: func() any { return new(ListPanelSpec) },
|
||||
PanelKindText: func() any { return new(TextPanelSpec) },
|
||||
}
|
||||
queryPluginSpecs = map[QueryPluginKind]func() any{
|
||||
QueryKindBuilder: func() any { return new(BuilderQuerySpec) },
|
||||
@@ -250,6 +253,7 @@ var (
|
||||
PanelKindPieChart: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
|
||||
PanelKindTable: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
|
||||
PanelKindList: {QueryKindBuilder},
|
||||
PanelKindText: {},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -172,7 +172,12 @@ func (d *DashboardV2) GetPanelQuery(startTime, endTime uint64, panelKey string)
|
||||
if !ok || panel == nil {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardInvalidInput, "panel with key %q doesn't exist", panelKey)
|
||||
}
|
||||
// Validator guarantees exactly one query per panel.
|
||||
// A panel kind that renders from its own plugin spec has no query to execute;
|
||||
// asking for its query range is a client mistake.
|
||||
if panel.Spec.Plugin.Kind.rendersWithoutQuery() {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardInvalidWidgetQuery, "panel %q is a %q and has no query to execute", panelKey, panel.Spec.Plugin.Kind)
|
||||
}
|
||||
// Validator guarantees exactly one query for every other panel kind.
|
||||
if len(panel.Spec.Queries) != 1 {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardInvalidWidgetQuery, "panel %q must have exactly one query", panelKey)
|
||||
}
|
||||
|
||||
@@ -173,10 +173,15 @@ const (
|
||||
PanelKindTable PanelPluginKind = "signoz/TablePanel"
|
||||
PanelKindHistogram PanelPluginKind = "signoz/HistogramPanel"
|
||||
PanelKindList PanelPluginKind = "signoz/ListPanel"
|
||||
PanelKindText PanelPluginKind = "signoz/TextPanel"
|
||||
)
|
||||
|
||||
func (PanelPluginKind) Enum() []any {
|
||||
return []any{PanelKindTimeSeries, PanelKindBarChart, PanelKindNumber, PanelKindPieChart, PanelKindTable, PanelKindHistogram, PanelKindList}
|
||||
return []any{PanelKindTimeSeries, PanelKindBarChart, PanelKindNumber, PanelKindPieChart, PanelKindTable, PanelKindHistogram, PanelKindList, PanelKindText}
|
||||
}
|
||||
|
||||
func (k PanelPluginKind) rendersWithoutQuery() bool {
|
||||
return k == PanelKindText
|
||||
}
|
||||
|
||||
type TimeSeriesPanelSpec struct {
|
||||
@@ -237,6 +242,18 @@ type ListPanelSpec struct {
|
||||
SelectFields []telemetrytypes.TelemetryFieldKey `json:"selectFields,omitzero" validate:"dive"`
|
||||
}
|
||||
|
||||
type TextPanelSpec struct {
|
||||
Mode TextMode `json:"mode"`
|
||||
Text string `json:"text"`
|
||||
Presentation TextPresentation `json:"presentation"`
|
||||
}
|
||||
|
||||
type TextPresentation struct {
|
||||
TextAlign TextAlign `json:"textAlign"`
|
||||
VerticalAlign VerticalAlign `json:"verticalAlign"`
|
||||
Background PanelBackground `json:"background"`
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════
|
||||
// Panel common types
|
||||
// ══════════════════════════════════════════════
|
||||
@@ -658,6 +675,157 @@ func (sg SpanGaps) validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// TextMode is how a text panel interprets its `text`. Only markdown is
|
||||
// rendered today; further modes (e.g. plain text, HTML) are expected.
|
||||
type TextMode struct{ valuer.String }
|
||||
|
||||
var TextModeMarkdown = TextMode{valuer.NewString("markdown")} // default
|
||||
|
||||
func (TextMode) Enum() []any {
|
||||
return []any{TextModeMarkdown}
|
||||
}
|
||||
|
||||
func (m TextMode) ValueOrDefault() string {
|
||||
if m.IsZero() {
|
||||
return TextModeMarkdown.StringValue()
|
||||
}
|
||||
return m.StringValue()
|
||||
}
|
||||
|
||||
func (m TextMode) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(m.ValueOrDefault())
|
||||
}
|
||||
|
||||
func (m *TextMode) UnmarshalJSON(data []byte) error {
|
||||
var v string
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid text mode: must be the string `markdown`")
|
||||
}
|
||||
tm := TextMode{valuer.NewString(v)}
|
||||
switch tm {
|
||||
case TextModeMarkdown:
|
||||
*m = tm
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid text mode %q: must be `markdown`", v)
|
||||
}
|
||||
}
|
||||
|
||||
type TextAlign struct{ valuer.String }
|
||||
|
||||
var (
|
||||
TextAlignLeft = TextAlign{valuer.NewString("left")} // default
|
||||
TextAlignCenter = TextAlign{valuer.NewString("center")}
|
||||
TextAlignRight = TextAlign{valuer.NewString("right")}
|
||||
)
|
||||
|
||||
func (TextAlign) Enum() []any {
|
||||
return []any{TextAlignLeft, TextAlignCenter, TextAlignRight}
|
||||
}
|
||||
|
||||
func (a TextAlign) ValueOrDefault() string {
|
||||
if a.IsZero() {
|
||||
return TextAlignLeft.StringValue()
|
||||
}
|
||||
return a.StringValue()
|
||||
}
|
||||
|
||||
func (a TextAlign) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(a.ValueOrDefault())
|
||||
}
|
||||
|
||||
func (a *TextAlign) UnmarshalJSON(data []byte) error {
|
||||
var v string
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid text align: must be a string, one of `left`, `center`, or `right`")
|
||||
}
|
||||
val := TextAlign{valuer.NewString(v)}
|
||||
switch val {
|
||||
case TextAlignLeft, TextAlignCenter, TextAlignRight:
|
||||
*a = val
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid text align %q: must be `left`, `center`, or `right`", v)
|
||||
}
|
||||
}
|
||||
|
||||
type VerticalAlign struct{ valuer.String }
|
||||
|
||||
var (
|
||||
VerticalAlignTop = VerticalAlign{valuer.NewString("top")} // default
|
||||
VerticalAlignCenter = VerticalAlign{valuer.NewString("center")}
|
||||
VerticalAlignBottom = VerticalAlign{valuer.NewString("bottom")}
|
||||
)
|
||||
|
||||
func (VerticalAlign) Enum() []any {
|
||||
return []any{VerticalAlignTop, VerticalAlignCenter, VerticalAlignBottom}
|
||||
}
|
||||
|
||||
func (a VerticalAlign) ValueOrDefault() string {
|
||||
if a.IsZero() {
|
||||
return VerticalAlignTop.StringValue()
|
||||
}
|
||||
return a.StringValue()
|
||||
}
|
||||
|
||||
func (a VerticalAlign) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(a.ValueOrDefault())
|
||||
}
|
||||
|
||||
func (a *VerticalAlign) UnmarshalJSON(data []byte) error {
|
||||
var v string
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid vertical align: must be a string, one of `top`, `center`, or `bottom`")
|
||||
}
|
||||
val := VerticalAlign{valuer.NewString(v)}
|
||||
switch val {
|
||||
case VerticalAlignTop, VerticalAlignCenter, VerticalAlignBottom:
|
||||
*a = val
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid vertical align %q: must be `top`, `center`, or `bottom`", v)
|
||||
}
|
||||
}
|
||||
|
||||
// PanelBackground selects the panel's surface: `solid` draws the standard panel
|
||||
// card, `transparent` drops the card so only the content shows.
|
||||
type PanelBackground struct{ valuer.String }
|
||||
|
||||
var (
|
||||
PanelBackgroundSolid = PanelBackground{valuer.NewString("solid")} // default
|
||||
PanelBackgroundTransparent = PanelBackground{valuer.NewString("transparent")}
|
||||
)
|
||||
|
||||
func (PanelBackground) Enum() []any {
|
||||
return []any{PanelBackgroundSolid, PanelBackgroundTransparent}
|
||||
}
|
||||
|
||||
func (b PanelBackground) ValueOrDefault() string {
|
||||
if b.IsZero() {
|
||||
return PanelBackgroundSolid.StringValue()
|
||||
}
|
||||
return b.StringValue()
|
||||
}
|
||||
|
||||
func (b PanelBackground) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(b.ValueOrDefault())
|
||||
}
|
||||
|
||||
func (b *PanelBackground) UnmarshalJSON(data []byte) error {
|
||||
var v string
|
||||
if err := json.Unmarshal(data, &v); err != nil {
|
||||
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid background: must be a string, one of `solid` or `transparent`")
|
||||
}
|
||||
val := PanelBackground{valuer.NewString(v)}
|
||||
switch val {
|
||||
case PanelBackgroundSolid, PanelBackgroundTransparent:
|
||||
*b = val
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid background %q: must be `solid` or `transparent`", v)
|
||||
}
|
||||
}
|
||||
|
||||
type PrecisionOption struct{ valuer.String }
|
||||
|
||||
var (
|
||||
|
||||
@@ -374,7 +374,7 @@ func (q *QueryBuilderQuery[T]) validateAggregations(cfg validationConfig) error
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m MetricAggregation) ValidateForType() error {
|
||||
func (m MetricAggregation) ValidateForTypeAndTemporality() error {
|
||||
if m.SpaceAggregation.IsPercentile() && !m.Type.IsPercentileSpaceAggregationAllowed() {
|
||||
return errors.Newf(
|
||||
errors.TypeInvalidInput,
|
||||
@@ -384,6 +384,17 @@ func (m MetricAggregation) ValidateForType() error {
|
||||
m.Type.StringValue(),
|
||||
)
|
||||
}
|
||||
// reading a step's distribution out of a cumulative sketch would mean
|
||||
// subtracting the previous point's sketch, which ClickHouse cannot do
|
||||
if m.Type == metrictypes.ExpHistogramType && m.Temporality != metrictypes.Delta {
|
||||
return errors.Newf(
|
||||
errors.TypeUnsupported,
|
||||
errors.CodeUnsupported,
|
||||
"metric `%s` is an exponential histogram recorded with `%s` temporality, which cannot be queried; only `delta` exponential histograms are supported",
|
||||
m.MetricName,
|
||||
m.Temporality.StringValue(),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1517,10 +1517,11 @@ func TestNonAggregationFieldsSkipped(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestMetricAggregationValidateForType(t *testing.T) {
|
||||
func TestMetricAggregationValidateForTypeAndTemporality(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
metricType metrictypes.Type
|
||||
temporality metrictypes.Temporality
|
||||
spaceAggregation metrictypes.SpaceAggregation
|
||||
comparisonParam *metrictypes.ComparisonSpaceAggregationParam
|
||||
wantErr bool
|
||||
@@ -1532,11 +1533,33 @@ func TestMetricAggregationValidateForType(t *testing.T) {
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "percentile on exponential histogram is allowed",
|
||||
name: "percentile on delta exponential histogram is allowed",
|
||||
metricType: metrictypes.ExpHistogramType,
|
||||
temporality: metrictypes.Delta,
|
||||
spaceAggregation: metrictypes.SpaceAggregationPercentile99,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "cumulative exponential histogram is not allowed",
|
||||
metricType: metrictypes.ExpHistogramType,
|
||||
temporality: metrictypes.Cumulative,
|
||||
spaceAggregation: metrictypes.SpaceAggregationPercentile99,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "exponential histogram with unresolved temporality is not allowed",
|
||||
metricType: metrictypes.ExpHistogramType,
|
||||
temporality: metrictypes.Unknown,
|
||||
spaceAggregation: metrictypes.SpaceAggregationPercentile99,
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "cumulative histogram is unaffected by the exponential histogram rule",
|
||||
metricType: metrictypes.HistogramType,
|
||||
temporality: metrictypes.Cumulative,
|
||||
spaceAggregation: metrictypes.SpaceAggregationPercentile95,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "percentile on summary is not allowed",
|
||||
metricType: metrictypes.SummaryType,
|
||||
@@ -1562,10 +1585,11 @@ func TestMetricAggregationValidateForType(t *testing.T) {
|
||||
agg := MetricAggregation{
|
||||
MetricName: "test_metric",
|
||||
Type: tc.metricType,
|
||||
Temporality: tc.temporality,
|
||||
SpaceAggregation: tc.spaceAggregation,
|
||||
ComparisonSpaceAggregationParam: tc.comparisonParam,
|
||||
}
|
||||
err := agg.ValidateForType()
|
||||
err := agg.ValidateForTypeAndTemporality()
|
||||
if tc.wantErr && err == nil {
|
||||
t.Errorf("expected error, got nil")
|
||||
}
|
||||
|
||||
@@ -23,10 +23,11 @@ var (
|
||||
const savedViewNameSuffixLen = 8
|
||||
|
||||
var (
|
||||
SourceTraces = Source{valuer.NewString("traces")}
|
||||
SourceLogs = Source{valuer.NewString("logs")}
|
||||
SourceMetrics = Source{valuer.NewString("metrics")}
|
||||
SourceMeter = Source{valuer.NewString("meter")}
|
||||
SourceTraces = Source{valuer.NewString("traces")}
|
||||
SourceLogs = Source{valuer.NewString("logs")}
|
||||
SourceMetrics = Source{valuer.NewString("metrics")}
|
||||
SourceMeter = Source{valuer.NewString("meter")}
|
||||
SourceAIObservability = Source{valuer.NewString("ai_observability")}
|
||||
)
|
||||
|
||||
type SavedView struct {
|
||||
@@ -117,12 +118,13 @@ func (Source) Enum() []any {
|
||||
SourceLogs,
|
||||
SourceMetrics,
|
||||
SourceMeter,
|
||||
SourceAIObservability,
|
||||
}
|
||||
}
|
||||
|
||||
func (s Source) Validate() error {
|
||||
switch s {
|
||||
case SourceTraces, SourceLogs, SourceMetrics, SourceMeter:
|
||||
case SourceTraces, SourceLogs, SourceMetrics, SourceMeter, SourceAIObservability:
|
||||
return nil
|
||||
default:
|
||||
return errors.NewInvalidInputf(ErrCodeSavedViewInvalidInput, "invalid source: %s", s.StringValue())
|
||||
|
||||
@@ -39,6 +39,7 @@ func TestSourceValidate(t *testing.T) {
|
||||
{name: "logs", source: SourceLogs},
|
||||
{name: "metrics", source: SourceMetrics},
|
||||
{name: "meter", source: SourceMeter},
|
||||
{name: "ai_observability", source: SourceAIObservability},
|
||||
{name: "unknown is rejected", source: Source{valuer.NewString("bogus")}, expectError: true},
|
||||
}
|
||||
|
||||
|
||||
@@ -173,6 +173,21 @@ func TestSavedViewSpecValidate(t *testing.T) {
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "builder_ai_query is valid",
|
||||
spec: SavedViewSpec{
|
||||
DisplayName: "My View",
|
||||
PanelType: PanelTypeList,
|
||||
RequestType: qbtypes.RequestTypeRaw,
|
||||
Queries: []qbtypes.QueryEnvelope{{
|
||||
Type: qbtypes.QueryTypeBuilderAI,
|
||||
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
|
||||
Signal: telemetrytypes.SignalTraces,
|
||||
},
|
||||
}},
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "graph panel query with no aggregation is still rejected",
|
||||
spec: SavedViewSpec{
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
// - Use `scope.` prefix to explicitly indicate and enforce scope context. Example
|
||||
// - `scope.name`
|
||||
// - `scope.version`
|
||||
// - `scope.my.custom.attribute` and `scope.attribute.my.custom.attribute` resolve to same attribute
|
||||
// - `scope.my.custom.attribute` resolves to the `my.custom.attribute` scope attribute
|
||||
//
|
||||
// - Use `attribute.` to explicitly indicate and enforce attribute context. Example
|
||||
// - `attribute.http.method`
|
||||
@@ -190,7 +190,7 @@ func (FieldContext) Enum() []any {
|
||||
FieldContextSpan,
|
||||
FieldContextTrace,
|
||||
FieldContextResource,
|
||||
// FieldContextScope,
|
||||
FieldContextScope,
|
||||
FieldContextAttribute,
|
||||
// FieldContextEvent,
|
||||
FieldContextBody,
|
||||
|
||||
@@ -294,6 +294,17 @@ func TestNormalize(t *testing.T) {
|
||||
FieldDataType: FieldDataTypeString,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Normalize keeps a prefix that does not match the set context",
|
||||
input: TelemetryFieldKey{
|
||||
Name: "scope.name",
|
||||
FieldContext: FieldContextAttribute,
|
||||
},
|
||||
expected: TelemetryFieldKey{
|
||||
Name: "scope.name",
|
||||
FieldContext: FieldContextAttribute,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Normalize body field",
|
||||
input: TelemetryFieldKey{
|
||||
|
||||
@@ -19,6 +19,7 @@ pytest_plugins = [
|
||||
"fixtures.traces",
|
||||
"fixtures.metrics",
|
||||
"fixtures.queriercommon",
|
||||
"fixtures.semconvfamilies",
|
||||
"fixtures.metadata",
|
||||
"fixtures.meter",
|
||||
"fixtures.browser",
|
||||
|
||||
459
tests/e2e/fixtures/alerts/alert-history.ts
Normal file
459
tests/e2e/fixtures/alerts/alert-history.ts
Normal file
@@ -0,0 +1,459 @@
|
||||
import type { Browser } from '@playwright/test';
|
||||
|
||||
import {
|
||||
createEmailChannelViaApi,
|
||||
createLogsAlertViaApi,
|
||||
createMetricAlertViaApi,
|
||||
createNoDataAlertViaApi,
|
||||
createTracesAlertViaApi,
|
||||
deleteAlertViaApi,
|
||||
deleteChannelViaApi,
|
||||
setRuleDisabledViaApi,
|
||||
} from '../../helpers/alerts/api';
|
||||
import {
|
||||
readTimelineTotal,
|
||||
waitForTimelineEntries,
|
||||
waitForTimelineStates,
|
||||
} from '../../helpers/alerts/history';
|
||||
import {
|
||||
seedAlertHistoryLogs,
|
||||
seedAlertHistoryMetrics,
|
||||
seedAlertHistoryTraces,
|
||||
} from '../../helpers/alerts/seeding';
|
||||
import { expect, test as base, withAdminPage } from './alert-rules';
|
||||
import {
|
||||
FIXTURE_ALERT_HISTORY_TIMEOUT,
|
||||
FIXTURE_EMPTY_HISTORY_TIMEOUT,
|
||||
FIXTURE_METRICS_HISTORY_TIMEOUT,
|
||||
FIXTURE_NODATA_HISTORY_TIMEOUT,
|
||||
FIXTURE_RESOLVED_HISTORY_TIMEOUT,
|
||||
FIXTURE_TRACES_HISTORY_TIMEOUT,
|
||||
WAIT_METRICS_TIMELINE_TIMEOUT,
|
||||
WAIT_NODATA_TIMELINE_TIMEOUT,
|
||||
} from './timeouts';
|
||||
|
||||
// Worker-scoped alert-history fixtures. Extends `alert-rules`, so a spec that
|
||||
// imports `test` from here also gets `alertChannel` / `alertList` / `ownedRules`
|
||||
// — the details specs need a history seed *and* their own throwaway rules.
|
||||
//
|
||||
// Every history row has to come from the ruler actually evaluating a rule (there
|
||||
// is no seeder endpoint for `rule_state_history_v0`), so each fixture pays a
|
||||
// real ruler wait: ~20-35s for logs, ~10s for metrics, ~105s for firing→resolved.
|
||||
// Worker scope means one wait per worker instead of one per test, and Playwright
|
||||
// creates each fixture lazily — a spec that never asks for `resolvedHistory`
|
||||
// never pays its 105s.
|
||||
|
||||
/** Service count for `alertHistory`. 25 yields multi-page timeline + pagination tests. */
|
||||
const LOGS_HISTORY_SERVICES = 25;
|
||||
|
||||
/** Service count for `resolvedHistory`. 3 services + 1m window = resolves in ~105s. */
|
||||
const RESOLVED_HISTORY_SERVICES = 3;
|
||||
|
||||
/** Hosts for `metricsHistory`. 2 rows fit one page, no related-logs links. */
|
||||
const METRICS_HISTORY_HOSTS = ['host-0', 'host-1'];
|
||||
|
||||
/** Service count for `tracesHistory`. 3 keeps wait short while proving traces link. */
|
||||
const TRACES_HISTORY_SERVICES = 3;
|
||||
|
||||
/** Team label for the v1 rule in `alertHistory`, so its header labels row is non-empty. */
|
||||
export const V1_RULE_TEAM_LABEL = 'e2e-platform';
|
||||
|
||||
export interface AlertHistorySeed {
|
||||
/** v2 (`schemaVersion: v2alpha1`) rule — the default history subject. */
|
||||
ruleId: string;
|
||||
/** Legacy v1 rule over the same logs. Its `threshold.name` is `warning`. */
|
||||
ruleIdV1: string;
|
||||
channelName: string;
|
||||
/** The `body CONTAINS` marker both rules match. */
|
||||
marker: string;
|
||||
/** The seeded `service.name` values, in creation order. */
|
||||
services: string[];
|
||||
/** Baseline `total` for {@link ruleId}, read after the rule was frozen. */
|
||||
total: number;
|
||||
/** Baseline `total` for {@link ruleIdV1}. */
|
||||
totalV1: number;
|
||||
}
|
||||
|
||||
export interface MetricsHistorySeed {
|
||||
ruleId: string;
|
||||
channelName: string;
|
||||
metricName: string;
|
||||
hosts: string[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface TracesHistorySeed {
|
||||
ruleId: string;
|
||||
channelName: string;
|
||||
/** The span `name` the rule matches (`name = '<marker>'`). */
|
||||
marker: string;
|
||||
services: string[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface ResolvedHistorySeed {
|
||||
ruleId: string;
|
||||
channelName: string;
|
||||
marker: string;
|
||||
services: string[];
|
||||
/** Rows in the `firing` state — equals `stats.totalCurrentTriggers`. */
|
||||
firingCount: number;
|
||||
/** Rows in the `inactive` state, i.e. what the `Resolved` filter shows. */
|
||||
resolvedCount: number;
|
||||
}
|
||||
|
||||
export interface NoDataHistorySeed {
|
||||
ruleId: string;
|
||||
channelName: string;
|
||||
}
|
||||
|
||||
export interface EmptyHistorySeed {
|
||||
ruleId: string;
|
||||
channelName: string;
|
||||
}
|
||||
|
||||
async function cleanup(
|
||||
browser: Browser,
|
||||
{ ruleIds, channelId }: { ruleIds: string[]; channelId?: string },
|
||||
): Promise<void> {
|
||||
await withAdminPage(browser, async (page) => {
|
||||
for (const id of ruleIds) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await deleteAlertViaApi(page, id);
|
||||
}
|
||||
if (channelId) {
|
||||
await deleteChannelViaApi(page, channelId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Fixture setup functions ---
|
||||
|
||||
interface HistoryFixtureResult<T> {
|
||||
seed: T;
|
||||
ruleIds: string[];
|
||||
channelId: string;
|
||||
}
|
||||
|
||||
async function createAlertHistorySeed(
|
||||
browser: Browser,
|
||||
): Promise<HistoryFixtureResult<AlertHistorySeed>> {
|
||||
const stamp = Date.now();
|
||||
const marker = `e2e alert history ${stamp}`;
|
||||
|
||||
const result = await withAdminPage(browser, async (page) => {
|
||||
const channel = await createEmailChannelViaApi(page, `e2e-ah-ch-${stamp}`);
|
||||
|
||||
const services = await seedAlertHistoryLogs(page, {
|
||||
marker,
|
||||
services: LOGS_HISTORY_SERVICES,
|
||||
servicePrefix: `e2e-ah-svc`,
|
||||
});
|
||||
|
||||
const ruleId = await createLogsAlertViaApi(page, {
|
||||
name: `e2e-ah-rule-v2-${stamp}`,
|
||||
marker,
|
||||
channels: [channel.name],
|
||||
schema: 'v2',
|
||||
});
|
||||
const ruleIdV1 = await createLogsAlertViaApi(page, {
|
||||
name: `e2e-ah-rule-v1-${stamp}`,
|
||||
marker,
|
||||
channels: [channel.name],
|
||||
schema: 'v1',
|
||||
extraLabels: { team: V1_RULE_TEAM_LABEL },
|
||||
});
|
||||
|
||||
await waitForTimelineEntries(page, ruleId, { min: LOGS_HISTORY_SERVICES });
|
||||
await waitForTimelineEntries(page, ruleIdV1, { min: LOGS_HISTORY_SERVICES });
|
||||
|
||||
await setRuleDisabledViaApi(page, ruleId, true);
|
||||
await setRuleDisabledViaApi(page, ruleIdV1, true);
|
||||
|
||||
return {
|
||||
seed: {
|
||||
ruleId,
|
||||
ruleIdV1,
|
||||
channelName: channel.name,
|
||||
marker,
|
||||
services,
|
||||
total: await readTimelineTotal(page, ruleId),
|
||||
totalV1: await readTimelineTotal(page, ruleIdV1),
|
||||
},
|
||||
ruleIds: [ruleId, ruleIdV1],
|
||||
channelId: channel.id,
|
||||
};
|
||||
});
|
||||
|
||||
if (result.seed.total !== LOGS_HISTORY_SERVICES) {
|
||||
throw new Error(
|
||||
`alertHistory expected ${LOGS_HISTORY_SERVICES} timeline rows, got ${result.seed.total}`,
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function createMetricsHistorySeed(
|
||||
browser: Browser,
|
||||
): Promise<HistoryFixtureResult<MetricsHistorySeed>> {
|
||||
const stamp = Date.now();
|
||||
const metricName = `e2e_ah_probe_metric_${stamp}`;
|
||||
|
||||
return withAdminPage(browser, async (page) => {
|
||||
const channel = await createEmailChannelViaApi(
|
||||
page,
|
||||
`e2e-ah-metrics-ch-${stamp}`,
|
||||
);
|
||||
|
||||
await seedAlertHistoryMetrics(page, {
|
||||
metricName,
|
||||
hosts: METRICS_HISTORY_HOSTS,
|
||||
});
|
||||
|
||||
const ruleId = await createMetricAlertViaApi(page, {
|
||||
name: `e2e-ah-metrics-rule-${stamp}`,
|
||||
metricName,
|
||||
channels: [channel.name],
|
||||
});
|
||||
|
||||
await waitForTimelineEntries(page, ruleId, {
|
||||
min: METRICS_HISTORY_HOSTS.length,
|
||||
timeoutMs: WAIT_METRICS_TIMELINE_TIMEOUT,
|
||||
});
|
||||
await setRuleDisabledViaApi(page, ruleId, true);
|
||||
|
||||
return {
|
||||
seed: {
|
||||
ruleId,
|
||||
channelName: channel.name,
|
||||
metricName,
|
||||
hosts: METRICS_HISTORY_HOSTS,
|
||||
total: await readTimelineTotal(page, ruleId),
|
||||
},
|
||||
ruleIds: [ruleId],
|
||||
channelId: channel.id,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function createTracesHistorySeed(
|
||||
browser: Browser,
|
||||
): Promise<HistoryFixtureResult<TracesHistorySeed>> {
|
||||
const stamp = Date.now();
|
||||
const marker = `e2e-aht-span-${stamp}`;
|
||||
|
||||
return withAdminPage(browser, async (page) => {
|
||||
const channel = await createEmailChannelViaApi(
|
||||
page,
|
||||
`e2e-ah-traces-ch-${stamp}`,
|
||||
);
|
||||
|
||||
const services = await seedAlertHistoryTraces(page, {
|
||||
marker,
|
||||
services: TRACES_HISTORY_SERVICES,
|
||||
servicePrefix: 'e2e-aht-svc',
|
||||
});
|
||||
|
||||
const ruleId = await createTracesAlertViaApi(page, {
|
||||
name: `e2e-ah-traces-rule-${stamp}`,
|
||||
marker,
|
||||
channels: [channel.name],
|
||||
});
|
||||
|
||||
await waitForTimelineEntries(page, ruleId, { min: TRACES_HISTORY_SERVICES });
|
||||
await setRuleDisabledViaApi(page, ruleId, true);
|
||||
|
||||
return {
|
||||
seed: {
|
||||
ruleId,
|
||||
channelName: channel.name,
|
||||
marker,
|
||||
services,
|
||||
total: await readTimelineTotal(page, ruleId),
|
||||
},
|
||||
ruleIds: [ruleId],
|
||||
channelId: channel.id,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function createResolvedHistorySeed(
|
||||
browser: Browser,
|
||||
): Promise<HistoryFixtureResult<ResolvedHistorySeed>> {
|
||||
const stamp = Date.now();
|
||||
const marker = `e2e alert resolved ${stamp}`;
|
||||
|
||||
return withAdminPage(browser, async (page) => {
|
||||
const channel = await createEmailChannelViaApi(
|
||||
page,
|
||||
`e2e-ah-resolved-ch-${stamp}`,
|
||||
);
|
||||
|
||||
const services = await seedAlertHistoryLogs(page, {
|
||||
marker,
|
||||
services: RESOLVED_HISTORY_SERVICES,
|
||||
ageSeconds: 40,
|
||||
minAgeSeconds: 28,
|
||||
servicePrefix: 'e2e-ahr-svc',
|
||||
});
|
||||
|
||||
const ruleId = await createLogsAlertViaApi(page, {
|
||||
name: `e2e-ah-resolved-rule-${stamp}`,
|
||||
marker,
|
||||
channels: [channel.name],
|
||||
evalWindow: '1m0s',
|
||||
});
|
||||
|
||||
const timeline = await waitForTimelineStates(page, ruleId, {
|
||||
states: {
|
||||
firing: RESOLVED_HISTORY_SERVICES,
|
||||
inactive: RESOLVED_HISTORY_SERVICES,
|
||||
},
|
||||
});
|
||||
await setRuleDisabledViaApi(page, ruleId, true);
|
||||
|
||||
return {
|
||||
seed: {
|
||||
ruleId,
|
||||
channelName: channel.name,
|
||||
marker,
|
||||
services,
|
||||
firingCount: timeline.items.filter((i) => i.state === 'firing').length,
|
||||
resolvedCount: timeline.items.filter((i) => i.state === 'inactive').length,
|
||||
},
|
||||
ruleIds: [ruleId],
|
||||
channelId: channel.id,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function createNoDataHistorySeed(
|
||||
browser: Browser,
|
||||
): Promise<HistoryFixtureResult<NoDataHistorySeed>> {
|
||||
const stamp = Date.now();
|
||||
|
||||
return withAdminPage(browser, async (page) => {
|
||||
const channel = await createEmailChannelViaApi(
|
||||
page,
|
||||
`e2e-ah-nodata-ch-${stamp}`,
|
||||
);
|
||||
|
||||
const ruleId = await createNoDataAlertViaApi(page, {
|
||||
name: `e2e-ah-nodata-rule-${stamp}`,
|
||||
marker: `e2e alert nodata ${stamp}`,
|
||||
channels: [channel.name],
|
||||
});
|
||||
|
||||
await waitForTimelineEntries(page, ruleId, {
|
||||
min: 1,
|
||||
state: 'nodata',
|
||||
timeoutMs: WAIT_NODATA_TIMELINE_TIMEOUT,
|
||||
});
|
||||
await setRuleDisabledViaApi(page, ruleId, true);
|
||||
|
||||
return {
|
||||
seed: { ruleId, channelName: channel.name },
|
||||
ruleIds: [ruleId],
|
||||
channelId: channel.id,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function createEmptyHistorySeed(
|
||||
browser: Browser,
|
||||
): Promise<HistoryFixtureResult<EmptyHistorySeed>> {
|
||||
const stamp = Date.now();
|
||||
|
||||
return withAdminPage(browser, async (page) => {
|
||||
const channel = await createEmailChannelViaApi(
|
||||
page,
|
||||
`e2e-ah-empty-ch-${stamp}`,
|
||||
);
|
||||
|
||||
const ruleId = await createLogsAlertViaApi(page, {
|
||||
name: `e2e-ah-empty-rule-${stamp}`,
|
||||
marker: `e2e alert never seeded ${stamp}`,
|
||||
channels: [channel.name],
|
||||
});
|
||||
await setRuleDisabledViaApi(page, ruleId, true);
|
||||
|
||||
return {
|
||||
seed: { ruleId, channelName: channel.name },
|
||||
ruleIds: [ruleId],
|
||||
channelId: channel.id,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// --- Fixture definitions ---
|
||||
|
||||
export const test = base.extend<
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
{},
|
||||
{
|
||||
alertHistory: AlertHistorySeed;
|
||||
metricsHistory: MetricsHistorySeed;
|
||||
tracesHistory: TracesHistorySeed;
|
||||
resolvedHistory: ResolvedHistorySeed;
|
||||
noDataHistory: NoDataHistorySeed;
|
||||
emptyHistory: EmptyHistorySeed;
|
||||
}
|
||||
>({
|
||||
alertHistory: [
|
||||
async ({ browser }, use) => {
|
||||
const { seed, ruleIds, channelId } = await createAlertHistorySeed(browser);
|
||||
await use(seed);
|
||||
await cleanup(browser, { ruleIds, channelId });
|
||||
},
|
||||
{ scope: 'worker', timeout: FIXTURE_ALERT_HISTORY_TIMEOUT },
|
||||
],
|
||||
|
||||
metricsHistory: [
|
||||
async ({ browser }, use) => {
|
||||
const { seed, ruleIds, channelId } = await createMetricsHistorySeed(browser);
|
||||
await use(seed);
|
||||
await cleanup(browser, { ruleIds, channelId });
|
||||
},
|
||||
{ scope: 'worker', timeout: FIXTURE_METRICS_HISTORY_TIMEOUT },
|
||||
],
|
||||
|
||||
tracesHistory: [
|
||||
async ({ browser }, use) => {
|
||||
const { seed, ruleIds, channelId } = await createTracesHistorySeed(browser);
|
||||
await use(seed);
|
||||
await cleanup(browser, { ruleIds, channelId });
|
||||
},
|
||||
{ scope: 'worker', timeout: FIXTURE_TRACES_HISTORY_TIMEOUT },
|
||||
],
|
||||
|
||||
resolvedHistory: [
|
||||
async ({ browser }, use) => {
|
||||
const { seed, ruleIds, channelId } =
|
||||
await createResolvedHistorySeed(browser);
|
||||
await use(seed);
|
||||
await cleanup(browser, { ruleIds, channelId });
|
||||
},
|
||||
{ scope: 'worker', timeout: FIXTURE_RESOLVED_HISTORY_TIMEOUT },
|
||||
],
|
||||
|
||||
noDataHistory: [
|
||||
async ({ browser }, use) => {
|
||||
const { seed, ruleIds, channelId } = await createNoDataHistorySeed(browser);
|
||||
await use(seed);
|
||||
await cleanup(browser, { ruleIds, channelId });
|
||||
},
|
||||
{ scope: 'worker', timeout: FIXTURE_NODATA_HISTORY_TIMEOUT },
|
||||
],
|
||||
|
||||
emptyHistory: [
|
||||
async ({ browser }, use) => {
|
||||
const { seed, ruleIds, channelId } = await createEmptyHistorySeed(browser);
|
||||
await use(seed);
|
||||
await cleanup(browser, { ruleIds, channelId });
|
||||
},
|
||||
{ scope: 'worker', timeout: FIXTURE_EMPTY_HISTORY_TIMEOUT },
|
||||
],
|
||||
});
|
||||
|
||||
export { expect };
|
||||
245
tests/e2e/fixtures/alerts/alert-rules.ts
Normal file
245
tests/e2e/fixtures/alerts/alert-rules.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
import type { Browser, Page } from '@playwright/test';
|
||||
|
||||
import {
|
||||
createEmailChannelViaApi,
|
||||
createLogsAlertViaApi,
|
||||
createThresholdAlertViaApi,
|
||||
deleteAlertViaApi,
|
||||
deleteChannelViaApi,
|
||||
} from '../../helpers/alerts/api';
|
||||
import { seedAlertRules } from '../../helpers/alerts/seeding';
|
||||
import type {
|
||||
AlertSchema,
|
||||
LogsAlertSeed,
|
||||
ThresholdAlertSeed,
|
||||
} from '../../helpers/alerts/types';
|
||||
import { newAdminContext } from '../../helpers/auth';
|
||||
import { expect, test as base } from '../auth';
|
||||
import { FIXTURE_ALERT_LIST_TIMEOUT } from './timeouts';
|
||||
|
||||
// Alert *rule* fixtures — the API-only half of the alerts suite. Nothing here
|
||||
// waits on the ruler: a rule is created and that's it. History rows need real
|
||||
// evaluations, so those fixtures live in `alert-history.ts`, which
|
||||
// extends this module — a spec importing from there gets both sets.
|
||||
//
|
||||
// Scopes, and why:
|
||||
// `alertChannel` — worker. Every rule payload has to reference a channel by
|
||||
// name, and one channel serves the whole worker.
|
||||
// `alertList` — worker. Read-only rule list the `tests/alerts/list` specs
|
||||
// page, search and sort through. Names and label values are stamped per
|
||||
// worker so parallel batches never count each other's rules.
|
||||
// `ownedRules` — test. Scenarios that rename/toggle/clone/delete a rule seed
|
||||
// their own and have it removed when they finish; mutating a shared seed
|
||||
// would break every scenario scheduled after it.
|
||||
|
||||
export interface AlertChannel {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface AlertListSeed {
|
||||
channelName: string;
|
||||
/** Rules are named `<namePrefix>-NN` — unique to this worker's batch. */
|
||||
namePrefix: string;
|
||||
/** Rules seeded ⇒ the `of N` total once the list is scoped to the prefix. */
|
||||
count: number;
|
||||
/** `team` label on the odd-indexed half of the batch, i.e. `count / 2` rules. */
|
||||
paymentsLabel: string;
|
||||
ruleIds: string[];
|
||||
}
|
||||
|
||||
export interface OwnedRules {
|
||||
/** Seed a metric threshold rule this test owns. */
|
||||
threshold(
|
||||
name: string,
|
||||
overrides?: Partial<Omit<ThresholdAlertSeed, 'name'>>,
|
||||
): Promise<string>;
|
||||
/**
|
||||
* Seed a logs rule this test owns. No telemetry is seeded for its marker, so
|
||||
* it never fires — enough for anything about the details shell.
|
||||
*
|
||||
* `schema: 'v1'` posts the legacy payload; the condition overrides exist so
|
||||
* a v1 *prefill* assertion can be made against values the create form would
|
||||
* not have produced by itself.
|
||||
*/
|
||||
logs(
|
||||
options: {
|
||||
name: string;
|
||||
schema?: AlertSchema;
|
||||
marker?: string;
|
||||
} & Partial<
|
||||
Pick<
|
||||
LogsAlertSeed,
|
||||
'severity' | 'extraLabels' | 'evalWindow' | 'target' | 'op' | 'matchType'
|
||||
>
|
||||
>,
|
||||
): Promise<string>;
|
||||
/**
|
||||
* Track a rule the *app* created (Clone / Duplicate) so teardown removes it
|
||||
* too. Lives here because the id may legitimately be missing and a
|
||||
* conditional inside a test body is a lint error.
|
||||
*/
|
||||
register(response: { json: () => Promise<unknown> }): Promise<void>;
|
||||
}
|
||||
|
||||
/** alertList size. 12 over page size 10 = short second page for pagination tests. */
|
||||
const LIST_SEED_COUNT = 12;
|
||||
|
||||
/**
|
||||
* Run `body` on a throwaway admin page. Worker hooks can't use the test-scoped
|
||||
* `authedPage`, and every API helper needs a page whose context carries the
|
||||
* admin storage state.
|
||||
*/
|
||||
export async function withAdminPage<T>(
|
||||
browser: Browser,
|
||||
body: (page: Page) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const ctx = await newAdminContext(browser);
|
||||
const page = await ctx.newPage();
|
||||
try {
|
||||
return await body(page);
|
||||
} finally {
|
||||
await ctx.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteRules(browser: Browser, ids: string[]): Promise<void> {
|
||||
if (ids.length === 0) {
|
||||
return;
|
||||
}
|
||||
await withAdminPage(browser, async (page) => {
|
||||
for (const id of ids) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await deleteAlertViaApi(page, id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Fixture setup/teardown functions ---
|
||||
|
||||
async function createAlertChannel(
|
||||
browser: Browser,
|
||||
workerIndex: number,
|
||||
): Promise<AlertChannel> {
|
||||
return withAdminPage(browser, (page) =>
|
||||
createEmailChannelViaApi(page, `e2e-alerts-ch-w${workerIndex}-${Date.now()}`),
|
||||
);
|
||||
}
|
||||
|
||||
async function deleteAlertChannel(
|
||||
browser: Browser,
|
||||
channelId: string,
|
||||
): Promise<void> {
|
||||
await withAdminPage(browser, (page) => deleteChannelViaApi(page, channelId));
|
||||
}
|
||||
|
||||
async function createAlertList(
|
||||
browser: Browser,
|
||||
channelName: string,
|
||||
workerIndex: number,
|
||||
): Promise<AlertListSeed> {
|
||||
const stamp = `w${workerIndex}-${Date.now()}`;
|
||||
const namePrefix = `e2e-alert-list-${stamp}`;
|
||||
const teamSuffix = `-${stamp}`;
|
||||
|
||||
const ruleIds = await withAdminPage(browser, (page) =>
|
||||
seedAlertRules(page, {
|
||||
count: LIST_SEED_COUNT,
|
||||
channelName,
|
||||
namePrefix,
|
||||
teamSuffix,
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
channelName,
|
||||
namePrefix,
|
||||
count: LIST_SEED_COUNT,
|
||||
paymentsLabel: `payments${teamSuffix}`,
|
||||
ruleIds,
|
||||
};
|
||||
}
|
||||
|
||||
function createOwnedRulesFactory(
|
||||
browser: Browser,
|
||||
channelName: string,
|
||||
ids: Set<string>,
|
||||
): OwnedRules {
|
||||
const seed = async (
|
||||
create: (page: Page) => Promise<string>,
|
||||
): Promise<string> => {
|
||||
const id = await withAdminPage(browser, create);
|
||||
ids.add(id);
|
||||
return id;
|
||||
};
|
||||
|
||||
return {
|
||||
threshold: (name, overrides = {}) =>
|
||||
seed((page) =>
|
||||
createThresholdAlertViaApi(page, {
|
||||
name,
|
||||
target: 42,
|
||||
channels: [channelName],
|
||||
labels: { severity: 'critical' },
|
||||
...overrides,
|
||||
}),
|
||||
),
|
||||
|
||||
logs: ({ name, schema = 'v2', marker, ...overrides }) =>
|
||||
seed((page) =>
|
||||
createLogsAlertViaApi(page, {
|
||||
name,
|
||||
marker: marker ?? `e2e alert never seeded ${name}`,
|
||||
channels: [channelName],
|
||||
schema,
|
||||
...overrides,
|
||||
}),
|
||||
),
|
||||
|
||||
register: async (response) => {
|
||||
const body = (await response.json()) as { data?: { id?: string } };
|
||||
const id = body.data?.id;
|
||||
if (id) {
|
||||
ids.add(String(id));
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// --- Fixture definitions ---
|
||||
|
||||
export const test = base.extend<
|
||||
{ ownedRules: OwnedRules },
|
||||
{ alertChannel: AlertChannel; alertList: AlertListSeed }
|
||||
>({
|
||||
alertChannel: [
|
||||
async ({ browser }, use, workerInfo) => {
|
||||
const channel = await createAlertChannel(browser, workerInfo.workerIndex);
|
||||
await use(channel);
|
||||
await deleteAlertChannel(browser, channel.id);
|
||||
},
|
||||
{ scope: 'worker' },
|
||||
],
|
||||
|
||||
alertList: [
|
||||
async ({ browser, alertChannel }, use, workerInfo) => {
|
||||
const seed = await createAlertList(
|
||||
browser,
|
||||
alertChannel.name,
|
||||
workerInfo.workerIndex,
|
||||
);
|
||||
await use(seed);
|
||||
await deleteRules(browser, seed.ruleIds);
|
||||
},
|
||||
{ scope: 'worker', timeout: FIXTURE_ALERT_LIST_TIMEOUT },
|
||||
],
|
||||
|
||||
ownedRules: async ({ browser, alertChannel }, use) => {
|
||||
const ids = new Set<string>();
|
||||
const factory = createOwnedRulesFactory(browser, alertChannel.name, ids);
|
||||
await use(factory);
|
||||
await deleteRules(browser, [...ids]);
|
||||
},
|
||||
});
|
||||
|
||||
export { expect };
|
||||
96
tests/e2e/fixtures/alerts/timeouts.ts
Normal file
96
tests/e2e/fixtures/alerts/timeouts.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Centralized timeout constants for alert fixtures.
|
||||
*
|
||||
* **Ruler**: SigNoz's alert evaluation engine. Runs on ~15s cycles, checks each
|
||||
* rule's query against ClickHouse, writes results to `rule_state_history_v0`.
|
||||
* There's no API to force-evaluate or seed history directly, so fixtures must
|
||||
* poll the timeline endpoint until the ruler writes rows.
|
||||
*
|
||||
* Fixture timeouts are set generously because:
|
||||
* 1. CI environments are slower than local dev machines
|
||||
* 2. Ruler evaluation depends on Kafka/ClickHouse latency
|
||||
* 3. A timeout should mean "something is broken", not "it's just slow today"
|
||||
*
|
||||
* Typical measured times (local):
|
||||
* - API call (create/delete rule/channel): ~1-2s
|
||||
* - waitForTimelineEntries (logs, 25 services): ~20-35s
|
||||
* - waitForTimelineEntries (metrics, 2 hosts): ~10s
|
||||
* - waitForTimelineStates (firing→resolved): ~105s
|
||||
* - waitForTimelineEntries (nodata state): ~60-120s
|
||||
*/
|
||||
|
||||
// ─── Fixture-specific wait overrides (ms) ──────────────────────────────
|
||||
|
||||
/**
|
||||
* Metrics history wait override. Metrics push faster than logs, but ruler still
|
||||
* needs 2 evaluation windows to confirm state. 10s typical, 120s defensive.
|
||||
*/
|
||||
export const WAIT_METRICS_TIMELINE_TIMEOUT = 120_000;
|
||||
|
||||
/**
|
||||
* Nodata state detection. Ruler must evaluate twice with empty result set.
|
||||
* Takes longer than firing detection because it's an absence check.
|
||||
*/
|
||||
export const WAIT_NODATA_TIMELINE_TIMEOUT = 180_000;
|
||||
|
||||
// ─── Fixture timeouts (ms) ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* alertList: seeds 12 rules via API.
|
||||
*
|
||||
* Breakdown: createChannel(2s) + 12×createRule(24s) = ~26s.
|
||||
* Timeout: 120s (~5x headroom for CI).
|
||||
*/
|
||||
export const FIXTURE_ALERT_LIST_TIMEOUT = 120_000;
|
||||
|
||||
/**
|
||||
* alertHistory: seeds 25 logs + 2 rules, waits for ruler evaluation.
|
||||
*
|
||||
* Breakdown: createChannel(2s) + seedLogs(10s) + 2×createRule(4s) +
|
||||
* 2×waitForEntries(70s) + 2×disableRule(4s) = ~90s.
|
||||
* Timeout: 240s (~2.5x headroom).
|
||||
*/
|
||||
export const FIXTURE_ALERT_HISTORY_TIMEOUT = 240_000;
|
||||
|
||||
/**
|
||||
* metricsHistory: seeds 2 hosts, waits for metrics ruler cycle.
|
||||
*
|
||||
* Breakdown: createChannel(2s) + seedMetrics(5s) + createRule(2s) +
|
||||
* waitForEntries(10s actual, 120s budget) + disableRule(2s) = ~21s.
|
||||
* Timeout: 240s (matches alertHistory for consistency).
|
||||
*/
|
||||
export const FIXTURE_METRICS_HISTORY_TIMEOUT = 240_000;
|
||||
|
||||
/**
|
||||
* tracesHistory: seeds 3 trace services, waits for ruler.
|
||||
*
|
||||
* Breakdown: similar to alertHistory but fewer services = ~45s.
|
||||
* Timeout: 240s (~5x headroom).
|
||||
*/
|
||||
export const FIXTURE_TRACES_HISTORY_TIMEOUT = 240_000;
|
||||
|
||||
/**
|
||||
* resolvedHistory: waits for firing→resolved transition.
|
||||
*
|
||||
* Breakdown: setup(30s) + waitForStates(105s) = ~135s.
|
||||
* Timeout: 300s (~2x headroom). Longest because resolved requires
|
||||
* evalWindow expiry after data stops matching.
|
||||
*/
|
||||
export const FIXTURE_RESOLVED_HISTORY_TIMEOUT = 300_000;
|
||||
|
||||
/**
|
||||
* noDataHistory: waits for nodata state to appear.
|
||||
*
|
||||
* Breakdown: createChannel(2s) + createRule(2s) + waitForEntries(60-120s).
|
||||
* Timeout: 300s. Nodata detection is slowest because ruler must confirm
|
||||
* absence across multiple evaluation cycles.
|
||||
*/
|
||||
export const FIXTURE_NODATA_HISTORY_TIMEOUT = 300_000;
|
||||
|
||||
/**
|
||||
* emptyHistory: creates rule then immediately disables it (no ruler wait).
|
||||
*
|
||||
* Breakdown: createChannel(2s) + createRule(2s) + disableRule(2s) = ~6s.
|
||||
* Timeout: 120s (generous for slow CI, no ruler dependency).
|
||||
*/
|
||||
export const FIXTURE_EMPTY_HISTORY_TIMEOUT = 120_000;
|
||||
@@ -1,81 +1,11 @@
|
||||
import {
|
||||
test as base,
|
||||
expect,
|
||||
type Browser,
|
||||
type BrowserContext,
|
||||
type Page,
|
||||
} from '@playwright/test';
|
||||
import { test as base, expect, type Page } from '@playwright/test';
|
||||
|
||||
export type User = { email: string; password: string };
|
||||
import { ADMIN, storageStateFor, type User } from '../helpers/auth';
|
||||
|
||||
// Default user — admin from the pytest bootstrap (.env.local) or staging .env.
|
||||
export const ADMIN: User = {
|
||||
email: process.env.SIGNOZ_E2E_USERNAME!,
|
||||
password: process.env.SIGNOZ_E2E_PASSWORD!,
|
||||
};
|
||||
|
||||
// Per-worker storageState cache. One login per unique user per worker.
|
||||
// Promise-valued so concurrent requests share the same in-flight work.
|
||||
// Held in memory only — no .auth/ dir, no JSON on disk.
|
||||
type StorageState = Awaited<ReturnType<BrowserContext['storageState']>>;
|
||||
const storageByUser = new Map<string, Promise<StorageState>>();
|
||||
|
||||
async function storageFor(browser: Browser, user: User): Promise<StorageState> {
|
||||
const cached = storageByUser.get(user.email);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const task = (async () => {
|
||||
const ctx = await browser.newContext();
|
||||
const page = await ctx.newPage();
|
||||
await login(page, user);
|
||||
await pinSidenav(page);
|
||||
const state = await ctx.storageState();
|
||||
await ctx.close();
|
||||
return state;
|
||||
})();
|
||||
|
||||
storageByUser.set(user.email, task);
|
||||
return task;
|
||||
}
|
||||
|
||||
async function login(page: Page, user: User): Promise<void> {
|
||||
if (!user.email || !user.password) {
|
||||
throw new Error(
|
||||
'User credentials missing. Set SIGNOZ_E2E_USERNAME / SIGNOZ_E2E_PASSWORD ' +
|
||||
'(pytest bootstrap writes them to .env.local), or pass a User via test.use({ user: ... }).',
|
||||
);
|
||||
}
|
||||
await page.goto('/login?password=Y');
|
||||
await page.getByTestId('email').fill(user.email);
|
||||
await page.getByTestId('initiate_login').click();
|
||||
await page.getByTestId('password').fill(user.password);
|
||||
await page.getByRole('button', { name: 'Sign in with Password' }).click();
|
||||
// Post-login lands somewhere different depending on whether the org is
|
||||
// licensed (onboarding flow on ENTERPRISE) or not (legacy "Hello there"
|
||||
// welcome). Wait for URL to move off /login — whichever page follows
|
||||
// is fine, each spec navigates to the feature under test anyway.
|
||||
await page.waitForURL((url) => !url.pathname.startsWith('/login'));
|
||||
}
|
||||
|
||||
// Pin the nav suite-wide: unpinned it flies out on hover and overlays content.
|
||||
// Server-side pref, so set once per user at login.
|
||||
async function pinSidenav(page: Page): Promise<void> {
|
||||
const token = await page.evaluate(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
() => (globalThis as any).localStorage.getItem('AUTH_TOKEN') || '',
|
||||
);
|
||||
const res = await page.request.put('/api/v1/user/preferences/sidenav_pinned', {
|
||||
data: { value: true },
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!res.ok()) {
|
||||
throw new Error(
|
||||
`PUT /api/v1/user/preferences/sidenav_pinned ${res.status()}: ${await res.text()}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
// 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<{
|
||||
/**
|
||||
@@ -95,7 +25,7 @@ export const test = base.extend<{
|
||||
user: [ADMIN, { option: true }],
|
||||
|
||||
authedPage: async ({ browser, user }, use) => {
|
||||
const storageState = await storageFor(browser, user);
|
||||
const storageState = await storageStateFor(browser, user);
|
||||
const ctx = await browser.newContext({ storageState });
|
||||
const page = await ctx.newPage();
|
||||
// Opt-in CPU throttling to reproduce GitHub-Linux-runner conditions on
|
||||
|
||||
105
tests/e2e/helpers/alert-forms/constants.ts
Normal file
105
tests/e2e/helpers/alert-forms/constants.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
// ─── Routes ────────────────────────────────────────────────────────────────
|
||||
|
||||
export const ALERTS_NEW_PATH = '/alerts/new';
|
||||
|
||||
/**
|
||||
* The standalone edit route. Distinct from `/alerts/overview`, which renders the
|
||||
* *same* editor inside the details shell. The two are not interchangeable for v2
|
||||
* rules — see `edit/v2.spec.ts` EV2-12.
|
||||
*/
|
||||
export const ALERT_EDIT_PATH = '/alerts/edit';
|
||||
|
||||
// ─── Enums mirrored from the frontend ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* URL values of `AlertTypes` (`frontend/src/types/api/alerts/alertTypes.ts`).
|
||||
* Note `METRICS` maps to the *singular* `METRIC_BASED_ALERT` — the enum key and
|
||||
* its value disagree in the source, and the URL carries the value.
|
||||
*/
|
||||
export const AlertType = {
|
||||
METRICS: 'METRIC_BASED_ALERT',
|
||||
LOGS: 'LOGS_BASED_ALERT',
|
||||
TRACES: 'TRACES_BASED_ALERT',
|
||||
EXCEPTIONS: 'EXCEPTIONS_BASED_ALERT',
|
||||
ANOMALY: 'ANOMALY_BASED_ALERT',
|
||||
} as const;
|
||||
|
||||
export type AlertTypeValue = (typeof AlertType)[keyof typeof AlertType];
|
||||
|
||||
/** `AlertDetectionTypes` (`frontend/src/container/FormAlertRules/index.tsx:78-81`). */
|
||||
export const RuleType = {
|
||||
THRESHOLD: 'threshold_rule',
|
||||
ANOMALY: 'anomaly_rule',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* `AlertThresholdOperator` (`CreateAlertV2/context/types.ts:97-105`) and its
|
||||
* dropdown labels (`context/constants.ts:123-137`).
|
||||
*
|
||||
* Threshold-alert operators only. Anomaly alerts render a different, shorter
|
||||
* list (`ANOMALY_THRESHOLD_OPERATOR_OPTIONS`) with relabelled entries.
|
||||
*/
|
||||
export const ThresholdOperator = {
|
||||
ABOVE: { value: 'above', label: 'ABOVE' },
|
||||
BELOW: { value: 'below', label: 'BELOW' },
|
||||
EQUAL_TO: { value: 'equal', label: 'EQUAL TO' },
|
||||
NOT_EQUAL_TO: { value: 'not_equal', label: 'NOT EQUAL TO' },
|
||||
ABOVE_OR_EQUAL_TO: { value: 'above_or_equal', label: 'ABOVE OR EQUAL TO' },
|
||||
BELOW_OR_EQUAL_TO: { value: 'below_or_equal', label: 'BELOW OR EQUAL TO' },
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* `AlertThresholdMatchType` (`CreateAlertV2/context/types.ts:105-111`) and its
|
||||
* dropdown labels (`context/constants.ts:136-142`).
|
||||
*
|
||||
* Watch the plural: the enum *key* is `ALL_THE_TIME` but the wire value is
|
||||
* `all_the_times`, and the API rejects the singular outright — the same
|
||||
* key/value mismatch as `METRICS_BASED_ALERT` → `METRIC_BASED_ALERT`.
|
||||
*/
|
||||
export const ThresholdMatchType = {
|
||||
AT_LEAST_ONCE: { value: 'at_least_once', label: 'AT LEAST ONCE' },
|
||||
ALL_THE_TIME: { value: 'all_the_times', label: 'ALL THE TIME' },
|
||||
ON_AVERAGE: { value: 'on_average', label: 'ON AVERAGE' },
|
||||
IN_TOTAL: { value: 'in_total', label: 'IN TOTAL' },
|
||||
LAST: { value: 'last', label: 'LAST' },
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* `AlertListTabs` (`frontend/src/pages/AlertList/types.ts:7-9`). The values are
|
||||
* space-less — the tab *labels* read "Triggered Alerts" but the `tab` URL param
|
||||
* is `TriggeredAlerts`, and asserting the label form silently fails.
|
||||
*/
|
||||
export const AlertListTab = {
|
||||
TRIGGERED_ALERTS: 'TriggeredAlerts',
|
||||
ALERT_RULES: 'AlertRules',
|
||||
CONFIGURATION: 'Configuration',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* The four cards a stock stack shows, in render order
|
||||
* (`CreateAlertRule/SelectAlertType/config.ts:10-31`). Anomaly is `unshift`ed to
|
||||
* the **front** of this list when the `ANOMALY_DETECTION` feature flag is active,
|
||||
* so both the count and the order change when it is enabled.
|
||||
*/
|
||||
export const STOCK_ALERT_TYPE_CARDS: AlertTypeValue[] = [
|
||||
AlertType.METRICS,
|
||||
AlertType.LOGS,
|
||||
AlertType.TRACES,
|
||||
AlertType.EXCEPTIONS,
|
||||
];
|
||||
|
||||
/**
|
||||
* Rolling-window presets (`EvaluationSettings/constants.ts:9-18`) paired with the
|
||||
* button label each one produces. A value *outside* this set collapses to `custom`
|
||||
* on load (`utils.tsx:86-96`), which is what makes it a prefill assertion worth
|
||||
* having: `10m0s` proves the seed was read, `7m0s` proves the fallback fired.
|
||||
*/
|
||||
export const EVALUATION_WINDOW_PRESETS = {
|
||||
'5m0s': 'Last 5 minutes',
|
||||
'10m0s': 'Last 10 minutes',
|
||||
'15m0s': 'Last 15 minutes',
|
||||
'30m0s': 'Last 30 minutes',
|
||||
'1h0m0s': 'Last 1 hour',
|
||||
'2h0m0s': 'Last 2 hours',
|
||||
'4h0m0s': 'Last 4 hours',
|
||||
} as const;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user