Compare commits

..

35 Commits

Author SHA1 Message Date
nityanandagohain
59ca03330f fix: lint 2026-06-04 15:33:06 +05:30
nityanandagohain
c076d48b9c fix: comment 2026-06-04 15:02:48 +05:30
nityanandagohain
7f7e6e3659 Merge remote-tracking branch 'origin/issue_4203' into issue_4203 2026-06-04 14:59:38 +05:30
nityanandagohain
b210e5f532 fix: lint 2026-06-04 14:58:57 +05:30
Nityananda Gohain
8c719696bf Merge branch 'main' into issue_4203 2026-06-04 14:41:05 +05:30
nityanandagohain
7ae7c6eb4b fix: tests 2026-06-04 14:40:33 +05:30
nityanandagohain
c4efc0d2da Merge remote-tracking branch 'origin/main' into issue_4203 2026-06-04 12:52:36 +05:30
nityanandagohain
13dec174bf fix: move tests to the same file 2026-05-19 01:00:03 +05:30
nityanandagohain
9ee57c0950 fix: lint issues 2026-05-19 00:07:34 +05:30
nityanandagohain
33df48c822 fix: send all data for trace operators as well 2026-05-19 00:06:16 +05:30
nityanandagohain
af117374c8 fix: lint issues 2026-05-18 18:18:46 +05:30
nityanandagohain
ba4cef67ac fix: remove unnecessary tests 2026-05-18 17:58:09 +05:30
nityanandagohain
f0c33a6734 fix: send parsed events and links 2026-05-18 17:50:40 +05:30
nityanandagohain
e897f4866a Merge remote-tracking branch 'origin/main' into issue_4203 2026-05-18 16:30:00 +05:30
nityanandagohain
282b6fdef1 fix: address comments 2026-05-07 20:09:11 +05:30
Nityananda Gohain
9b64bb2fc0 Merge branch 'main' into issue_4203 2026-05-04 11:12:10 +05:30
nityanandagohain
b818ff5fc4 fix: address comments 2026-04-29 17:19:19 +05:30
nityanandagohain
e7d729ab5d Merge remote-tracking branch 'origin/main' into issue_4203 2026-04-29 16:51:49 +05:30
Nityananda Gohain
ed812ad1c8 Merge branch 'main' into issue_4203 2026-04-24 11:25:38 +05:30
nityanandagohain
3b82c2ce43 fix: restrict merging to only span data 2026-04-24 11:25:11 +05:30
nityanandagohain
214980ddad Merge remote-tracking branch 'origin/main' into issue_4203 2026-04-24 10:22:33 +05:30
nityanandagohain
a7b69a2678 fix: py-fmt 2026-04-21 12:13:47 +05:30
nityanandagohain
73c82f50a9 Merge remote-tracking branch 'origin/main' into issue_4203 2026-04-21 11:49:52 +05:30
nityanandagohain
2593c5eb91 fix: linting issues 2026-04-13 15:44:43 +05:30
Nityananda Gohain
b6b2d36baa Merge branch 'main' into issue_4203 2026-04-10 17:15:08 +05:30
nityanandagohain
a444a039f9 Merge remote-tracking branch 'origin/issue_4203' into issue_4203 2026-04-10 17:13:22 +05:30
nityanandagohain
bfb050ec17 fix: add changes 2026-04-10 16:57:50 +05:30
nityanandagohain
ff3e87f70c Merge remote-tracking branch 'origin/main' into issue_4203 2026-04-09 21:29:11 +05:30
Nityananda Gohain
9ac02ebe00 Merge branch 'main' into issue_4203 2026-03-25 15:50:04 +05:30
nityanandagohain
fbdd0bebbc Merge remote-tracking branch 'origin/main' into issue_4203 2026-03-25 15:21:52 +05:30
nityanandagohain
b2245b48fe fix: retain existing behaviour 2026-03-23 11:03:34 +05:30
Nityananda Gohain
87e654fc73 chore: add comment
Co-authored-by: Tushar Vats <tushar@signoz.io>
2026-03-18 16:54:09 +05:30
nityanandagohain
0ee31ce440 chore: fix tests 2026-03-17 18:16:51 +05:30
nityanandagohain
63e681b87b chore: add integration tests 2026-03-17 15:38:00 +05:30
nityanandagohain
28375c8c1e chore: send all data for trace list api 2026-03-13 19:31:59 +05:30
42 changed files with 881 additions and 3260 deletions

View File

@@ -2645,30 +2645,6 @@ components:
legend:
$ref: '#/components/schemas/DashboardtypesLegend'
type: object
DashboardtypesJSONPatchOperation:
properties:
from:
description: Source JSON Pointer for move/copy ops; ignored for other ops.
type: string
op:
$ref: '#/components/schemas/DashboardtypesPatchOp'
path:
description: JSON Pointer (RFC 6901) into the dashboard's postable shape
— e.g. /spec/display/name, /spec/panels/<id>, /spec/panels/<id>/spec/queries/0,
/tags/-.
type: string
value:
description: 'Value to add/replace/test against. The expected type depends
on the path. Common shapes (see referenced schemas for the exact field
set): /spec/panels/<id> takes a DashboardtypesPanel; /spec/panels/<id>/spec/queries/N
(or /-) takes a DashboardtypesQuery; /spec/variables/N takes a DashboardtypesVariable;
/spec/layouts/N takes a DashboardtypesLayout; /tags/N (or /-) takes a
TagtypesPostableTag; /spec/display/name and other leaf string fields take
a string. Required for add/replace/test; ignored for remove/move/copy.'
required:
- op
- path
type: object
DashboardtypesLayout:
oneOf:
- $ref: '#/components/schemas/DashboardtypesLayoutEnvelopeGithubComPersesPersesPkgModelApiV1DashboardGridLayoutSpec'
@@ -2884,20 +2860,6 @@ components:
$ref: '#/components/schemas/DashboardtypesQuery'
type: array
type: object
DashboardtypesPatchOp:
enum:
- add
- remove
- replace
- move
- copy
- test
type: string
DashboardtypesPatchableDashboardV2:
items:
$ref: '#/components/schemas/DashboardtypesJSONPatchOperation'
nullable: true
type: array
DashboardtypesPieChartPanelSpec:
properties:
formatting:
@@ -3185,27 +3147,6 @@ components:
timePreference:
$ref: '#/components/schemas/DashboardtypesTimePreference'
type: object
DashboardtypesUpdatableDashboardV2:
properties:
image:
type: string
name:
type: string
schemaVersion:
type: string
spec:
$ref: '#/components/schemas/DashboardtypesDashboardSpec'
tags:
items:
$ref: '#/components/schemas/TagtypesPostableTag'
nullable: true
type: array
required:
- schemaVersion
- name
- tags
- spec
type: object
DashboardtypesUpdatablePublicDashboard:
properties:
defaultTimeRange:
@@ -12883,12 +12824,6 @@ paths:
- data
type: object
description: Created
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
@@ -12941,12 +12876,6 @@ paths:
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
@@ -12959,12 +12888,6 @@ paths:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
@@ -12979,262 +12902,6 @@ paths:
summary: Get dashboard (v2)
tags:
- dashboard
patch:
deprecated: false
description: 'This endpoint applies an RFC 6902 JSON Patch to a v2-shape dashboard.
The patch is applied against the postable view of the dashboard (metadata,
data, tags), so individual panels, queries, variables, layouts, or tags can
be updated without re-sending the rest of the dashboard. Apply is lenient:
`remove` on a missing path is a no-op (idempotent) and `add` creates any missing
parent objects, rather than failing as strict RFC 6902 would. The resulting
dashboard is still validated. Locked dashboards are rejected.'
operationId: PatchDashboardV2
parameters:
- in: path
name: id
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/DashboardtypesPatchableDashboardV2'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/DashboardtypesGettableDashboardV2'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- EDITOR
- tokenizer:
- EDITOR
summary: Patch dashboard (v2)
tags:
- dashboard
put:
deprecated: false
description: This endpoint updates a v2-shape dashboard's metadata, data, and
tag set. Locked dashboards are rejected.
operationId: UpdateDashboardV2
parameters:
- in: path
name: id
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/DashboardtypesUpdatableDashboardV2'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/DashboardtypesGettableDashboardV2'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- EDITOR
- tokenizer:
- EDITOR
summary: Update dashboard (v2)
tags:
- dashboard
/api/v2/dashboards/{id}/lock:
delete:
deprecated: false
description: This endpoint unlocks a v2-shape dashboard. Only the dashboard's
creator or an org admin may lock or unlock.
operationId: UnlockDashboardV2
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"204":
content:
application/json:
schema:
type: string
description: No Content
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- EDITOR
- tokenizer:
- EDITOR
summary: Unlock dashboard (v2)
tags:
- dashboard
put:
deprecated: false
description: This endpoint locks a v2-shape dashboard. Only the dashboard's
creator or an org admin may lock or unlock.
operationId: LockDashboardV2
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"204":
content:
application/json:
schema:
type: string
description: No Content
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- EDITOR
- tokenizer:
- EDITOR
summary: Lock dashboard (v2)
tags:
- dashboard
/api/v2/factor_password/forgot:
post:
deprecated: false

View File

@@ -221,18 +221,6 @@ func (module *module) GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UU
return module.pkgDashboardModule.GetV2(ctx, orgID, id)
}
func (module *module) UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, updatable dashboardtypes.UpdatableDashboardV2) (*dashboardtypes.DashboardV2, error) {
return module.pkgDashboardModule.UpdateV2(ctx, orgID, id, updatedBy, updatable)
}
func (module *module) PatchV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, patch dashboardtypes.PatchableDashboardV2) (*dashboardtypes.DashboardV2, error) {
return module.pkgDashboardModule.PatchV2(ctx, orgID, id, updatedBy, patch)
}
func (module *module) LockUnlockV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, isAdmin bool, lock bool) error {
return module.pkgDashboardModule.LockUnlockV2(ctx, orgID, id, updatedBy, isAdmin, lock)
}
func (module *module) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.Dashboard, error) {
return module.pkgDashboardModule.Get(ctx, orgID, id)
}

View File

@@ -291,8 +291,6 @@
// Prevents the usage of specific antd components in favor of our lib
"signoz/no-signozhq-ui-barrel": "error",
// Forces subpath imports (@signozhq/ui/<component>) instead of the eagerly-loaded barrel
"signoz/no-css-module-bracket-access": "warn",
// Prevents bracket access on CSS modules (styles['kebab-case']) which fails with camelCaseOnly config
"no-restricted-globals": [
"error",
{

View File

@@ -2,33 +2,9 @@
const path = require('path');
module.exports = {
plugins: [
path.join(__dirname, 'stylelint-rules/no-unsupported-asset-url.js'),
path.join(__dirname, 'stylelint-rules/css-modules/no-deep-nesting.js'),
path.join(__dirname, 'stylelint-rules/css-modules/no-id-selectors.js'),
path.join(
__dirname,
'stylelint-rules/css-modules/no-bare-element-selectors.js',
),
path.join(__dirname, 'stylelint-rules/css-modules/prefer-css-variables.js'),
path.join(__dirname, 'stylelint-rules/css-modules/class-name-pattern.js'),
],
plugins: [path.join(__dirname, 'stylelint-rules/no-unsupported-asset-url.js')],
customSyntax: 'postcss-scss',
rules: {
// Applies to all SCSS files
'local/no-unsupported-asset-url': true,
},
overrides: [
{
// CSS module-specific rules
files: ['**/*.module.scss'],
rules: {
'local/no-deep-nesting': [true, { severity: 'warning' }],
'local/no-id-selectors': true,
'local/no-bare-element-selectors': true,
'local/prefer-css-variables': [true, { severity: 'warning' }],
'local/class-name-pattern': [true, { severity: 'warning' }],
},
},
],
};

View File

@@ -23,8 +23,6 @@ You are operating within a constrained context window and strict system prompts.
- Always add data-testid or testId (if supported) to critical/behavioral components like inputs, buttons, etc...
- When creating test, these IDs should be used instead of finding by role.
- Never create barrel files.
- When writing new css, prefer CSS Modules
- Use ./docs/css-modules-guide.md as reference on how to write good CSS Modules.
3. FORCED VERIFICATION: Your internal tools mark file writes as successful even if the code does not compile. You are FORBIDDEN from reporting a task as complete until you have:
- Run `pnpm tsgo --noEmit`

View File

@@ -1,471 +0,0 @@
# CSS Modules Guide for AI Agents
## Config (vite.config.ts)
```ts
css: {
modules: {
localsConvention: 'camelCaseOnly',
},
}
```
**Critical:** `camelCaseOnly` exports ONLY camelCase keys. Original kebab-case NOT accessible.
## Quick Reference
| CSS Class | JS Access | Works? |
|-----------|-----------|--------|
| `.alertHistory` | `styles.alertHistory` | Yes |
| `.alert-history` | `styles.alertHistory` | Yes |
| `.alert-history` | `styles['alert-history']` | NO - undefined |
## Bad Patterns
### Class Naming
```scss
// BAD: Bracket access won't work
.my-class { }
// Then in JS: styles['my-class'] -> undefined
// BAD: Collision - both become same key
.alertHistory { }
.alert-history { } // -> styles.alertHistory (conflicts)
// BAD: Underscore inconsistency
.my_class { } // -> styles.myClass (confusing)
// GOOD: Direct camelCase
.alertHistory { }
.statsCard { }
```
### Nesting
```scss
// BAD: Deep nesting - specificity wars, hard to override
.container {
.wrapper {
.inner {
.content { }
}
}
}
// BAD: Nesting creates separate classes you might not expect
.button {
.icon { } // -> styles.icon (separate class, not scoped under .button)
}
// GOOD: Flat structure
.container { }
.containerWrapper { }
.containerContent { }
// GOOD: Nesting only for pseudo/states
.button {
&:hover { }
&:disabled { }
&::before { }
}
```
### Global Escapes
```scss
// BAD: Overusing global
:global {
.everything { }
.in-here { }
.is-global { }
}
// BAD: Global without necessity
:global(.myComponent) { } // defeats purpose of modules
// GOOD: Targeted global for third-party overrides
.container {
:global(.ant-modal-content) {
padding: 0;
}
}
```
### Selectors
```scss
// BAD: ID selectors - not reusable
#myComponent { }
// BAD: Element selectors without scope
div { } // affects ALL divs in component
// BAD: Complex selectors
.container > div + span ~ p { }
// GOOD: Class-only selectors
.container { }
.title { }
```
### Variables & Values
```scss
// BAD: Hardcoded colors
.button {
background: #1890ff;
color: white;
}
// BAD: Magic numbers
.container {
padding: 17px;
margin-left: 43px;
}
// GOOD: Semantic tokens (theme-aware)
.button {
background: var(--primary-background);
color: var(--primary-foreground);
}
.card {
background: var(--l2-background);
color: var(--l2-foreground);
}
// GOOD: Spacing system
.container {
padding: var(--spacing-4);
margin-left: var(--spacing-5);
}
```
## Design Tokens (@signozhq/design-tokens)
Prefer semantic tokens over hardcoded values.
You can read the ./node_modules/@signozhq/design-tokens/dist/style.css to find complete list of available tokens.
### Spacing
```scss
// Spacing scale (index -> px):
// --spacing-0=0 --spacing-1=2 --spacing-2=4 --spacing-3=6 --spacing-4=8
// --spacing-5=10 --spacing-6=12 --spacing-7=14 --spacing-8=16 --spacing-10=20
// --spacing-12=24 --spacing-16=32 --spacing-20=40 --spacing-24=48 --spacing-32=64
// --spacing-40=80 --spacing-48=96 --spacing-56=112 --spacing-64=128
// (index != px; --spacing-2 is 4px, not 2px)
.container {
padding: var(--spacing-4); // 8px
gap: var(--spacing-6); // 12px
margin-bottom: var(--spacing-8); // 16px
}
// Also available: --padding-* and --margin-* (rem-based)
// --padding-1 = 0.25rem, --padding-4 = 1rem, etc.
```
### Typography
```scss
// Font sizes (preferred)
.title {
font-size: var(--periscope-font-size-large); // 18px
font-size: var(--periscope-font-size-medium); // 16px
font-size: var(--periscope-font-size-base); // 13px
font-size: var(--periscope-font-size-small); // 11px
}
// Alternative scale (rem-based)
.heading {
font-size: var(--font-size-xl); // 1.25rem
font-size: var(--font-size-lg); // 1.125rem
font-size: var(--font-size-base); // 1rem
font-size: var(--font-size-sm); // 0.875rem
}
// Font weights
.bold {
font-weight: var(--font-weight-semibold); // 600
font-weight: var(--font-weight-medium); // 500
font-weight: var(--font-weight-normal); // 400
}
// Line heights
.text {
line-height: var(--line-height-20); // 20px
line-height: var(--line-height-24); // 24px
}
```
### Colors (Prefer Semantic Tokens)
Use L1/L2/L3 semantic tokens - they handle light/dark theme automatically.
```scss
// BAD: Primitive tokens (fixed value across themes, won't swap on theme change)
.card {
background: var(--bg-ink-400);
color: var(--text-vanilla-100);
}
// GOOD: L1/L2/L3 tokens (theme-aware - swap automatically light/dark)
.card {
background: var(--l1-background); // base layer
color: var(--l1-foreground); // primary text
}
.panel {
background: var(--l2-background); // elevated surface
color: var(--l2-foreground); // secondary text
border-color: var(--l2-border);
}
.nested {
background: var(--l3-background); // nested/inset
color: var(--l3-foreground); // tertiary text
}
// Hover states
.card:hover {
background: var(--l1-background-hover);
color: var(--l1-foreground-hover);
}
// Semantic action colors (also theme-aware)
.primary {
background: var(--primary-background);
color: var(--primary-foreground);
}
.danger {
background: var(--danger-background);
color: var(--danger-foreground);
}
.success {
background: var(--success-background);
color: var(--success-foreground);
}
.warning {
background: var(--warning-background);
color: var(--warning-foreground);
}
// Accent colors (for highlights, badges, etc.)
.accent {
background: var(--accent-primary); // robin blue
background: var(--accent-forest); // green
background: var(--accent-cherry); // red
background: var(--accent-amber); // yellow
}
```
**Token hierarchy:**
- Primitive tokens (`--bg-*`, `--text-*`, etc.) have fixed values across themes.
- Semantic tokens (L1/L2/L3, `--primary-*`, `--danger-*`, etc.) automatically swap based on theme.
- L1 = base/root layer
- L2 = elevated surfaces (cards, panels)
- L3 = nested/inset elements
## Overriding @signozhq/ui Components
Components expose CSS variables for customization.
You can ensure they exist by looking at ./node_modules/@signozhq/ui/dist.
Never write a override without confirm it exists.
Override via:
### Method 1: CSS Variables (Preferred)
Each component exposes `--<component>-<property>` variables:
```scss
// Override Button
.customButton {
--button-background: var(--success-background);
--button-border-radius: var(--radius-2);
--button-padding: var(--spacing-4) var(--spacing-8);
--button-font-size: var(--periscope-font-size-base);
}
// Override Input
.customInput {
--input-height: 2.5rem;
--input-border-color: var(--l2-border);
--input-padding: var(--spacing-2) var(--spacing-6);
--input-placeholder-color: var(--l3-foreground);
}
// Override nested parts
.customInput {
--input-prefix-padding: 0 var(--spacing-4) 0 var(--spacing-6);
--input-suffix-color: var(--accent-primary);
}
```
### Method 2: Data Attributes
Components use data attributes for variants/states. Target them for state-specific overrides:
```scss
// Target variant
.wrapper :global([data-variant="outlined"]) {
--button-border-color: var(--accent-primary);
}
// Target size
.wrapper :global([data-size="sm"]) {
--button-font-size: var(--periscope-font-size-small);
}
// Target color
.wrapper :global([data-color="destructive"]) {
--button-background: var(--danger-background);
}
// Target state (Radix patterns)
.popover :global([data-state="open"]) {
opacity: 1;
}
.tooltip :global([data-side="top"]) {
margin-bottom: var(--spacing-2);
}
```
### Common Component CSS Variables
**Button:**
- `--button-background`, `--button-border-radius`, `--button-padding`
- `--button-font-size`, `--button-height`, `--button-gap`
- `--button-hover-background`, `--button-disabled-opacity`
**Input:**
- `--input-height`, `--input-border-color`, `--input-background`
- `--input-padding`, `--input-font-size`, `--input-placeholder-color`
- `--input-focus-outline-color`, `--input-hover-border-color`
- `--input-prefix-*`, `--input-suffix-*` for adornments
**General pattern:** `--<component>-<property>` or `--<component>-<state>-<property>`
## Good Patterns
### Structure
```scss
// Flat, descriptive, component-scoped
.alertHistory { }
.alertHistoryHeader { }
.alertHistoryContent { }
.alertHistoryFooter { }
// State modifiers as separate classes
.alertHistory { }
.alertHistoryLoading { }
.alertHistoryEmpty { }
.alertHistoryError { }
```
### Composition
```scss
// GOOD: Composing styles
.baseButton {
padding: var(--spacing-2) var(--spacing-4);
border-radius: var(--radius-2);
}
.primaryButton {
composes: baseButton;
background: var(--primary-background);
}
```
### Pseudo Elements
```scss
.button {
// States
&:hover { opacity: 0.9; }
&:focus { outline: 2px solid var(--ring); outline-offset: 2px; }
&:disabled { opacity: 0.5; cursor: not-allowed; }
// Pseudo elements
&::before { content: ''; }
&::after { content: ''; }
}
```
### Media Queries
```scss
.container {
display: flex;
flex-direction: column;
@media (min-width: 768px) {
flex-direction: row;
}
}
```
## JS Import Patterns
```tsx
// GOOD
import styles from './Component.module.scss';
<div className={styles.container}>
<span className={styles.title}>Title</span>
</div>
// GOOD: Conditional classes
<div className={`${styles.button} ${isActive ? styles.buttonActive : ''}`}>
// GOOD: With clsx/classnames
<div className={clsx(styles.button, { [styles.buttonActive]: isActive })}>
// BAD: Bracket access (may be undefined)
<div className={styles['button-active']}> // undefined if CSS has .button-active
// BAD: String interpolation for class names
<div className={`${styles.button}-active`}> // won't work
```
## Checklist Before Committing
- [ ] All class names use camelCase in CSS
- [ ] No bracket access (`styles['...']`) in JS unless verified
- [ ] No deep class nesting (max 3 class levels; pseudo-classes/elements and parent-reference selectors like `&.active`, `&#bar` are not counted)
- [ ] No hardcoded colors - use `--l1/l2/l3-*` semantic tokens (not `--bg-*` primitives)
- [ ] No magic numbers - use `--spacing-*` tokens
- [ ] Typography uses `--periscope-font-size-*` or `--font-size-*` tokens
- [ ] @signozhq/ui overrides use CSS variables, not direct class overrides
- [ ] Global escapes only for third-party overrides
- [ ] No ID selectors
- [ ] No bare element selectors
## Lint Rules
### JS/TS (oxlint)
| Rule | Severity | Catches |
|------|----------|---------|
| `signoz/no-css-module-bracket-access` | warn | `styles['kebab-case']`, dynamic access |
### CSS/SCSS (stylelint)
| Rule | Severity | Catches |
|------|----------|---------|
| `local/no-deep-nesting` | warning | class nesting >3 levels (pseudo-classes/elements and parent-reference selectors `&.foo`, `&#bar` not counted; configurable via `maxDepth` secondary option) |
| `local/no-id-selectors` | error | `#id` selectors |
| `local/no-bare-element-selectors` | error | root-level `div`, `span` etc |
| `local/prefer-css-variables` | warning | hardcoded colors |
| `local/class-name-pattern` | warning | kebab-case, snake_case, PascalCase |
Run: `pnpm lint:styles` to check CSS modules.

View File

@@ -1,144 +0,0 @@
/**
* Rule: no-css-module-bracket-access
*
* Prevents bracket access on CSS module imports that may fail with camelCaseOnly config.
*
* With Vite's `localsConvention: 'camelCaseOnly'`, kebab-case class names are
* converted to camelCase and the original key is NOT exported.
*
* This rule catches patterns like:
* styles['my-class'] // BAD - undefined if CSS has .my-class
* styles['myClass'] // OK but prefer dot notation
* styles.myClass // GOOD
*
* Catches:
* - Bracket access with kebab-case strings (always fails)
* - Bracket access with any string literal (warn - prefer dot notation)
* - Dynamic bracket access (warn - risky)
*/
const CSS_MODULE_IMPORT_NAMES = new Set([
'styles',
'classes',
'css',
'classNames',
]);
function looksLikeCssModuleImport(name) {
// Common patterns: styles, componentStyles, alertHistoryStyles
return (
CSS_MODULE_IMPORT_NAMES.has(name) ||
name.endsWith('Styles') ||
name.endsWith('Classes') ||
name.endsWith('Css')
);
}
function isKebabCase(str) {
return str.includes('-');
}
function isSnakeCase(str) {
return str.includes('_');
}
export default {
create(context) {
return {
MemberExpression(node) {
// Only check bracket notation: styles['...']
if (!node.computed) {
return;
}
const object = node.object;
if (object.type !== 'Identifier') {
return;
}
// Check if this looks like a CSS module import
if (!looksLikeCssModuleImport(object.name)) {
return;
}
const property = node.property;
// Dynamic access: styles[variable]
if (property.type === 'Identifier') {
context.report({
node,
message: `Dynamic CSS module access '${object.name}[${property.name}]' is risky. With 'camelCaseOnly' config, kebab-case keys don't exist. Use dot notation or verify the key exists.`,
});
return;
}
// Template literal: styles[\`...\`]
if (property.type === 'TemplateLiteral') {
context.report({
node,
message: `Template literal CSS module access is risky. With 'camelCaseOnly' config, kebab-case keys don't exist. Prefer dot notation.`,
});
return;
}
// Numeric / boolean / null literal: styles[0]. Not a class lookup; ignore.
if (property.type === 'Literal' && typeof property.value !== 'string') {
return;
}
// String literal: styles['...']
if (property.type === 'Literal' && typeof property.value === 'string') {
const className = property.value;
// Kebab-case will definitely fail
if (isKebabCase(className)) {
context.report({
node,
message: `CSS module class '${className}' uses kebab-case which won't work with 'camelCaseOnly' config. Use '${object.name}.${toCamelCase(className)}' instead.`,
});
return;
}
// Snake_case is suspicious
if (isSnakeCase(className)) {
context.report({
node,
message: `CSS module class '${className}' uses snake_case which may not work as expected. Prefer camelCase: '${object.name}.${toCamelCase(className)}'.`,
});
return;
}
// Valid camelCase but using bracket notation - prefer dot
if (/^[a-z][a-zA-Z0-9]*$/.test(className)) {
context.report({
node,
message: `Prefer dot notation: '${object.name}.${className}' instead of '${object.name}['${className}']'.`,
});
}
return;
}
// Catch-all for other dynamic expressions:
// styles['prefix' + suffix] (BinaryExpression)
// styles[isActive && 'foo'] (LogicalExpression)
// styles[isActive ? 'a' : 'b'] (ConditionalExpression)
// styles[fn()] (CallExpression)
context.report({
node,
message: `Dynamic CSS module access on '${object.name}' is risky. With 'camelCaseOnly' config, kebab-case keys don't exist. Use dot notation or verify each key resolves to an exported camelCase class.`,
});
},
};
},
};
function toCamelCase(str) {
return str
.split(/[-_]/)
.map((part, i) =>
i === 0
? part.toLowerCase()
: part.charAt(0).toUpperCase() + part.slice(1).toLowerCase(),
)
.join('');
}

View File

@@ -11,7 +11,6 @@ import noUnsupportedAssetPattern from './rules/no-unsupported-asset-pattern.mjs'
import noRawAbsolutePath from './rules/no-raw-absolute-path.mjs';
import noAntdComponents from './rules/no-antd-components.mjs';
import noSignozhqUiBarrel from './rules/no-signozhq-ui-barrel.mjs';
import noCssModuleBracketAccess from './rules/no-css-module-bracket-access.mjs';
export default {
meta: {
@@ -24,6 +23,5 @@ export default {
'no-raw-absolute-path': noRawAbsolutePath,
'no-antd-components': noAntdComponents,
'no-signozhq-ui-barrel': noSignozhqUiBarrel,
'no-css-module-bracket-access': noCssModuleBracketAccess,
},
};

View File

@@ -21,10 +21,8 @@ import type {
CreateDashboardV2201,
CreatePublicDashboard201,
CreatePublicDashboardPathParameters,
DashboardtypesPatchableDashboardV2DTO,
DashboardtypesPostableDashboardV2DTO,
DashboardtypesPostablePublicDashboardDTO,
DashboardtypesUpdatableDashboardV2DTO,
DashboardtypesUpdatablePublicDashboardDTO,
DeletePublicDashboardPathParameters,
GetDashboardV2200,
@@ -35,13 +33,7 @@ import type {
GetPublicDashboardPathParameters,
GetPublicDashboardWidgetQueryRange200,
GetPublicDashboardWidgetQueryRangePathParameters,
LockDashboardV2PathParameters,
PatchDashboardV2200,
PatchDashboardV2PathParameters,
RenderErrorResponseDTO,
UnlockDashboardV2PathParameters,
UpdateDashboardV2200,
UpdateDashboardV2PathParameters,
UpdatePublicDashboardPathParameters,
} from '../sigNoz.schemas';
@@ -824,360 +816,3 @@ export const invalidateGetDashboardV2 = async (
return queryClient;
};
/**
* This endpoint applies an RFC 6902 JSON Patch to a v2-shape dashboard. The patch is applied against the postable view of the dashboard (metadata, data, tags), so individual panels, queries, variables, layouts, or tags can be updated without re-sending the rest of the dashboard. Apply is lenient: `remove` on a missing path is a no-op (idempotent) and `add` creates any missing parent objects, rather than failing as strict RFC 6902 would. The resulting dashboard is still validated. Locked dashboards are rejected.
* @summary Patch dashboard (v2)
*/
export const patchDashboardV2 = (
{ id }: PatchDashboardV2PathParameters,
dashboardtypesPatchableDashboardV2DTONull?: BodyType<DashboardtypesPatchableDashboardV2DTO | null> | null,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<PatchDashboardV2200>({
url: `/api/v2/dashboards/${id}`,
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
data: dashboardtypesPatchableDashboardV2DTONull,
signal,
});
};
export const getPatchDashboardV2MutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof patchDashboardV2>>,
TError,
{
pathParams: PatchDashboardV2PathParameters;
data?: BodyType<DashboardtypesPatchableDashboardV2DTO | null>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof patchDashboardV2>>,
TError,
{
pathParams: PatchDashboardV2PathParameters;
data?: BodyType<DashboardtypesPatchableDashboardV2DTO | null>;
},
TContext
> => {
const mutationKey = ['patchDashboardV2'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof patchDashboardV2>>,
{
pathParams: PatchDashboardV2PathParameters;
data?: BodyType<DashboardtypesPatchableDashboardV2DTO | null>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return patchDashboardV2(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type PatchDashboardV2MutationResult = NonNullable<
Awaited<ReturnType<typeof patchDashboardV2>>
>;
export type PatchDashboardV2MutationBody =
| BodyType<DashboardtypesPatchableDashboardV2DTO | null>
| undefined;
export type PatchDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Patch dashboard (v2)
*/
export const usePatchDashboardV2 = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof patchDashboardV2>>,
TError,
{
pathParams: PatchDashboardV2PathParameters;
data?: BodyType<DashboardtypesPatchableDashboardV2DTO | null>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof patchDashboardV2>>,
TError,
{
pathParams: PatchDashboardV2PathParameters;
data?: BodyType<DashboardtypesPatchableDashboardV2DTO | null>;
},
TContext
> => {
return useMutation(getPatchDashboardV2MutationOptions(options));
};
/**
* This endpoint updates a v2-shape dashboard's metadata, data, and tag set. Locked dashboards are rejected.
* @summary Update dashboard (v2)
*/
export const updateDashboardV2 = (
{ id }: UpdateDashboardV2PathParameters,
dashboardtypesUpdatableDashboardV2DTO?: BodyType<DashboardtypesUpdatableDashboardV2DTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<UpdateDashboardV2200>({
url: `/api/v2/dashboards/${id}`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
data: dashboardtypesUpdatableDashboardV2DTO,
signal,
});
};
export const getUpdateDashboardV2MutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateDashboardV2>>,
TError,
{
pathParams: UpdateDashboardV2PathParameters;
data?: BodyType<DashboardtypesUpdatableDashboardV2DTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof updateDashboardV2>>,
TError,
{
pathParams: UpdateDashboardV2PathParameters;
data?: BodyType<DashboardtypesUpdatableDashboardV2DTO>;
},
TContext
> => {
const mutationKey = ['updateDashboardV2'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof updateDashboardV2>>,
{
pathParams: UpdateDashboardV2PathParameters;
data?: BodyType<DashboardtypesUpdatableDashboardV2DTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return updateDashboardV2(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type UpdateDashboardV2MutationResult = NonNullable<
Awaited<ReturnType<typeof updateDashboardV2>>
>;
export type UpdateDashboardV2MutationBody =
| BodyType<DashboardtypesUpdatableDashboardV2DTO>
| undefined;
export type UpdateDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Update dashboard (v2)
*/
export const useUpdateDashboardV2 = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateDashboardV2>>,
TError,
{
pathParams: UpdateDashboardV2PathParameters;
data?: BodyType<DashboardtypesUpdatableDashboardV2DTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof updateDashboardV2>>,
TError,
{
pathParams: UpdateDashboardV2PathParameters;
data?: BodyType<DashboardtypesUpdatableDashboardV2DTO>;
},
TContext
> => {
return useMutation(getUpdateDashboardV2MutationOptions(options));
};
/**
* This endpoint unlocks a v2-shape dashboard. Only the dashboard's creator or an org admin may lock or unlock.
* @summary Unlock dashboard (v2)
*/
export const unlockDashboardV2 = (
{ id }: UnlockDashboardV2PathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<string>({
url: `/api/v2/dashboards/${id}/lock`,
method: 'DELETE',
signal,
});
};
export const getUnlockDashboardV2MutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof unlockDashboardV2>>,
TError,
{ pathParams: UnlockDashboardV2PathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof unlockDashboardV2>>,
TError,
{ pathParams: UnlockDashboardV2PathParameters },
TContext
> => {
const mutationKey = ['unlockDashboardV2'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof unlockDashboardV2>>,
{ pathParams: UnlockDashboardV2PathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return unlockDashboardV2(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type UnlockDashboardV2MutationResult = NonNullable<
Awaited<ReturnType<typeof unlockDashboardV2>>
>;
export type UnlockDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Unlock dashboard (v2)
*/
export const useUnlockDashboardV2 = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof unlockDashboardV2>>,
TError,
{ pathParams: UnlockDashboardV2PathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof unlockDashboardV2>>,
TError,
{ pathParams: UnlockDashboardV2PathParameters },
TContext
> => {
return useMutation(getUnlockDashboardV2MutationOptions(options));
};
/**
* This endpoint locks a v2-shape dashboard. Only the dashboard's creator or an org admin may lock or unlock.
* @summary Lock dashboard (v2)
*/
export const lockDashboardV2 = (
{ id }: LockDashboardV2PathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<string>({
url: `/api/v2/dashboards/${id}/lock`,
method: 'PUT',
signal,
});
};
export const getLockDashboardV2MutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof lockDashboardV2>>,
TError,
{ pathParams: LockDashboardV2PathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof lockDashboardV2>>,
TError,
{ pathParams: LockDashboardV2PathParameters },
TContext
> => {
const mutationKey = ['lockDashboardV2'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof lockDashboardV2>>,
{ pathParams: LockDashboardV2PathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return lockDashboardV2(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type LockDashboardV2MutationResult = NonNullable<
Awaited<ReturnType<typeof lockDashboardV2>>
>;
export type LockDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Lock dashboard (v2)
*/
export const useLockDashboardV2 = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof lockDashboardV2>>,
TError,
{ pathParams: LockDashboardV2PathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof lockDashboardV2>>,
TError,
{ pathParams: LockDashboardV2PathParameters },
TContext
> => {
return useMutation(getLockDashboardV2MutationOptions(options));
};

View File

@@ -4653,32 +4653,6 @@ export interface DashboardtypesGettablePublicDashboardDataDTO {
publicDashboard?: DashboardtypesGettablePublicDasbhboardDTO;
}
export enum DashboardtypesPatchOpDTO {
add = 'add',
remove = 'remove',
replace = 'replace',
move = 'move',
copy = 'copy',
test = 'test',
}
export interface DashboardtypesJSONPatchOperationDTO {
/**
* @type string
* @description Source JSON Pointer for move/copy ops; ignored for other ops.
*/
from?: string;
op: DashboardtypesPatchOpDTO;
/**
* @type string
* @description JSON Pointer (RFC 6901) into the dashboard's postable shape — e.g. /spec/display/name, /spec/panels/<id>, /spec/panels/<id>/spec/queries/0, /tags/-.
*/
path: string;
/**
* @description Value to add/replace/test against. The expected type depends on the path. Common shapes (see referenced schemas for the exact field set): /spec/panels/<id> takes a DashboardtypesPanel; /spec/panels/<id>/spec/queries/N (or /-) takes a DashboardtypesQuery; /spec/variables/N takes a DashboardtypesVariable; /spec/layouts/N takes a DashboardtypesLayout; /tags/N (or /-) takes a TagtypesPostableTag; /spec/display/name and other leaf string fields take a string. Required for add/replace/test; ignored for remove/move/copy.
*/
value?: unknown;
}
export enum DashboardtypesPanelPluginKindDTO {
'signoz/TimeSeriesPanel' = 'signoz/TimeSeriesPanel',
'signoz/BarChartPanel' = 'signoz/BarChartPanel',
@@ -4688,13 +4662,6 @@ export enum DashboardtypesPanelPluginKindDTO {
'signoz/HistogramPanel' = 'signoz/HistogramPanel',
'signoz/ListPanel' = 'signoz/ListPanel',
}
/**
* @nullable
*/
export type DashboardtypesPatchableDashboardV2DTO =
| DashboardtypesJSONPatchOperationDTO[]
| null;
export interface DashboardtypesPostableDashboardV2DTO {
/**
* @type boolean
@@ -4738,26 +4705,6 @@ export enum DashboardtypesQueryPluginKindDTO {
'signoz/ClickHouseSQL' = 'signoz/ClickHouseSQL',
'signoz/TraceOperator' = 'signoz/TraceOperator',
}
export interface DashboardtypesUpdatableDashboardV2DTO {
/**
* @type string
*/
image?: string;
/**
* @type string
*/
name: string;
/**
* @type string
*/
schemaVersion: string;
spec: DashboardtypesDashboardSpecDTO;
/**
* @type array,null
*/
tags: TagtypesPostableTagDTO[] | null;
}
export interface DashboardtypesUpdatablePublicDashboardDTO {
/**
* @type string
@@ -9529,34 +9476,6 @@ export type GetDashboardV2200 = {
status: string;
};
export type PatchDashboardV2PathParameters = {
id: string;
};
export type PatchDashboardV2200 = {
data: DashboardtypesGettableDashboardV2DTO;
/**
* @type string
*/
status: string;
};
export type UpdateDashboardV2PathParameters = {
id: string;
};
export type UpdateDashboardV2200 = {
data: DashboardtypesGettableDashboardV2DTO;
/**
* @type string
*/
status: string;
};
export type UnlockDashboardV2PathParameters = {
id: string;
};
export type LockDashboardV2PathParameters = {
id: string;
};
export type GetFeatures200 = {
/**
* @type array

View File

@@ -1,186 +0,0 @@
/**
* Stylelint rule: local/class-name-pattern
*
* Enforces camelCase class names in CSS modules.
* With Vite's `localsConvention: 'camelCaseOnly'`, kebab-case is converted but
* using camelCase directly avoids confusion.
*
* BAD:
* .my-class { } // converted to myClass, but confusing
* .my_class { } // converted to myClass, but confusing
* .MyClass { } // PascalCase not conventional
*
* GOOD:
* .myClass { }
* .alertHistory { }
* .statsCard { }
*/
import stylelint from 'stylelint';
const ruleName = 'local/class-name-pattern';
const messages = stylelint.utils.ruleMessages(ruleName, {
kebabCase: (className) =>
`Class "${className}" uses kebab-case. Use camelCase instead: "${toCamelCase(className)}".`,
snakeCase: (className) =>
`Class "${className}" uses snake_case. Use camelCase instead: "${toCamelCase(className)}".`,
pascalCase: (className) =>
`Class "${className}" uses PascalCase. Use camelCase instead: "${className.charAt(0).toLowerCase() + className.slice(1)}".`,
});
function toCamelCase(str) {
return str
.split(/[-_]/)
.map((part, i) =>
i === 0
? part.toLowerCase()
: part.charAt(0).toUpperCase() + part.slice(1).toLowerCase(),
)
.join('');
}
function isKebabCase(str) {
return str.includes('-');
}
function isSnakeCase(str) {
return str.includes('_');
}
function isPascalCase(str) {
return /^[A-Z][a-zA-Z0-9]*$/.test(str);
}
const CLASS_PATTERN = /\.([a-zA-Z_][a-zA-Z0-9_-]*)/g;
const DEFAULT_THIRD_PARTY_PREFIXES = [
'ant-', // Ant Design
'rc-', // rc-components (Ant Design internals)
'recharts-', // Recharts
'uplot-', // uPlot
'u-', // uPlot legacy
'leaflet-', // Leaflet
'monaco-', // Monaco editor
'react-resizable', // react-resizable
'cm-', // CodeMirror
];
// Bare `:global { ... }` block (no parens) makes all descendants global.
// `:global(.foo)` is a per-selector escape — handled separately by globalRanges().
function hasBareGlobalAncestor(node) {
let current = node.parent;
while (current) {
const selector = current.selector;
if (selector && /:global(?!\s*\()/.test(selector)) {
return true;
}
current = current.parent;
}
return false;
}
// Return list of [start, end) index ranges inside `selector` that fall within a
// balanced `:global(...)` argument list. Class matches inside these ranges are
// third-party and should be skipped.
function globalRanges(selector) {
const ranges = [];
const re = /:global\s*\(/g;
let match;
while ((match = re.exec(selector)) !== null) {
const argStart = match.index + match[0].length;
let depth = 1;
let i = argStart;
while (i < selector.length && depth > 0) {
const ch = selector[i];
if (ch === '(') {
depth++;
} else if (ch === ')') {
depth--;
}
if (depth > 0) {
i++;
}
}
ranges.push([argStart, i]);
re.lastIndex = i;
}
return ranges;
}
function indexInRanges(index, ranges) {
for (const [start, end] of ranges) {
if (index >= start && index < end) {
return true;
}
}
return false;
}
const rule = (primaryOption, secondaryOptions) => {
return (root, result) => {
if (!primaryOption) {
return;
}
const userPrefixes =
(secondaryOptions && secondaryOptions.ignoreThirdPartyPrefixes) || [];
const allPrefixes = [...DEFAULT_THIRD_PARTY_PREFIXES, ...userPrefixes];
root.walkRules((ruleNode) => {
const selector = ruleNode.selector;
// Bare `:global { }` block makes all descendants global — skip entirely.
if (hasBareGlobalAncestor(ruleNode)) {
return;
}
const ranges = globalRanges(selector);
let match;
CLASS_PATTERN.lastIndex = 0;
while ((match = CLASS_PATTERN.exec(selector)) !== null) {
const className = match[1];
// Skip classes inside `:global(...)` ranges of this selector.
if (indexInRanges(match.index, ranges)) {
continue;
}
// Skip third-party library classes
if (allPrefixes.some((prefix) => className.startsWith(prefix))) {
continue;
}
if (isKebabCase(className)) {
stylelint.utils.report({
message: messages.kebabCase(className),
node: ruleNode,
result,
ruleName,
});
} else if (isSnakeCase(className)) {
stylelint.utils.report({
message: messages.snakeCase(className),
node: ruleNode,
result,
ruleName,
});
} else if (isPascalCase(className)) {
stylelint.utils.report({
message: messages.pascalCase(className),
node: ruleNode,
result,
ruleName,
});
}
}
});
};
};
rule.ruleName = ruleName;
rule.messages = messages;
rule.meta = {};
export { ruleName, rule };
export default { ruleName, rule };

View File

@@ -1,164 +0,0 @@
/**
* Stylelint rule: local/no-bare-element-selectors
*
* Prevents bare element selectors at root level in CSS modules.
* Bare elements affect ALL instances of that element within component scope,
* often unintentionally.
*
* BAD:
* div { }
* span { padding: 4px; }
* p { margin: 0; }
*
* GOOD:
* .container { }
* .title { }
*
* ALLOWED (nested under class):
* .container {
* p { margin: 0; } // Scoped to .container
* }
*/
import stylelint from 'stylelint';
const ruleName = 'local/no-bare-element-selectors';
const messages = stylelint.utils.ruleMessages(ruleName, {
unexpected: (element) =>
`Bare element selector "${element}" at root level affects all instances. Use class selector or nest under a class.`,
unexpectedInCompound: (element, full) =>
`Bare element "${element}" in unscoped selector "${full}" at root level matches every "<${element}>" in the module. Anchor the selector with a class (e.g., ".container ${element}") or use :global() if intentional.`,
});
const ELEMENT_PATTERN = /^[a-z][a-z0-9]*$/i;
const EXCLUDED_ELEMENTS = new Set(['html', 'body', 'root']);
function isPartBareElement(part) {
const trimmed = part.trim();
if (!trimmed) {
return false;
}
// Strip pseudo suffixes (`:hover`, `::before`) — element part is what's before
const elementOnly = trimmed.split(':')[0];
if (!elementOnly) {
return false;
}
// Skip class/id/attribute parts
if (/[.#[]/.test(elementOnly)) {
return false;
}
return (
ELEMENT_PATTERN.test(elementOnly) &&
!EXCLUDED_ELEMENTS.has(elementOnly.toLowerCase())
);
}
function isBareElement(selector) {
const trimmed = selector.trim();
// Skip combined selectors
if (/[,\s>+~]/.test(trimmed)) {
return false;
}
// Skip pseudo-selectors
if (trimmed.includes(':')) {
return false;
}
// Skip class/id/attribute selectors
if (/[.#[]/.test(trimmed)) {
return false;
}
return (
ELEMENT_PATTERN.test(trimmed) && !EXCLUDED_ELEMENTS.has(trimmed.toLowerCase())
);
}
// Split a compound selector on combinators (`>`, `+`, `~`, descendant space)
// while keeping each part intact. Returns an array of selector parts.
function splitOnCombinators(selector) {
return selector
.split(/\s*[>+~]\s*|\s+/)
.map((p) => p.trim())
.filter(Boolean);
}
function isInsideGlobal(selector) {
return /:global\s*\(/.test(selector);
}
const KEYFRAME_ATRULES = new Set([
'keyframes',
'-webkit-keyframes',
'-moz-keyframes',
'-o-keyframes',
]);
// Rule's effective parent is root if all ancestors above it are atrules
// (e.g. `@media`, `@supports`). A bare `div` inside `@media { }` at top level
// still matches every `<div>` in the module. Exclude `@keyframes` — its
// `from`/`to`/`0%` children are not element selectors.
function isEffectivelyTopLevel(node) {
let parent = node.parent;
while (parent && parent.type === 'atrule') {
if (KEYFRAME_ATRULES.has(parent.name.toLowerCase())) {
return false;
}
parent = parent.parent;
}
return Boolean(parent) && parent.type === 'root';
}
const rule = (primaryOption) => {
return (root, result) => {
if (!primaryOption) {
return;
}
root.walkRules((ruleNode) => {
if (!isEffectivelyTopLevel(ruleNode)) {
return;
}
const selectors = ruleNode.selector.split(',').map((s) => s.trim());
for (const selector of selectors) {
if (isInsideGlobal(selector)) {
continue;
}
if (isBareElement(selector)) {
stylelint.utils.report({
message: messages.unexpected(selector),
node: ruleNode,
result,
ruleName,
});
continue;
}
// Check combined selectors part-by-part. Only flag when the compound
// has NO class/id anchor — `.container > div` is already scoped, but
// `div + span` at root affects every matching descendant in the module.
if (/[\s>+~]/.test(selector) && !/[.#]/.test(selector)) {
const parts = splitOnCombinators(selector);
for (const part of parts) {
if (isPartBareElement(part)) {
stylelint.utils.report({
message: messages.unexpectedInCompound(part.split(':')[0], selector),
node: ruleNode,
result,
ruleName,
});
}
}
}
}
});
};
};
rule.ruleName = ruleName;
rule.messages = messages;
rule.meta = {};
export { ruleName, rule };
export default { ruleName, rule };

View File

@@ -1,97 +0,0 @@
/**
* Stylelint rule: local/no-deep-nesting
*
* Prevents deep nesting in CSS modules (max 3 levels).
* Deep nesting creates specificity wars and hard-to-override styles.
*
* BAD:
* .container { .wrapper { .inner { .content { } } } } // 4 levels
*
* GOOD:
* .container { }
* .containerWrapper { }
* .containerContent { }
*
* Allowed nesting (pseudo-classes/elements):
* .button { &:hover { } &::before { } }
*/
import stylelint from 'stylelint';
const ruleName = 'local/no-deep-nesting';
const DEFAULT_MAX_DEPTH = 3;
const messages = stylelint.utils.ruleMessages(ruleName, {
tooDeep: (depth, max) =>
`Nesting depth ${depth} exceeds maximum of ${max}. Flatten selectors for CSS modules.`,
});
function isPseudoSelector(selector) {
return /^&?:/.test(selector.trim());
}
function isParentReference(selector) {
return /^&[.#[]/.test(selector.trim());
}
function countNestingDepth(rule, depth = 0) {
const selector = rule.selector || '';
// Don't count pseudo-selectors or parent references toward depth
if (isPseudoSelector(selector) || isParentReference(selector)) {
// Still check children
let maxChildDepth = depth;
rule.walkRules?.((child) => {
const childDepth = countNestingDepth(child, depth);
maxChildDepth = Math.max(maxChildDepth, childDepth);
});
return maxChildDepth;
}
const currentDepth = depth + 1;
let maxDepth = currentDepth;
rule.walkRules?.((child) => {
const childDepth = countNestingDepth(child, currentDepth);
maxDepth = Math.max(maxDepth, childDepth);
});
return maxDepth;
}
const rule = (primaryOption, secondaryOptions) => {
return (root, result) => {
if (!primaryOption) {
return;
}
const maxDepth =
secondaryOptions && Number.isInteger(secondaryOptions.maxDepth)
? secondaryOptions.maxDepth
: DEFAULT_MAX_DEPTH;
root.walkRules((ruleNode) => {
// Only check top-level rules
if (ruleNode.parent.type !== 'root' && ruleNode.parent.type !== 'atrule') {
return;
}
const depth = countNestingDepth(ruleNode);
if (depth > maxDepth) {
stylelint.utils.report({
message: messages.tooDeep(depth, maxDepth),
node: ruleNode,
result,
ruleName,
});
}
});
};
};
rule.ruleName = ruleName;
rule.messages = messages;
rule.meta = {};
export { ruleName, rule };
export default { ruleName, rule };

View File

@@ -1,55 +0,0 @@
/**
* Stylelint rule: local/no-id-selectors
*
* Prevents ID selectors in CSS modules.
* IDs create high specificity, can't be reused, and defeat CSS modules scoping purpose.
*
* BAD:
* #myComponent { }
* .container #header { }
*
* GOOD:
* .myComponent { }
* .containerHeader { }
*/
import stylelint from 'stylelint';
const ruleName = 'local/no-id-selectors';
const messages = stylelint.utils.ruleMessages(ruleName, {
unexpected: (selector) =>
`ID selector "${selector}" not allowed in CSS modules. Use class selector instead.`,
});
const ID_PATTERN = /#[a-zA-Z_][a-zA-Z0-9_-]*/g;
const rule = (primaryOption) => {
return (root, result) => {
if (!primaryOption) {
return;
}
root.walkRules((ruleNode) => {
const selector = ruleNode.selector;
const matches = selector.match(ID_PATTERN);
if (matches) {
for (const match of matches) {
stylelint.utils.report({
message: messages.unexpected(match),
node: ruleNode,
result,
ruleName,
});
}
}
});
};
};
rule.ruleName = ruleName;
rule.messages = messages;
rule.meta = {};
export { ruleName, rule };
export default { ruleName, rule };

View File

@@ -1,168 +0,0 @@
/**
* Stylelint rule: local/prefer-css-variables
*
* Warns on hardcoded colors in CSS modules.
* Use CSS variables for consistent theming.
*
* BAD:
* color: #ff0000;
* background: rgb(255, 0, 0);
* border: 1px solid blue;
*
* GOOD:
* color: var(--l1-foreground);
* background: var(--primary-background);
* border: 1px solid var(--l2-border);
*
* ALLOWED:
* transparent, inherit, currentColor, none
* Colors inside var() fallbacks
*/
import stylelint from 'stylelint';
const ruleName = 'local/prefer-css-variables';
const messages = stylelint.utils.ruleMessages(ruleName, {
hardcodedColor: (value, property) =>
`Hardcoded color "${value}" in "${property}". Use a semantic CSS variable instead (e.g., var(--l1-foreground), var(--primary-background)). See docs/css-modules-guide.md.`,
});
const COLOR_PROPERTIES = new Set([
'color',
'background',
'background-color',
'background-image',
'border',
'border-color',
'border-top',
'border-right',
'border-bottom',
'border-left',
'border-top-color',
'border-right-color',
'border-bottom-color',
'border-left-color',
'border-image',
'border-image-source',
'outline',
'outline-color',
'box-shadow',
'text-shadow',
'fill',
'stroke',
'caret-color',
'text-decoration-color',
'column-rule-color',
'mask',
'mask-image',
]);
const ALLOWED_VALUES = new Set([
'transparent',
'inherit',
'initial',
'unset',
'currentcolor',
'none',
'auto',
]);
const HEX_PATTERN = /#[0-9a-fA-F]{3,8}\b/;
const RGB_PATTERN = /rgba?\s*\([^)]+\)/i;
const HSL_PATTERN = /hsla?\s*\([^)]+\)/i;
const NAMED_COLOR_PATTERN =
/\b(red|blue|green|yellow|orange|purple|pink|black|white|gray|grey|cyan|magenta|brown|navy|teal|olive|maroon|lime|aqua|fuchsia|silver)\b/i;
// Strip balanced `fn(...)` sections (e.g. `var(...)`, `url(...)`) from value.
// Handles nested parens by counting depth.
function stripBalancedFn(value, fnName) {
const needle = `${fnName}(`;
let out = '';
let i = 0;
while (i < value.length) {
if (value.startsWith(needle, i)) {
let depth = 1;
i += needle.length;
while (i < value.length && depth > 0) {
const ch = value[i];
if (ch === '(') {
depth++;
} else if (ch === ')') {
depth--;
}
i++;
}
} else {
out += value[i];
i++;
}
}
return out;
}
function containsHardcodedColor(value) {
// Strip url(...) first — paths/fragments may contain hex-like or color-named substrings
let scanned = value.includes('url(') ? stripBalancedFn(value, 'url') : value;
// Strip var(...) — color tokens inside var fallbacks are allowed
if (scanned.includes('var(')) {
scanned = stripBalancedFn(scanned, 'var');
if (!scanned.trim()) {
return null;
}
}
const lower = scanned.toLowerCase();
if (ALLOWED_VALUES.has(lower)) {
return null;
}
if (HEX_PATTERN.test(scanned)) {
return scanned.match(HEX_PATTERN)[0];
}
if (RGB_PATTERN.test(scanned)) {
return scanned.match(RGB_PATTERN)[0];
}
if (HSL_PATTERN.test(scanned)) {
return scanned.match(HSL_PATTERN)[0];
}
if (NAMED_COLOR_PATTERN.test(scanned)) {
return scanned.match(NAMED_COLOR_PATTERN)[0];
}
return null;
}
const rule = (primaryOption) => {
return (root, result) => {
if (!primaryOption) {
return;
}
root.walkDecls((decl) => {
const prop = decl.prop.toLowerCase();
if (!COLOR_PROPERTIES.has(prop)) {
return;
}
const hardcodedColor = containsHardcodedColor(decl.value);
if (hardcodedColor) {
stylelint.utils.report({
message: messages.hardcodedColor(hardcodedColor, decl.prop),
node: decl,
result,
ruleName,
});
}
});
};
};
rule.ruleName = ruleName;
rule.messages = messages;
rule.meta = {};
export { ruleName, rule };
export default { ruleName, rule };

1
go.mod
View File

@@ -18,7 +18,6 @@ require (
github.com/dgraph-io/ristretto/v2 v2.3.0
github.com/dustin/go-humanize v1.0.1
github.com/emersion/go-smtp v0.24.0
github.com/evanphx/json-patch/v5 v5.9.11
github.com/gin-gonic/gin v1.11.0
github.com/go-co-op/gocron v1.30.1
github.com/go-openapi/runtime v0.29.2

2
go.sum
View File

@@ -311,8 +311,6 @@ github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQ
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds=
github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0=
github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU=
github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM=
github.com/facette/natsort v0.0.0-20181210072756-2cd4dd1e2dcb h1:IT4JYU7k4ikYg1SCxNI1/Tieq/NFvh6dzLdgi7eu0tM=
github.com/facette/natsort v0.0.0-20181210072756-2cd4dd1e2dcb/go.mod h1:bH6Xx7IW64qjjJq8M2u4dxNaBiDfKK+z/3eGDpXEQhc=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=

View File

@@ -24,10 +24,9 @@ func (provider *provider) addDashboardRoutes(router *mux.Router) error {
Response: new(dashboardtypes.GettableDashboardV2),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
// TODO: add http.StatusConflict once the dashboard name unique index is added.
ErrorStatusCodes: []int{http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
@@ -42,87 +41,13 @@ func (provider *provider) addDashboardRoutes(router *mux.Router) error {
Response: new(dashboardtypes.GettableDashboardV2),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/dashboards/{id}", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.UpdateV2), handler.OpenAPIDef{
ID: "UpdateDashboardV2",
Tags: []string{"dashboard"},
Summary: "Update dashboard (v2)",
Description: "This endpoint updates a v2-shape dashboard's metadata, data, and tag set. Locked dashboards are rejected.",
Request: new(dashboardtypes.UpdatableDashboardV2),
RequestContentType: "application/json",
Response: new(dashboardtypes.GettableDashboardV2),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/dashboards/{id}", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.PatchV2), handler.OpenAPIDef{
ID: "PatchDashboardV2",
Tags: []string{"dashboard"},
Summary: "Patch dashboard (v2)",
Description: "This endpoint applies an RFC 6902 JSON Patch to a v2-shape dashboard. The patch is applied against the postable view of the dashboard (metadata, data, tags), so individual panels, queries, variables, layouts, or tags can be updated without re-sending the rest of the dashboard. Apply is lenient: `remove` on a missing path is a no-op (idempotent) and `add` creates any missing parent objects, rather than failing as strict RFC 6902 would. The resulting dashboard is still validated. Locked dashboards are rejected.",
Request: new(dashboardtypes.PatchableDashboardV2),
// Strictly per RFC 6902 the content type is `application/json-patch+json`,
// but our OpenAPI generator only reflects schemas for content types it
// understands (application/json, form-urlencoded, multipart) — anything
// else degrades to `type: string`. Declaring application/json here keeps
// the array-of-ops schema visible to spec consumers; the runtime decoder
// parses JSON regardless of the request's actual Content-Type header.
RequestContentType: "application/json",
Response: new(dashboardtypes.GettableDashboardV2),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPatch).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/dashboards/{id}/lock", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.LockV2), handler.OpenAPIDef{
ID: "LockDashboardV2",
Tags: []string{"dashboard"},
Summary: "Lock dashboard (v2)",
Description: "This endpoint locks a v2-shape dashboard. Only the dashboard's creator or an org admin may lock or unlock.",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/dashboards/{id}/lock", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.UnlockV2), handler.OpenAPIDef{
ID: "UnlockDashboardV2",
Tags: []string{"dashboard"},
Summary: "Unlock dashboard (v2)",
Description: "This endpoint unlocks a v2-shape dashboard. Only the dashboard's creator or an org admin may lock or unlock.",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/dashboards/{id}/public", handler.New(provider.authzMiddleware.AdminAccess(provider.dashboardHandler.CreatePublic), handler.OpenAPIDef{
ID: "CreatePublicDashboard",
Tags: []string{"dashboard"},

View File

@@ -60,12 +60,6 @@ type Module interface {
CreateV2(ctx context.Context, orgID valuer.UUID, createdBy string, creator valuer.UUID, source dashboardtypes.Source, postable dashboardtypes.PostableDashboardV2) (*dashboardtypes.DashboardV2, error)
GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error)
UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, updatable dashboardtypes.UpdatableDashboardV2) (*dashboardtypes.DashboardV2, error)
LockUnlockV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, isAdmin bool, lock bool) error
PatchV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, patch dashboardtypes.PatchableDashboardV2) (*dashboardtypes.DashboardV2, error)
}
type Handler interface {
@@ -95,12 +89,4 @@ type Handler interface {
CreateV2(http.ResponseWriter, *http.Request)
GetV2(http.ResponseWriter, *http.Request)
UpdateV2(http.ResponseWriter, *http.Request)
LockV2(http.ResponseWriter, *http.Request)
UnlockV2(http.ResponseWriter, *http.Request)
PatchV2(http.ResponseWriter, *http.Request)
}

View File

@@ -153,7 +153,7 @@ func (store *store) ListPublic(ctx context.Context, orgID valuer.UUID) ([]*dashb
func (store *store) Update(ctx context.Context, orgID valuer.UUID, storableDashboard *dashboardtypes.StorableDashboard) error {
_, err := store.
sqlstore.
BunDBCtx(ctx).
BunDB().
NewUpdate().
Model(storableDashboard).
WherePK().

View File

@@ -9,7 +9,6 @@ import (
"github.com/SigNoz/signoz/pkg/http/binding"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
@@ -73,135 +72,3 @@ func (handler *handler) GetV2(rw http.ResponseWriter, r *http.Request) {
render.Success(rw, http.StatusOK, dashboard.ToGettableDashboardV2())
}
func (handler *handler) LockV2(rw http.ResponseWriter, r *http.Request) {
handler.lockUnlockV2(rw, r, true)
}
func (handler *handler) UnlockV2(rw http.ResponseWriter, r *http.Request) {
handler.lockUnlockV2(rw, r, false)
}
func (handler *handler) lockUnlockV2(rw http.ResponseWriter, r *http.Request, lock bool) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
id := mux.Vars(r)["id"]
if id == "" {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is missing in the path"))
return
}
dashboardID, err := valuer.NewUUID(id)
if err != nil {
render.Error(rw, err)
return
}
isAdmin := false
selectors := []coretypes.Selector{
coretypes.TypeRole.MustSelector(authtypes.SigNozAdminRoleName),
}
err = handler.authz.CheckWithTupleCreation(
ctx,
claims,
orgID,
authtypes.Relation{Verb: coretypes.VerbAssignee},
coretypes.NewResourceRole(),
selectors,
selectors,
)
if err == nil {
isAdmin = true
}
if err := handler.module.LockUnlockV2(ctx, orgID, dashboardID, claims.Email, isAdmin, lock); err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusNoContent, nil)
}
func (handler *handler) UpdateV2(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
id := mux.Vars(r)["id"]
if id == "" {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is missing in the path"))
return
}
dashboardID, err := valuer.NewUUID(id)
if err != nil {
render.Error(rw, err)
return
}
req := dashboardtypes.UpdatableDashboardV2{}
if err := binding.JSON.BindBody(r.Body, &req); err != nil {
render.Error(rw, err)
return
}
dashboard, err := handler.module.UpdateV2(ctx, orgID, dashboardID, claims.Email, req)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, dashboard.ToGettableDashboardV2())
}
func (handler *handler) PatchV2(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
id := mux.Vars(r)["id"]
if id == "" {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is missing in the path"))
return
}
dashboardID, err := valuer.NewUUID(id)
if err != nil {
render.Error(rw, err)
return
}
req := dashboardtypes.PatchableDashboardV2{}
if err := binding.JSON.BindBody(r.Body, &req); err != nil {
render.Error(rw, err)
return
}
dashboard, err := handler.module.PatchV2(ctx, orgID, dashboardID, claims.Email, req)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, dashboard.ToGettableDashboardV2())
}

View File

@@ -55,97 +55,3 @@ func (module *module) GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UU
return storable.ToDashboardV2(tags)
}
func (module *module) UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, updatable dashboardtypes.UpdatableDashboardV2) (*dashboardtypes.DashboardV2, error) {
if err := updatable.Validate(); err != nil {
return nil, err
}
existing, err := module.GetV2(ctx, orgID, id)
if err != nil {
return nil, err
}
// Locked-dashboard / state gate — independent of tags, so run it before the tx.
if err := existing.CanUpdate(); err != nil {
return nil, err
}
err = module.store.RunInTx(ctx, func(ctx context.Context) error {
resolvedTags, err := module.tagModule.SyncTags(ctx, orgID, coretypes.KindDashboard, id, updatable.Tags)
if err != nil {
return err
}
err = existing.Update(updatable, updatedBy, resolvedTags)
if err != nil {
return err
}
storable, err := existing.ToStorableDashboard()
if err != nil {
return err
}
return module.store.Update(ctx, orgID, storable)
})
if err != nil {
return nil, err
}
return existing, nil
}
func (module *module) PatchV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, patch dashboardtypes.PatchableDashboardV2) (*dashboardtypes.DashboardV2, error) {
existing, err := module.GetV2(ctx, orgID, id)
if err != nil {
return nil, err
}
// Locked-dashboard / state gate — independent of tags, so run it before the tx.
if err := existing.CanUpdate(); err != nil {
return nil, err
}
updateable, err := patch.Apply(existing)
if err != nil {
return nil, err
}
err = module.store.RunInTx(ctx, func(ctx context.Context) error {
resolvedTags, err := module.tagModule.SyncTags(ctx, orgID, coretypes.KindDashboard, id, updateable.Tags)
if err != nil {
return err
}
err = existing.Update(*updateable, updatedBy, resolvedTags)
if err != nil {
return err
}
storable, err := existing.ToStorableDashboard()
if err != nil {
return err
}
return module.store.Update(ctx, orgID, storable)
})
if err != nil {
return nil, err
}
return existing, nil
}
func (module *module) LockUnlockV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, isAdmin bool, lock bool) error {
existing, err := module.GetV2(ctx, orgID, id)
if err != nil {
return err
}
if err := existing.LockUnlock(lock, isAdmin, updatedBy); err != nil {
return err
}
storable, err := existing.ToStorableDashboard()
if err != nil {
return err
}
return module.store.Update(ctx, orgID, storable)
}

View File

@@ -363,6 +363,15 @@ func (q *builderQuery[T]) executeWithContext(ctx context.Context, query string,
return nil, err
}
// TODO: This should move to readAsRaw function in consume.go but for now we are keeping it here since it's only relevant for traces
if q.spec.Signal == telemetrytypes.SignalTraces {
if raw, ok := payload.(*qbtypes.RawData); ok {
for _, rr := range raw.Rows {
mergeSpanAttributeColumns(rr.Data)
}
}
}
return &qbtypes.Result{
Type: q.kind,
Value: payload,

View File

@@ -14,6 +14,7 @@ import (
"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
"github.com/SigNoz/signoz/pkg/errors"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/spantypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/bytedance/sonic"
)
@@ -440,6 +441,53 @@ func readAsRaw(rows driver.Rows, queryName string) (*qbtypes.RawData, error) {
}, nil
}
// mergeSpanAttributeColumns merges (attributes_string, attributes_number, attributes_bool, resources_string) into
// unified "attributes" and "resource" keys, and parses the stringified `events`
// and `links` columns into structured slices. Raw DB columns are removed.
func mergeSpanAttributeColumns(data map[string]any) {
attrStr, hasStr := data["attributes_string"]
attrNum, hasNum := data["attributes_number"]
attrBool, hasBool := data["attributes_bool"]
// todo(nitya): move to resource json
resStr, hasRes := data["resources_string"]
if hasStr || hasNum || hasBool || hasRes {
attributes := make(map[string]any)
if m, ok := attrStr.(map[string]string); ok {
for k, v := range m {
attributes[k] = v
}
}
if m, ok := attrNum.(map[string]float64); ok {
for k, v := range m {
attributes[k] = v
}
}
if m, ok := attrBool.(map[string]bool); ok {
for k, v := range m {
attributes[k] = v
}
}
delete(data, "attributes_string")
delete(data, "attributes_number")
delete(data, "attributes_bool")
data["attributes"] = attributes
resource := map[string]string{}
if m, ok := resStr.(map[string]string); ok {
resource = m
}
data["resource"] = resource
delete(data, "resources_string")
}
if raw, ok := data["events"]; ok {
data["events"] = spantypes.ParseEvents(raw)
}
if raw, ok := data["links"]; ok {
data["links"] = spantypes.ParseLinks(raw)
}
}
// numericAsFloat converts numeric types to float64 efficiently.
func numericAsFloat(v any) float64 {
switch x := v.(type) {

View File

@@ -0,0 +1,92 @@
package querier
import (
"reflect"
"testing"
"github.com/SigNoz/signoz/pkg/types/spantypes"
)
func TestMergeSpanAttributeColumns_ParsesEventsAndLinks(t *testing.T) {
data := map[string]any{
"attributes_string": map[string]string{"http.method": "GET"},
"attributes_number": map[string]float64{"http.status_code": 200},
"attributes_bool": map[string]bool{"is_root": true},
"resources_string": map[string]string{"service.name": "api"},
"events": []string{
`{"name":"request_received","timeUnixNano":1778489782759245000,"attributeMap":{"http.method":"GET","http.route":"/api/chat"}}`,
`{"name":"cache_lookup","timeUnixNano":1778489782811697000,"attributeMap":{"cache.hit":"true","cache.key":"user:123:prompt"}}`,
},
"links": `[{"traceId":"abc","spanId":"123","refType":"CHILD_OF"},{"traceId":"def","spanId":"456","refType":"FOLLOWS_FROM"}]`,
}
mergeSpanAttributeColumns(data)
attrs, ok := data["attributes"].(map[string]any)
if !ok {
t.Fatalf("expected attributes to be map[string]any, got %T", data["attributes"])
}
if attrs["http.method"] != "GET" || attrs["http.status_code"] != float64(200) || attrs["is_root"] != true {
t.Fatalf("attributes not merged correctly: %#v", attrs)
}
res, ok := data["resource"].(map[string]string)
if !ok || res["service.name"] != "api" {
t.Fatalf("resource not set correctly: %#v", data["resource"])
}
for _, removed := range []string{"attributes_string", "attributes_number", "attributes_bool", "resources_string"} {
if _, present := data[removed]; present {
t.Fatalf("expected %s to be removed", removed)
}
}
events, ok := data["events"].([]spantypes.EventV2)
if !ok {
t.Fatalf("expected events to be []spantypes.EventV2, got %T", data["events"])
}
wantEvents := []spantypes.EventV2{
{
Name: "request_received",
TimeUnixNano: 1778489782759245000,
Attributes: map[string]any{"http.method": "GET", "http.route": "/api/chat"},
IsError: false,
},
{
Name: "cache_lookup",
TimeUnixNano: 1778489782811697000,
Attributes: map[string]any{"cache.hit": "true", "cache.key": "user:123:prompt"},
},
}
if !reflect.DeepEqual(events, wantEvents) {
t.Fatalf("events parsed incorrectly:\n got: %#v\nwant: %#v", events, wantEvents)
}
links, ok := data["links"].([]spantypes.Link)
if !ok {
t.Fatalf("expected links to be []spantypes.Link, got %T", data["links"])
}
wantLinks := []spantypes.Link{
{TraceID: "abc", SpanID: "123"},
{TraceID: "def", SpanID: "456"},
}
if !reflect.DeepEqual(links, wantLinks) {
t.Fatalf("links parsed incorrectly:\n got: %#v\nwant: %#v", links, wantLinks)
}
}
func TestMergeSpanAttributeColumns_EmptyEventsAndLinks(t *testing.T) {
data := map[string]any{
"events": []string{},
"links": "[]",
}
mergeSpanAttributeColumns(data)
if events, ok := data["events"].([]spantypes.EventV2); !ok || len(events) != 0 {
t.Fatalf("expected empty []spantypes.EventV2, got %#v", data["events"])
}
if links, ok := data["links"].([]spantypes.Link); !ok || len(links) != 0 {
t.Fatalf("expected empty []spantypes.Link, got %#v", data["links"])
}
}

View File

@@ -85,6 +85,13 @@ func (q *traceOperatorQuery) executeWithContext(ctx context.Context, query strin
return nil, err
}
// TODO: This should move to readAsRaw function in consume.go but for now we can keep it here since it's only relevant for traces
if raw, ok := payload.(*qbtypes.RawData); ok {
for _, rr := range raw.Rows {
mergeSpanAttributeColumns(rr.Data)
}
}
return &qbtypes.Result{
Type: q.kind,
Value: payload,

View File

@@ -4,6 +4,48 @@ import (
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
const (
// Internal Columns.
SpanTimestampBucketStartColumn = "ts_bucket_start"
SpanResourceFingerPrintColumn = "resource_fingerprint"
// Intrinsic Columns.
SpanTimestampColumn = "timestamp"
SpanTraceIDColumn = "trace_id"
SpanSpanIDColumn = "span_id"
SpanTraceStateColumn = "trace_state"
SpanParentSpanIDColumn = "parent_span_id"
SpanFlagsColumn = "flags"
SpanNameColumn = "name"
SpanKindColumn = "kind"
SpanKindStringColumn = "kind_string"
SpanDurationNanoColumn = "duration_nano"
SpanStatusCodeColumn = "status_code"
SpanStatusMessageColumn = "status_message"
SpanStatusCodeStringColumn = "status_code_string"
SpanEventsColumn = "events"
SpanLinksColumn = "links"
// Calculated Columns.
SpanResponseStatusCodeColumn = "response_status_code"
SpanExternalHTTPURLColumn = "external_http_url"
SpanHTTPURLColumn = "http_url"
SpanExternalHTTPMethodColumn = "external_http_method"
SpanHTTPMethodColumn = "http_method"
SpanHTTPHostColumn = "http_host"
SpanDBNameColumn = "db_name"
SpanDBOperationColumn = "db_operation"
SpanHasErrorColumn = "has_error"
SpanIsRemoteColumn = "is_remote"
// Contextual Columns.
SpanAttributesStringColumn = "attributes_string"
SpanAttributesNumberColumn = "attributes_number"
SpanAttributesBoolColumn = "attributes_bool"
SpanResourcesStringColumn = "resources_string"
)
var (
IntrinsicFields = map[string]telemetrytypes.TelemetryFieldKey{
"trace_id": {
@@ -336,6 +378,51 @@ var (
SpanSearchScopeRoot = "isroot"
SpanSearchScopeEntryPoint = "isentrypoint"
// IntrinsicSpanFields lists the intrinsic span columns, in the order they
// should appear when a raw query expands its SelectFields.
IntrinsicSpanFields = []telemetrytypes.TelemetryFieldKey{
{Name: SpanTimestampColumn, FieldContext: telemetrytypes.FieldContextSpan},
{Name: SpanTraceIDColumn, FieldContext: telemetrytypes.FieldContextSpan},
{Name: SpanSpanIDColumn, FieldContext: telemetrytypes.FieldContextSpan},
{Name: SpanTraceStateColumn, FieldContext: telemetrytypes.FieldContextSpan},
{Name: SpanParentSpanIDColumn, FieldContext: telemetrytypes.FieldContextSpan},
{Name: SpanFlagsColumn, FieldContext: telemetrytypes.FieldContextSpan},
{Name: SpanNameColumn, FieldContext: telemetrytypes.FieldContextSpan},
{Name: SpanKindColumn, FieldContext: telemetrytypes.FieldContextSpan},
{Name: SpanKindStringColumn, FieldContext: telemetrytypes.FieldContextSpan},
{Name: SpanDurationNanoColumn, FieldContext: telemetrytypes.FieldContextSpan},
{Name: SpanStatusCodeColumn, FieldContext: telemetrytypes.FieldContextSpan},
{Name: SpanStatusMessageColumn, FieldContext: telemetrytypes.FieldContextSpan},
{Name: SpanStatusCodeStringColumn, FieldContext: telemetrytypes.FieldContextSpan},
{Name: SpanEventsColumn, FieldContext: telemetrytypes.FieldContextSpan},
{Name: SpanLinksColumn, FieldContext: telemetrytypes.FieldContextSpan},
}
// CalculatedSpanFields lists the calculated/derived span columns, in the
// order they should appear when a raw query expands its SelectFields.
CalculatedSpanFields = []telemetrytypes.TelemetryFieldKey{
{Name: SpanResponseStatusCodeColumn, FieldContext: telemetrytypes.FieldContextSpan},
{Name: SpanExternalHTTPURLColumn, FieldContext: telemetrytypes.FieldContextSpan},
{Name: SpanHTTPURLColumn, FieldContext: telemetrytypes.FieldContextSpan},
{Name: SpanExternalHTTPMethodColumn, FieldContext: telemetrytypes.FieldContextSpan},
{Name: SpanHTTPMethodColumn, FieldContext: telemetrytypes.FieldContextSpan},
{Name: SpanHTTPHostColumn, FieldContext: telemetrytypes.FieldContextSpan},
{Name: SpanDBNameColumn, FieldContext: telemetrytypes.FieldContextSpan},
{Name: SpanDBOperationColumn, FieldContext: telemetrytypes.FieldContextSpan},
{Name: SpanHasErrorColumn, FieldContext: telemetrytypes.FieldContextSpan},
{Name: SpanIsRemoteColumn, FieldContext: telemetrytypes.FieldContextSpan},
}
// ContextualSpanColumns lists the typed attribute and resource columns
// selected raw (rather than via ColumnExpressionFor) so that consume.go
// can merge them into unified "attributes" and "resource" maps.
ContextualSpanColumns = []string{
SpanAttributesStringColumn,
SpanAttributesNumberColumn,
SpanAttributesBoolColumn,
SpanResourcesStringColumn,
}
DefaultFields = map[string]telemetrytypes.TelemetryFieldKey{
"timestamp": {
Name: "timestamp",

View File

@@ -82,6 +82,17 @@ func TestGetFieldKeyName(t *testing.T) {
expectedResult: "multiIf(resource.`deployment.environment` IS NOT NULL, resource.`deployment.environment`::String, `resource_string_deployment$$environment_exists`==true, `resource_string_deployment$$environment`, NULL)",
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",
key: telemetrytypes.TelemetryFieldKey{
Name: SpanAttributesStringColumn,
FieldContext: telemetrytypes.FieldContextAttribute,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
expectedResult: "attributes_string['attributes_string']",
expectedError: nil,
},
{
name: "Non-existent column",
key: telemetrytypes.TelemetryFieldKey{

View File

@@ -4,7 +4,6 @@ import (
"context"
"fmt"
"log/slog"
"slices"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
@@ -16,7 +15,6 @@ import (
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/huandu/go-sqlbuilder"
"golang.org/x/exp/maps"
)
var (
@@ -86,40 +84,13 @@ func (b *traceQueryStatementBuilder) Build(
start = querybuilder.ToNanoSecs(start)
end = querybuilder.ToNanoSecs(end)
/*
Adding a tech debt note here:
This piece of code is a hot fix and should be removed once we close issue: engineering-pod/issues/3622
*/
/*
-------------------------------- Start of tech debt ----------------------------
*/
isSelectFieldsEmpty := false
if requestType == qbtypes.RequestTypeRaw {
selectedFields := query.SelectFields
if len(selectedFields) == 0 {
sortedKeys := maps.Keys(DefaultFields)
slices.Sort(sortedKeys)
for _, key := range sortedKeys {
selectedFields = append(selectedFields, DefaultFields[key])
}
query.SelectFields = selectedFields
}
selectFieldKeys := []string{}
for _, field := range selectedFields {
selectFieldKeys = append(selectFieldKeys, field.Name)
}
for _, x := range []string{"timestamp", "span_id", "trace_id"} {
if !slices.Contains(selectFieldKeys, x) {
query.SelectFields = append(query.SelectFields, DefaultFields[x])
}
}
isSelectFieldsEmpty = len(query.SelectFields) == 0
// we are expanding here to ensure that all the conflicts are taken care in adjustKeys
// i.e if there is a conflict we strip away context of the key in adjustKeys
query = b.expandRawSelectFields(query)
}
/*
-------------------------------- End of tech debt ----------------------------
*/
// We modify SelectFields above (injecting default fields), and those default
// fields can carry keys that need evolutions, so fetch keys after that.
@@ -139,7 +110,7 @@ func (b *traceQueryStatementBuilder) Build(
switch requestType {
case qbtypes.RequestTypeRaw:
return b.buildListQuery(ctx, q, query, start, end, keys, variables)
return b.buildListQuery(ctx, q, query, start, end, keys, variables, isSelectFieldsEmpty)
case qbtypes.RequestTypeTimeSeries:
return b.buildTimeSeriesQuery(ctx, q, query, start, end, keys, variables)
case qbtypes.RequestTypeScalar:
@@ -305,6 +276,7 @@ func (b *traceQueryStatementBuilder) buildListQuery(
start, end uint64,
keys map[string][]*telemetrytypes.TelemetryFieldKey,
variables map[string]qbtypes.VariableItem,
isSelectFieldsEmpty bool,
) (*qbtypes.Statement, error) {
var (
@@ -321,7 +293,6 @@ func (b *traceQueryStatementBuilder) buildListQuery(
cteArgs = append(cteArgs, args)
}
// TODO: should we deprecate `SelectFields` and return everything from a span like we do for logs?
for _, field := range query.SelectFields {
colExpr, err := b.fm.ColumnExpressionFor(ctx, start, end, &field, keys)
if err != nil {
@@ -330,6 +301,12 @@ func (b *traceQueryStatementBuilder) buildListQuery(
sb.SelectMore(colExpr)
}
if isSelectFieldsEmpty {
for _, col := range ContextualSpanColumns {
sb.SelectMore(col)
}
}
// From table
sb.From(fmt.Sprintf("%s.%s", DBName, SpanIndexV3TableName))
@@ -851,3 +828,30 @@ func (b *traceQueryStatementBuilder) maybeAttachResourceFilter(
sb.Where("resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter)")
return fmt.Sprintf("__resource_filter AS (%s)", stmt.Query), stmt.Args, true, nil
}
// expandRawSelectFields populates SelectFields for raw (list view) queries.
// It must be called before adjustKeys so that normalization runs over the full set.
func (b *traceQueryStatementBuilder) expandRawSelectFields(query qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]) qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation] {
if len(query.SelectFields) == 0 {
selectFields := make([]telemetrytypes.TelemetryFieldKey, 0, len(IntrinsicSpanFields)+len(CalculatedSpanFields))
selectFields = append(selectFields, IntrinsicSpanFields...)
selectFields = append(selectFields, CalculatedSpanFields...)
query.SelectFields = selectFields
return query
}
selectFields := []telemetrytypes.TelemetryFieldKey{
{Name: SpanTimestampColumn, FieldContext: telemetrytypes.FieldContextSpan},
{Name: SpanTraceIDColumn, FieldContext: telemetrytypes.FieldContextSpan},
{Name: SpanSpanIDColumn, FieldContext: telemetrytypes.FieldContextSpan},
}
for _, field := range query.SelectFields {
// TODO(tvats): If a user specifies attribute.timestamp in the select fields, this loop will basically ignore it, as we already added a field by default. This can be fixed once we close https://github.com/SigNoz/engineering-pod/issues/3693
if field.Name == SpanTimestampColumn || field.Name == SpanTraceIDColumn || field.Name == SpanSpanIDColumn {
continue
}
selectFields = append(selectFields, field)
}
query.SelectFields = selectFields
return query
}

View File

@@ -462,7 +462,7 @@ func TestStatementBuilderListQuery(t *testing.T) {
},
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT name AS `name`, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) AS `service.name`, duration_nano AS `duration_nano`, `attribute_number_cart$$items_count` AS `cart.items_count`, timestamp AS `timestamp`, span_id AS `span_id`, trace_id AS `trace_id` FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT timestamp AS `timestamp`, trace_id AS `trace_id`, span_id AS `span_id`, name AS `name`, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) AS `service.name`, duration_nano AS `duration_nano`, `attribute_number_cart$$items_count` AS `cart.items_count` FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"redis-manual", "%service.name%", "%service.name\":\"redis-manual%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
},
expectedErr: nil,
@@ -491,7 +491,7 @@ func TestStatementBuilderListQuery(t *testing.T) {
Limit: 10,
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT duration_nano AS `duration_nano`, name AS `name`, response_status_code AS `response_status_code`, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) AS `service.name`, span_id AS `span_id`, timestamp AS `timestamp`, trace_id AS `trace_id` FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? ORDER BY attributes_string['user.id'] AS `user.id` desc LIMIT ?",
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT timestamp AS `timestamp`, trace_id AS `trace_id`, span_id AS `span_id`, trace_state AS `trace_state`, parent_span_id AS `parent_span_id`, flags AS `flags`, name AS `name`, kind AS `kind`, kind_string AS `kind_string`, duration_nano AS `duration_nano`, status_code AS `status_code`, status_message AS `status_message`, status_code_string AS `status_code_string`, events AS `events`, links AS `links`, response_status_code AS `response_status_code`, external_http_url AS `external_http_url`, http_url AS `http_url`, external_http_method AS `external_http_method`, http_method AS `http_method`, http_host AS `http_host`, db_name AS `db_name`, db_operation AS `db_operation`, has_error AS `has_error`, is_remote AS `is_remote`, attributes_string, attributes_number, attributes_bool, resources_string FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? ORDER BY attributes_string['user.id'] AS `user.id` desc LIMIT ?",
Args: []any{"redis-manual", "%service.name%", "%service.name\":\"redis-manual%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
},
expectedErr: nil,
@@ -535,7 +535,7 @@ func TestStatementBuilderListQuery(t *testing.T) {
Limit: 10,
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT name AS `name`, resource_string_service$$name AS `serviceName`, duration_nano AS `durationNano`, http_method AS `httpMethod`, response_status_code AS `responseStatusCode`, timestamp AS `timestamp`, span_id AS `span_id`, trace_id AS `trace_id` FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT timestamp AS `timestamp`, trace_id AS `trace_id`, span_id AS `span_id`, name AS `name`, resource_string_service$$name AS `serviceName`, duration_nano AS `durationNano`, http_method AS `httpMethod`, response_status_code AS `responseStatusCode` FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"redis-manual", "%service.name%", "%service.name\":\"redis-manual%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
},
expectedErr: nil,
@@ -579,7 +579,7 @@ func TestStatementBuilderListQuery(t *testing.T) {
Limit: 10,
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT name AS `name`, resource_string_service$$name AS `serviceName`, duration_nano AS `durationNano`, http_method AS `httpMethod`, multiIf(toString(`attribute_string_mixed$$materialization$$key`) != '', toString(`attribute_string_mixed$$materialization$$key`), toString(multiIf(resource.`mixed.materialization.key` IS NOT NULL, resource.`mixed.materialization.key`::String, mapContains(resources_string, 'mixed.materialization.key'), resources_string['mixed.materialization.key'], NULL)) != '', toString(multiIf(resource.`mixed.materialization.key` IS NOT NULL, resource.`mixed.materialization.key`::String, mapContains(resources_string, 'mixed.materialization.key'), resources_string['mixed.materialization.key'], NULL)), NULL) AS `mixed.materialization.key`, timestamp AS `timestamp`, span_id AS `span_id`, trace_id AS `trace_id` FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT timestamp AS `timestamp`, trace_id AS `trace_id`, span_id AS `span_id`, name AS `name`, resource_string_service$$name AS `serviceName`, duration_nano AS `durationNano`, http_method AS `httpMethod`, multiIf(toString(`attribute_string_mixed$$materialization$$key`) != '', toString(`attribute_string_mixed$$materialization$$key`), toString(multiIf(resource.`mixed.materialization.key` IS NOT NULL, resource.`mixed.materialization.key`::String, mapContains(resources_string, 'mixed.materialization.key'), resources_string['mixed.materialization.key'], NULL)) != '', toString(multiIf(resource.`mixed.materialization.key` IS NOT NULL, resource.`mixed.materialization.key`::String, mapContains(resources_string, 'mixed.materialization.key'), resources_string['mixed.materialization.key'], NULL)), NULL) AS `mixed.materialization.key` FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"redis-manual", "%service.name%", "%service.name\":\"redis-manual%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
},
expectedErr: nil,
@@ -624,7 +624,7 @@ func TestStatementBuilderListQuery(t *testing.T) {
Limit: 10,
},
expected: qbtypes.Statement{
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT name AS `name`, resource_string_service$$name AS `serviceName`, duration_nano AS `durationNano`, http_method AS `httpMethod`, `attribute_string_mixed$$materialization$$key` AS `mixed.materialization.key`, timestamp AS `timestamp`, span_id AS `span_id`, trace_id AS `trace_id` FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
Query: "WITH __resource_filter AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint) SELECT timestamp AS `timestamp`, trace_id AS `trace_id`, span_id AS `span_id`, name AS `name`, resource_string_service$$name AS `serviceName`, duration_nano AS `durationNano`, http_method AS `httpMethod`, `attribute_string_mixed$$materialization$$key` AS `mixed.materialization.key` FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
Args: []any{"redis-manual", "%service.name%", "%service.name\":\"redis-manual%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
},
expectedErr: nil,
@@ -746,7 +746,7 @@ func TestStatementBuilderListQueryWithCorruptData(t *testing.T) {
Limit: 10,
},
expected: qbtypes.Statement{
Query: "SELECT duration_nano AS `duration_nano`, name AS `name`, response_status_code AS `response_status_code`, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) AS `service.name`, span_id AS `span_id`, timestamp AS `timestamp`, trace_id AS `trace_id` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? LIMIT ?",
Query: "SELECT timestamp AS `timestamp`, trace_id AS `trace_id`, span_id AS `span_id`, trace_state AS `trace_state`, parent_span_id AS `parent_span_id`, flags AS `flags`, name AS `name`, kind AS `kind`, kind_string AS `kind_string`, duration_nano AS `duration_nano`, status_code AS `status_code`, status_message AS `status_message`, status_code_string AS `status_code_string`, events AS `events`, links AS `links`, response_status_code AS `response_status_code`, external_http_url AS `external_http_url`, http_url AS `http_url`, external_http_method AS `external_http_method`, http_method AS `http_method`, http_host AS `http_host`, db_name AS `db_name`, db_operation AS `db_operation`, has_error AS `has_error`, is_remote AS `is_remote`, attributes_string, attributes_number, attributes_bool, resources_string 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},
},
expectedErr: nil,
@@ -788,7 +788,7 @@ func TestStatementBuilderListQueryWithCorruptData(t *testing.T) {
}},
},
expected: qbtypes.Statement{
Query: "SELECT duration_nano AS `duration_nano`, name AS `name`, response_status_code AS `response_status_code`, multiIf(resource.`service.name` IS NOT NULL, resource.`service.name`::String, mapContains(resources_string, 'service.name'), resources_string['service.name'], NULL) AS `service.name`, span_id AS `span_id`, timestamp AS `timestamp`, trace_id AS `trace_id` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? ORDER BY timestamp AS `timestamp` asc LIMIT ?",
Query: "SELECT timestamp AS `timestamp`, trace_id AS `trace_id`, span_id AS `span_id`, trace_state AS `trace_state`, parent_span_id AS `parent_span_id`, flags AS `flags`, name AS `name`, kind AS `kind`, kind_string AS `kind_string`, duration_nano AS `duration_nano`, status_code AS `status_code`, status_message AS `status_message`, status_code_string AS `status_code_string`, events AS `events`, links AS `links`, response_status_code AS `response_status_code`, external_http_url AS `external_http_url`, http_url AS `http_url`, external_http_method AS `external_http_method`, http_method AS `http_method`, http_host AS `http_host`, db_name AS `db_name`, db_operation AS `db_operation`, has_error AS `has_error`, is_remote AS `is_remote`, attributes_string, attributes_number, attributes_bool, resources_string FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ? ORDER BY timestamp AS `timestamp` asc LIMIT ?",
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
},
expectedErr: nil,

View File

@@ -424,6 +424,20 @@ func (b *traceOperatorCTEBuilder) buildNotCTE(leftCTE, rightCTE string) (string,
}
func (b *traceOperatorCTEBuilder) buildFinalQuery(ctx context.Context, selectFromCTE string, requestType qbtypes.RequestType) (*qbtypes.Statement, error) {
// Mirror statement_builder.go::Build: for raw queries, empty selectFields
// expands to the full intrinsic + calculated set, and the list query also
// pulls in the contextual columns so the consume layer can merge them
// into unified attributes/resource (and parse events/links).
isSelectFieldsEmpty := false
if requestType == qbtypes.RequestTypeRaw {
isSelectFieldsEmpty = len(b.operator.SelectFields) == 0
if isSelectFieldsEmpty {
b.operator.SelectFields = make([]telemetrytypes.TelemetryFieldKey, 0, len(IntrinsicSpanFields)+len(CalculatedSpanFields))
b.operator.SelectFields = append(b.operator.SelectFields, IntrinsicSpanFields...)
b.operator.SelectFields = append(b.operator.SelectFields, CalculatedSpanFields...)
}
}
keySelectors := b.getKeySelectors()
keys, _, err := b.stmtBuilder.metadataStore.GetKeysMulti(ctx, keySelectors)
if err != nil {
@@ -433,7 +447,7 @@ func (b *traceOperatorCTEBuilder) buildFinalQuery(ctx context.Context, selectFro
switch requestType {
case qbtypes.RequestTypeRaw:
return b.buildListQuery(ctx, selectFromCTE, keys)
return b.buildListQuery(ctx, selectFromCTE, keys, isSelectFieldsEmpty)
case qbtypes.RequestTypeTimeSeries:
return b.buildTimeSeriesQuery(ctx, selectFromCTE, keys)
case qbtypes.RequestTypeTrace:
@@ -445,10 +459,11 @@ func (b *traceOperatorCTEBuilder) buildFinalQuery(ctx context.Context, selectFro
}
}
func (b *traceOperatorCTEBuilder) buildListQuery(ctx context.Context, selectFromCTE string, keys map[string][]*telemetrytypes.TelemetryFieldKey) (*qbtypes.Statement, error) {
func (b *traceOperatorCTEBuilder) buildListQuery(ctx context.Context, selectFromCTE string, keys map[string][]*telemetrytypes.TelemetryFieldKey, isSelectFieldsEmpty bool) (*qbtypes.Statement, error) {
sb := sqlbuilder.NewSelectBuilder()
// Select core fields
// Select core fields. These are always present so the trace operator
// response shape is stable regardless of user-supplied selectFields.
sb.Select(
"timestamp",
"trace_id",
@@ -482,6 +497,12 @@ func (b *traceOperatorCTEBuilder) buildListQuery(ctx context.Context, selectFrom
selectedFields[field.Name] = true
}
if isSelectFieldsEmpty {
for _, col := range ContextualSpanColumns {
sb.SelectMore(col)
}
}
sb.From(selectFromCTE)
// Add order by support using ColumnExpressionFor

View File

@@ -123,7 +123,7 @@ func TestTraceOperatorStatementBuilder(t *testing.T) {
},
},
expected: qbtypes.Statement{
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_B AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_B) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), A_INDIR_DESC_B AS (WITH RECURSIVE up AS (SELECT d.trace_id, d.span_id, d.parent_span_id, 0 AS depth FROM B AS d UNION ALL SELECT p.trace_id, p.span_id, p.parent_span_id, up.depth + 1 FROM all_spans AS p JOIN up ON p.trace_id = up.trace_id AND p.span_id = up.parent_span_id WHERE up.depth < 100) SELECT DISTINCT a.* FROM A AS a GLOBAL INNER JOIN (SELECT DISTINCT trace_id, span_id FROM up WHERE depth > 0 ) AS ancestors ON ancestors.trace_id = a.trace_id AND ancestors.span_id = a.span_id) SELECT timestamp, trace_id, span_id, name, duration_nano, parent_span_id FROM A_INDIR_DESC_B ORDER BY timestamp DESC LIMIT ? SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_B AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_B) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), A_INDIR_DESC_B AS (WITH RECURSIVE up AS (SELECT d.trace_id, d.span_id, d.parent_span_id, 0 AS depth FROM B AS d UNION ALL SELECT p.trace_id, p.span_id, p.parent_span_id, up.depth + 1 FROM all_spans AS p JOIN up ON p.trace_id = up.trace_id AND p.span_id = up.parent_span_id WHERE up.depth < 100) SELECT DISTINCT a.* FROM A AS a GLOBAL INNER JOIN (SELECT DISTINCT trace_id, span_id FROM up WHERE depth > 0 ) AS ancestors ON ancestors.trace_id = a.trace_id AND ancestors.span_id = a.span_id) SELECT timestamp, trace_id, span_id, name, duration_nano, parent_span_id, trace_state AS `trace_state`, flags AS `flags`, kind AS `kind`, kind_string AS `kind_string`, status_code AS `status_code`, status_message AS `status_message`, status_code_string AS `status_code_string`, events AS `events`, links AS `links`, response_status_code AS `response_status_code`, external_http_url AS `external_http_url`, http_url AS `http_url`, external_http_method AS `external_http_method`, http_method AS `http_method`, http_host AS `http_host`, db_name AS `db_name`, db_operation AS `db_operation`, has_error AS `has_error`, is_remote AS `is_remote`, attributes_string, attributes_number, attributes_bool, resources_string FROM A_INDIR_DESC_B ORDER BY timestamp DESC LIMIT ? SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "gateway", "%service.name%", "%service.name\":\"gateway%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "database", "%service.name%", "%service.name\":\"database%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 5},
},
expectedErr: nil,
@@ -160,7 +160,7 @@ func TestTraceOperatorStatementBuilder(t *testing.T) {
},
},
expected: qbtypes.Statement{
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_B AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_B) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), A_AND_B AS (SELECT l.* FROM A AS l INNER JOIN B AS r ON l.trace_id = r.trace_id) SELECT timestamp, trace_id, span_id, name, duration_nano, parent_span_id FROM A_AND_B ORDER BY timestamp DESC LIMIT ? SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_B AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_B) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), A_AND_B AS (SELECT l.* FROM A AS l INNER JOIN B AS r ON l.trace_id = r.trace_id) SELECT timestamp, trace_id, span_id, name, duration_nano, parent_span_id, trace_state AS `trace_state`, flags AS `flags`, kind AS `kind`, kind_string AS `kind_string`, status_code AS `status_code`, status_message AS `status_message`, status_code_string AS `status_code_string`, events AS `events`, links AS `links`, response_status_code AS `response_status_code`, external_http_url AS `external_http_url`, http_url AS `http_url`, external_http_method AS `external_http_method`, http_method AS `http_method`, http_host AS `http_host`, db_name AS `db_name`, db_operation AS `db_operation`, has_error AS `has_error`, is_remote AS `is_remote`, attributes_string, attributes_number, attributes_bool, resources_string FROM A_AND_B ORDER BY timestamp DESC LIMIT ? SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "frontend", "%service.name%", "%service.name\":\"frontend%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "backend", "%service.name%", "%service.name\":\"backend%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 15},
},
expectedErr: nil,
@@ -197,7 +197,7 @@ func TestTraceOperatorStatementBuilder(t *testing.T) {
},
},
expected: qbtypes.Statement{
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_B AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_B) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), A_OR_B AS (SELECT * FROM A UNION DISTINCT SELECT * FROM B) SELECT timestamp, trace_id, span_id, name, duration_nano, parent_span_id FROM A_OR_B ORDER BY timestamp DESC LIMIT ? SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_B AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_B) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), A_OR_B AS (SELECT * FROM A UNION DISTINCT SELECT * FROM B) SELECT timestamp, trace_id, span_id, name, duration_nano, parent_span_id, trace_state AS `trace_state`, flags AS `flags`, kind AS `kind`, kind_string AS `kind_string`, status_code AS `status_code`, status_message AS `status_message`, status_code_string AS `status_code_string`, events AS `events`, links AS `links`, response_status_code AS `response_status_code`, external_http_url AS `external_http_url`, http_url AS `http_url`, external_http_method AS `external_http_method`, http_method AS `http_method`, http_host AS `http_host`, db_name AS `db_name`, db_operation AS `db_operation`, has_error AS `has_error`, is_remote AS `is_remote`, attributes_string, attributes_number, attributes_bool, resources_string FROM A_OR_B ORDER BY timestamp DESC LIMIT ? SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "frontend", "%service.name%", "%service.name\":\"frontend%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "backend", "%service.name%", "%service.name\":\"backend%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 20},
},
expectedErr: nil,
@@ -234,7 +234,7 @@ func TestTraceOperatorStatementBuilder(t *testing.T) {
},
},
expected: qbtypes.Statement{
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_B AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_B) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), A_not_B AS (SELECT l.* FROM A AS l WHERE l.trace_id GLOBAL NOT IN (SELECT DISTINCT trace_id FROM B)) SELECT timestamp, trace_id, span_id, name, duration_nano, parent_span_id FROM A_not_B ORDER BY timestamp DESC LIMIT ? SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_B AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_B) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), A_not_B AS (SELECT l.* FROM A AS l WHERE l.trace_id GLOBAL NOT IN (SELECT DISTINCT trace_id FROM B)) SELECT timestamp, trace_id, span_id, name, duration_nano, parent_span_id, trace_state AS `trace_state`, flags AS `flags`, kind AS `kind`, kind_string AS `kind_string`, status_code AS `status_code`, status_message AS `status_message`, status_code_string AS `status_code_string`, events AS `events`, links AS `links`, response_status_code AS `response_status_code`, external_http_url AS `external_http_url`, http_url AS `http_url`, external_http_method AS `external_http_method`, http_method AS `http_method`, http_host AS `http_host`, db_name AS `db_name`, db_operation AS `db_operation`, has_error AS `has_error`, is_remote AS `is_remote`, attributes_string, attributes_number, attributes_bool, resources_string FROM A_not_B ORDER BY timestamp DESC LIMIT ? SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "frontend", "%service.name%", "%service.name\":\"frontend%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "backend", "%service.name%", "%service.name\":\"backend%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
},
expectedErr: nil,
@@ -399,7 +399,7 @@ func TestTraceOperatorStatementBuilder(t *testing.T) {
},
},
expected: qbtypes.Statement{
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_B AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_B) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), A_DIR_DESC_B AS (SELECT p.* FROM A AS p INNER JOIN B AS c ON p.trace_id = c.trace_id AND p.span_id = c.parent_span_id), __resource_filter_C AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), C AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_C) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_D AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), D AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_D) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), C_DIR_DESC_D AS (SELECT p.* FROM C AS p INNER JOIN D AS c ON p.trace_id = c.trace_id AND p.span_id = c.parent_span_id), A_DIR_DESC_B_AND_C_DIR_DESC_D AS (SELECT l.* FROM A_DIR_DESC_B AS l INNER JOIN C_DIR_DESC_D AS r ON l.trace_id = r.trace_id) SELECT timestamp, trace_id, span_id, name, duration_nano, parent_span_id FROM A_DIR_DESC_B_AND_C_DIR_DESC_D ORDER BY timestamp DESC LIMIT ? SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_B AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_B) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), A_DIR_DESC_B AS (SELECT p.* FROM A AS p INNER JOIN B AS c ON p.trace_id = c.trace_id AND p.span_id = c.parent_span_id), __resource_filter_C AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), C AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_C) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_D AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), D AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_D) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), C_DIR_DESC_D AS (SELECT p.* FROM C AS p INNER JOIN D AS c ON p.trace_id = c.trace_id AND p.span_id = c.parent_span_id), A_DIR_DESC_B_AND_C_DIR_DESC_D AS (SELECT l.* FROM A_DIR_DESC_B AS l INNER JOIN C_DIR_DESC_D AS r ON l.trace_id = r.trace_id) SELECT timestamp, trace_id, span_id, name, duration_nano, parent_span_id, trace_state AS `trace_state`, flags AS `flags`, kind AS `kind`, kind_string AS `kind_string`, status_code AS `status_code`, status_message AS `status_message`, status_code_string AS `status_code_string`, events AS `events`, links AS `links`, response_status_code AS `response_status_code`, external_http_url AS `external_http_url`, http_url AS `http_url`, external_http_method AS `external_http_method`, http_method AS `http_method`, http_host AS `http_host`, db_name AS `db_name`, db_operation AS `db_operation`, has_error AS `has_error`, is_remote AS `is_remote`, attributes_string, attributes_number, attributes_bool, resources_string FROM A_DIR_DESC_B_AND_C_DIR_DESC_D ORDER BY timestamp DESC LIMIT ? SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "frontend", "%service.name%", "%service.name\":\"frontend%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "backend", "%service.name%", "%service.name\":\"backend%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "auth", "%service.name%", "%service.name\":\"auth%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "database", "%service.name%", "%service.name\":\"database%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 5},
},
expectedErr: nil,
@@ -433,7 +433,7 @@ func TestTraceOperatorStatementBuilder(t *testing.T) {
},
},
expected: qbtypes.Statement{
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_B AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_B) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), A_INDIR_DESC_B AS (WITH RECURSIVE up AS (SELECT d.trace_id, d.span_id, d.parent_span_id, 0 AS depth FROM B AS d UNION ALL SELECT p.trace_id, p.span_id, p.parent_span_id, up.depth + 1 FROM all_spans AS p JOIN up ON p.trace_id = up.trace_id AND p.span_id = up.parent_span_id WHERE up.depth < 100) SELECT DISTINCT a.* FROM A AS a GLOBAL INNER JOIN (SELECT DISTINCT trace_id, span_id FROM up WHERE depth > 0 ) AS ancestors ON ancestors.trace_id = a.trace_id AND ancestors.span_id = a.span_id), __return_from_B AS (SELECT * FROM B WHERE trace_id IN (SELECT DISTINCT trace_id FROM A_INDIR_DESC_B)) SELECT timestamp, trace_id, span_id, name, duration_nano, parent_span_id FROM __return_from_B ORDER BY timestamp DESC LIMIT ? SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_B AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_B) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), A_INDIR_DESC_B AS (WITH RECURSIVE up AS (SELECT d.trace_id, d.span_id, d.parent_span_id, 0 AS depth FROM B AS d UNION ALL SELECT p.trace_id, p.span_id, p.parent_span_id, up.depth + 1 FROM all_spans AS p JOIN up ON p.trace_id = up.trace_id AND p.span_id = up.parent_span_id WHERE up.depth < 100) SELECT DISTINCT a.* FROM A AS a GLOBAL INNER JOIN (SELECT DISTINCT trace_id, span_id FROM up WHERE depth > 0 ) AS ancestors ON ancestors.trace_id = a.trace_id AND ancestors.span_id = a.span_id), __return_from_B AS (SELECT * FROM B WHERE trace_id IN (SELECT DISTINCT trace_id FROM A_INDIR_DESC_B)) SELECT timestamp, trace_id, span_id, name, duration_nano, parent_span_id, trace_state AS `trace_state`, flags AS `flags`, kind AS `kind`, kind_string AS `kind_string`, status_code AS `status_code`, status_message AS `status_message`, status_code_string AS `status_code_string`, events AS `events`, links AS `links`, response_status_code AS `response_status_code`, external_http_url AS `external_http_url`, http_url AS `http_url`, external_http_method AS `external_http_method`, http_method AS `http_method`, http_host AS `http_host`, db_name AS `db_name`, db_operation AS `db_operation`, has_error AS `has_error`, is_remote AS `is_remote`, attributes_string, attributes_number, attributes_bool, resources_string FROM __return_from_B ORDER BY timestamp DESC LIMIT ? SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "gateway", "%service.name%", "%service.name\":\"gateway%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "database", "%service.name%", "%service.name\":\"database%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
},
expectedErr: nil,
@@ -475,7 +475,7 @@ func TestTraceOperatorStatementBuilder(t *testing.T) {
},
},
expected: qbtypes.Statement{
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_B AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_B) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), A_INDIR_DESC_B AS (WITH RECURSIVE up AS (SELECT d.trace_id, d.span_id, d.parent_span_id, 0 AS depth FROM B AS d UNION ALL SELECT p.trace_id, p.span_id, p.parent_span_id, up.depth + 1 FROM all_spans AS p JOIN up ON p.trace_id = up.trace_id AND p.span_id = up.parent_span_id WHERE up.depth < 100) SELECT DISTINCT a.* FROM A AS a GLOBAL INNER JOIN (SELECT DISTINCT trace_id, span_id FROM up WHERE depth > 0 ) AS ancestors ON ancestors.trace_id = a.trace_id AND ancestors.span_id = a.span_id), __resource_filter_C AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), C AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_C) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), A_INDIR_DESC_B_AND_C AS (SELECT l.* FROM A_INDIR_DESC_B AS l INNER JOIN C AS r ON l.trace_id = r.trace_id), __return_from_C AS (SELECT * FROM C WHERE trace_id IN (SELECT DISTINCT trace_id FROM A_INDIR_DESC_B_AND_C)) SELECT timestamp, trace_id, span_id, name, duration_nano, parent_span_id FROM __return_from_C ORDER BY timestamp DESC LIMIT ? SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
Query: "WITH toDateTime64(1747947419000000000, 9) AS t_from, toDateTime64(1747983448000000000, 9) AS t_to, 1747945619 AS bucket_from, 1747983448 AS bucket_to, all_spans AS (SELECT *, resource_string_service$$name AS `service.name` FROM signoz_traces.distributed_signoz_index_v3 WHERE timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_A AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), A AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_A) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), __resource_filter_B AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), B AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_B) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), A_INDIR_DESC_B AS (WITH RECURSIVE up AS (SELECT d.trace_id, d.span_id, d.parent_span_id, 0 AS depth FROM B AS d UNION ALL SELECT p.trace_id, p.span_id, p.parent_span_id, up.depth + 1 FROM all_spans AS p JOIN up ON p.trace_id = up.trace_id AND p.span_id = up.parent_span_id WHERE up.depth < 100) SELECT DISTINCT a.* FROM A AS a GLOBAL INNER JOIN (SELECT DISTINCT trace_id, span_id FROM up WHERE depth > 0 ) AS ancestors ON ancestors.trace_id = a.trace_id AND ancestors.span_id = a.span_id), __resource_filter_C AS (SELECT fingerprint FROM signoz_traces.distributed_traces_v3_resource WHERE (simpleJSONExtractString(labels, 'service.name') = ? AND labels LIKE ? AND labels LIKE ?) AND seen_at_ts_bucket_start >= ? AND seen_at_ts_bucket_start <= ? GROUP BY fingerprint), C AS (SELECT * FROM signoz_traces.distributed_signoz_index_v3 WHERE resource_fingerprint GLOBAL IN (SELECT fingerprint FROM __resource_filter_C) AND timestamp >= ? AND timestamp < ? AND ts_bucket_start >= ? AND ts_bucket_start <= ?), A_INDIR_DESC_B_AND_C AS (SELECT l.* FROM A_INDIR_DESC_B AS l INNER JOIN C AS r ON l.trace_id = r.trace_id), __return_from_C AS (SELECT * FROM C WHERE trace_id IN (SELECT DISTINCT trace_id FROM A_INDIR_DESC_B_AND_C)) SELECT timestamp, trace_id, span_id, name, duration_nano, parent_span_id, trace_state AS `trace_state`, flags AS `flags`, kind AS `kind`, kind_string AS `kind_string`, status_code AS `status_code`, status_message AS `status_message`, status_code_string AS `status_code_string`, events AS `events`, links AS `links`, response_status_code AS `response_status_code`, external_http_url AS `external_http_url`, http_url AS `http_url`, external_http_method AS `external_http_method`, http_method AS `http_method`, http_host AS `http_host`, db_name AS `db_name`, db_operation AS `db_operation`, has_error AS `has_error`, is_remote AS `is_remote`, attributes_string, attributes_number, attributes_bool, resources_string FROM __return_from_C ORDER BY timestamp DESC LIMIT ? SETTINGS distributed_product_mode='allow', max_memory_usage=10000000000",
Args: []any{"1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "gateway", "%service.name%", "%service.name\":\"gateway%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "database", "%service.name%", "%service.name\":\"database%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), "auth", "%service.name%", "%service.name\":\"auth%", uint64(1747945619), uint64(1747983448), "1747947419000000000", "1747983448000000000", uint64(1747945619), uint64(1747983448), 10},
},
expectedErr: nil,

View File

@@ -21,7 +21,6 @@ var (
ErrCodeDashboardInvalidWidgetQuery = errors.MustNewCode("dashboard_invalid_widget_query")
ErrCodeDashboardInvalidSource = errors.MustNewCode("dashboard_invalid_source")
ErrCodeDashboardImmutable = errors.MustNewCode("dashboard_immutable")
ErrCodeDashboardInvalidPatch = errors.MustNewCode("dashboard_invalid_patch")
)
type StorableDashboard struct {

View File

@@ -62,54 +62,6 @@ type DashboardV2 struct {
Spec DashboardSpec `json:"spec" required:"true"`
}
func (d *DashboardV2) CanUpdate() error {
if d.Source == SourceIntegration {
return errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardImmutable, "integration dashboards cannot be modified")
}
if d.Locked {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "cannot update a locked dashboard, please unlock the dashboard to update")
}
return nil
}
func (d *DashboardV2) Update(updatable UpdatableDashboardV2, updatedBy string, resolvedTags []*tagtypes.Tag) error {
if err := d.CanUpdate(); err != nil {
return err
}
if updatable.Name != d.Name {
return errors.NewInvalidInputf(ErrCodeDashboardImmutable, "name is immutable; cannot change from %q to %q", d.Name, updatable.Name)
}
d.DashboardV2MetadataBase = updatable.DashboardV2MetadataBase
d.Tags = resolvedTags
d.Spec = updatable.Spec
d.UpdatedBy = updatedBy
d.UpdatedAt = time.Now()
return nil
}
func (d *DashboardV2) CanLockUnlock(isAdmin bool, updatedBy string) error {
if d.Source == SourceIntegration {
return errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardImmutable, "integration dashboards cannot be locked or unlocked")
}
if d.Source == SourceSystem {
return errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardImmutable, "system dashboards cannot be locked or unlocked")
}
if d.CreatedBy != updatedBy && !isAdmin {
return errors.Newf(errors.TypeForbidden, errors.CodeForbidden, "you are not authorized to lock/unlock this dashboard")
}
return nil
}
func (d *DashboardV2) LockUnlock(lock bool, isAdmin bool, updatedBy string) error {
if err := d.CanLockUnlock(isAdmin, updatedBy); err != nil {
return err
}
d.Locked = lock
d.UpdatedBy = updatedBy
d.UpdatedAt = time.Now()
return nil
}
type DashboardV2MetadataBase struct {
SchemaVersion string `json:"schemaVersion" required:"true"`
Image string `json:"image,omitempty"`
@@ -174,7 +126,7 @@ func (p *PostableDashboardV2) Validate() error {
if err := p.validateName(); err != nil {
return err
}
if err := validateDashboardTags(p.Tags); err != nil {
if err := p.validateTags(); err != nil {
return err
}
return p.Spec.Validate()
@@ -241,11 +193,11 @@ func generateDashboardName(displayName string) string {
return prefix + "-" + string(suffix)
}
func validateDashboardTags(tags []tagtypes.PostableTag) error {
if len(tags) > MaxTagsPerDashboard {
func (p *PostableDashboardV2) validateTags() error {
if len(p.Tags) > MaxTagsPerDashboard {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "a dashboard can have at most %d tags", MaxTagsPerDashboard)
}
for _, tag := range tags {
for _, tag := range p.Tags {
if _, reserved := reservedDSLKeys[DSLKey(strings.ToLower(strings.TrimSpace(tag.Key)))]; reserved {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "tag key %q is reserved", tag.Key)
}
@@ -311,57 +263,6 @@ func (s StorableDashboardV2Data) toStorableDashboardData() (StorableDashboardDat
type StorableDashboardV2Metadata = DashboardV2MetadataBase
// ════════════════════════════════════════════════════════════════════════
// Updatable
// ════════════════════════════════════════════════════════════════════════
type UpdatableDashboardV2 struct {
DashboardV2MetadataBase
Name string `json:"name" required:"true"`
Tags []tagtypes.PostableTag `json:"tags" required:"true"`
Spec DashboardSpec `json:"spec" required:"true"`
}
func (u *UpdatableDashboardV2) UnmarshalJSON(data []byte) error {
dec := json.NewDecoder(bytes.NewReader(data))
dec.DisallowUnknownFields()
type alias UpdatableDashboardV2
var tmp alias
if err := dec.Decode(&tmp); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s", err.Error())
}
*u = UpdatableDashboardV2(tmp)
if u.Spec.Display == nil {
u.Spec.Display = &common.Display{}
}
if u.Spec.Display.Name == "" {
u.Spec.Display.Name = u.Name
}
return u.Validate()
}
func (u *UpdatableDashboardV2) Validate() error {
if u.SchemaVersion != SchemaVersion {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "schemaVersion must be %q, got %q", SchemaVersion, u.SchemaVersion)
}
if err := validateDashboardName(u.Name); err != nil {
return err
}
if err := validateDashboardTags(u.Tags); err != nil {
return err
}
return u.Spec.Validate()
}
func (d DashboardV2) toUpdatableDashboardV2() UpdatableDashboardV2 {
return UpdatableDashboardV2{
DashboardV2MetadataBase: d.DashboardV2MetadataBase,
Name: d.Name,
Tags: tagtypes.NewPostableTagsFromTags(d.Tags),
Spec: d.Spec,
}
}
// ════════════════════════════════════════════════════════════════════════
// Convertors
// ════════════════════════════════════════════════════════════════════════

View File

@@ -1,83 +0,0 @@
package dashboardtypes
import (
"encoding/json"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/valuer"
jsonpatch "github.com/evanphx/json-patch/v5"
"github.com/swaggest/jsonschema-go"
)
// PatchableDashboardV2 is an RFC 6902 patch request.
type PatchableDashboardV2 struct {
// Ops shapes the OpenAPI schema; tagged so swaggest reflects it.
Ops []JSONPatchOperation `json:"ops"`
// patch holds the decoded payload, set by UnmarshalJSON.
patch jsonpatch.Patch
}
// PrepareJSONSchema collapses the struct's object schema into the bare ops array.
func (PatchableDashboardV2) PrepareJSONSchema(s *jsonschema.Schema) error {
// Called on several passes; only the one with built properties carries `ops`.
ops, ok := s.Properties["ops"]
if !ok || ops.TypeObject == nil {
return nil
}
*s = *ops.TypeObject
return nil
}
type JSONPatchOperation struct {
Op PatchOp `json:"op" required:"true"`
Path string `json:"path" required:"true" description:"JSON Pointer (RFC 6901) into the dashboard's postable shape — e.g. /spec/display/name, /spec/panels/<id>, /spec/panels/<id>/spec/queries/0, /tags/-."`
// `value` is required for add/replace/test.
Value any `json:"value,omitempty" description:"Value to add/replace/test against. The expected type depends on the path. Common shapes (see referenced schemas for the exact field set): /spec/panels/<id> takes a DashboardtypesPanel; /spec/panels/<id>/spec/queries/N (or /-) takes a DashboardtypesQuery; /spec/variables/N takes a DashboardtypesVariable; /spec/layouts/N takes a DashboardtypesLayout; /tags/N (or /-) takes a TagtypesPostableTag; /spec/display/name and other leaf string fields take a string. Required for add/replace/test; ignored for remove/move/copy."`
// `from` is required for move/copy.
From string `json:"from,omitempty" description:"Source JSON Pointer for move/copy ops; ignored for other ops."`
}
// PatchOp covers the six RFC 6902 JSON Patch verbs.
type PatchOp struct{ valuer.String }
var (
PatchOpAdd = PatchOp{valuer.NewString("add")}
PatchOpRemove = PatchOp{valuer.NewString("remove")}
PatchOpReplace = PatchOp{valuer.NewString("replace")}
PatchOpMove = PatchOp{valuer.NewString("move")}
PatchOpCopy = PatchOp{valuer.NewString("copy")}
PatchOpTest = PatchOp{valuer.NewString("test")}
)
func (PatchOp) Enum() []any {
return []any{PatchOpAdd, PatchOpRemove, PatchOpReplace, PatchOpMove, PatchOpCopy, PatchOpTest}
}
func (p *PatchableDashboardV2) UnmarshalJSON(data []byte) error {
patch, err := jsonpatch.DecodePatch(data)
if err != nil {
return errors.New(errors.TypeInvalidInput, ErrCodeDashboardInvalidPatch, "request body is not a valid RFC 6902 JSON Patch document").WithAdditional(err.Error())
}
if err := json.Unmarshal(data, &p.Ops); err != nil {
return errors.New(errors.TypeInvalidInput, ErrCodeDashboardInvalidPatch, "request body is not a valid RFC 6902 JSON Patch document").WithAdditional(err.Error())
}
p.patch = patch
return nil
}
func (p PatchableDashboardV2) Apply(existing *DashboardV2) (*UpdatableDashboardV2, error) {
existingAsUpdatable := existing.toUpdatableDashboardV2()
raw, err := json.Marshal(existingAsUpdatable)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "marshal existing dashboard for patch")
}
patched, err := p.patch.ApplyWithOptions(raw, &jsonpatch.ApplyOptions{AllowMissingPathOnRemove: true, EnsurePathExistsOnAdd: true})
if err != nil {
return nil, errors.Wrap(err, errors.TypeInvalidInput, ErrCodeDashboardInvalidPatch, "JSON Patch could not be applied to the target dashboard")
}
out := &UpdatableDashboardV2{}
if err := json.Unmarshal(patched, out); err != nil {
return nil, err
}
return out, nil
}

View File

@@ -1,569 +0,0 @@
package dashboardtypes
import (
"encoding/json"
"strings"
"testing"
"github.com/SigNoz/signoz/pkg/types/tagtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// basePostableJSON is the postable shape of a small but realistic v2
// dashboard used as the base document for patch tests. Each panel carries
// one builder query in the same shape production dashboards use
// (aggregations, filter, groupBy populated), and the dashboard has one
// variable — the variable is not patched in any test here, that's
// covered in a separate variable-focused suite.
const basePostableJSON = `{
"schemaVersion": "v6",
"name": "service-overview",
"tags": [{"key": "team", "value": "alpha"}, {"key": "env", "value": "prod"}],
"spec": {
"display": {"name": "Service overview"},
"variables": [
{
"kind": "ListVariable",
"spec": {
"name": "service",
"allowAllValue": true,
"allowMultiple": false,
"plugin": {
"kind": "signoz/DynamicVariable",
"spec": {"name": "service.name", "signal": "metrics"}
}
}
}
],
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [
{
"kind": "TimeSeriesQuery",
"spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{
"metricName": "signoz_calls_total",
"temporality": "cumulative",
"timeAggregation": "rate",
"spaceAggregation": "sum"
}],
"filter": {"expression": "service.name IN $service"},
"groupBy": [{"name": "service.name", "fieldDataType": "string", "fieldContext": "tag"}]
}}}
}
]
}
},
"p2": {
"kind": "Panel",
"spec": {
"plugin": {"kind": "signoz/NumberPanel", "spec": {}},
"queries": [
{
"kind": "TimeSeriesQuery",
"spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
"name": "X",
"signal": "metrics",
"aggregations": [{
"metricName": "signoz_latency_count",
"temporality": "cumulative",
"timeAggregation": "rate",
"spaceAggregation": "sum"
}]
}}}
}
]
}
}
},
"layouts": [
{
"kind": "Grid",
"spec": {
"display": {"title": "Row 1"},
"items": [
{"x": 0, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p1"}},
{"x": 6, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p2"}}
]
}
}
],
"duration": "1h"
}
}`
func TestPatchableDashboardV2_Apply(t *testing.T) {
// Apply doesn't mutate the input *DashboardV2 — it marshals it to
// JSON, applies the patch, and unmarshals the result into a fresh
// struct. Sharing one base across subtests is safe.
var p PostableDashboardV2
require.NoError(t, json.Unmarshal([]byte(basePostableJSON), &p), "base postable JSON must validate")
testOrgID := valuer.GenerateUUID()
base := p.NewDashboardV2(testOrgID, "somecreatedthisiguess@signoz.io", SourceUser)
base.Tags = []*tagtypes.Tag{
{Key: "team", Value: "alpha"},
{Key: "env", Value: "prod"},
}
decode := func(t *testing.T, body string) PatchableDashboardV2 {
t.Helper()
var patch PatchableDashboardV2
require.NoError(t, json.Unmarshal([]byte(body), &patch))
return patch
}
// jsonOf marshals the patched dashboard back to JSON so subtests can
// assert on field values without reaching into the typed plugin specs.
jsonOf := func(t *testing.T, out *UpdatableDashboardV2) string {
t.Helper()
raw, err := json.Marshal(out)
require.NoError(t, err)
return string(raw)
}
// ─────────────────────────────────────────────────────────────────
// Successful patches
// ─────────────────────────────────────────────────────────────────
t.Run("no-op preserves all fields", func(t *testing.T) {
out, err := decode(t, `[]`).Apply(base)
require.NoError(t, err)
assert.Equal(t, base.DashboardV2MetadataBase, out.DashboardV2MetadataBase)
assert.Equal(t, tagtypes.NewPostableTagsFromTags(base.Tags), out.Tags)
assert.Equal(t, base.Spec.Display.Name, out.Spec.Display.Name)
require.Equal(t, len(base.Spec.Panels), len(out.Spec.Panels))
for k, panel := range base.Spec.Panels {
require.Contains(t, out.Spec.Panels, k)
assert.Equal(t, panel.Spec.Plugin.Kind, out.Spec.Panels[k].Spec.Plugin.Kind)
}
assert.Len(t, out.Tags, len(base.Tags))
assert.Len(t, out.Spec.Variables, len(base.Spec.Variables))
assert.Len(t, out.Spec.Layouts, len(base.Spec.Layouts))
})
t.Run("add metadata image", func(t *testing.T) {
out, err := decode(t, `[{"op": "add", "path": "/image", "value": "https://example.com/img.png"}]`).Apply(base)
require.NoError(t, err)
assert.Equal(t, "https://example.com/img.png", out.Image)
assert.Equal(t, SchemaVersion, out.SchemaVersion, "schemaVersion preserved")
})
t.Run("replace display name", func(t *testing.T) {
out, err := decode(t, `[{"op": "replace", "path": "/spec/display/name", "value": "Renamed"}]`).Apply(base)
require.NoError(t, err)
assert.Equal(t, "Renamed", out.Spec.Display.Name)
})
// Per RFC 6902 § 4.1, `add` on an existing object member replaces the
// existing value rather than erroring — same effect as `replace`.
t.Run("add overwrites existing display name", func(t *testing.T) {
out, err := decode(t, `[{"op": "add", "path": "/spec/display/name", "value": "Overwritten"}]`).Apply(base)
require.NoError(t, err)
assert.Equal(t, "Overwritten", out.Spec.Display.Name)
})
t.Run("add data refreshInterval", func(t *testing.T) {
out, err := decode(t, `[{"op": "add", "path": "/spec/refreshInterval", "value": "30s"}]`).Apply(base)
require.NoError(t, err)
assert.Equal(t, "30s", string(out.Spec.RefreshInterval))
})
t.Run("add panel leaves others untouched", func(t *testing.T) {
out, err := decode(t, `[{
"op": "add",
"path": "/spec/panels/p3",
"value": {
"kind": "Panel",
"spec": {
"plugin": {"kind": "signoz/TablePanel", "spec": {}},
"queries": [{
"kind": "TimeSeriesQuery",
"spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
"name": "A",
"signal": "logs",
"aggregations": [{"expression": "count()"}]
}}}
}]
}
}
}]`).Apply(base)
require.NoError(t, err)
assert.Len(t, out.Spec.Panels, 3)
assert.Contains(t, out.Spec.Panels, "p3")
// Plugin specs round-trip through MarshalJSON which resolves defaults
// (e.g. timePreference → "global_time"), so compare the serialized
// shape rather than the in-memory structs to skip that normalization.
for _, id := range []string{"p1", "p2"} {
wantJSON, err := json.Marshal(base.Spec.Panels[id])
require.NoError(t, err)
gotJSON, err := json.Marshal(out.Spec.Panels[id])
require.NoError(t, err)
assert.JSONEq(t, string(wantJSON), string(gotJSON), "panel %s untouched", id)
}
})
t.Run("replace single panel", func(t *testing.T) {
out, err := decode(t, `[{
"op": "replace",
"path": "/spec/panels/p2",
"value": {
"kind": "Panel",
"spec": {
"plugin": {"kind": "signoz/BarChartPanel", "spec": {}},
"queries": [{
"kind": "TimeSeriesQuery",
"spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{
"metricName": "signoz_calls_total",
"temporality": "cumulative",
"timeAggregation": "rate",
"spaceAggregation": "sum"
}]
}}}
}]
}
}
}]`).Apply(base)
require.NoError(t, err)
assert.Equal(t, PanelPluginKind("signoz/BarChartPanel"), out.Spec.Panels["p2"].Spec.Plugin.Kind)
assert.Equal(t, PanelPluginKind("signoz/TimeSeriesPanel"), out.Spec.Panels["p1"].Spec.Plugin.Kind, "p1 untouched")
})
// Removing a panel realistically also drops its layout item — exercise
// the multi-op shape the UI sends.
t.Run("remove panel and its layout item", func(t *testing.T) {
out, err := decode(t, `[
{"op": "remove", "path": "/spec/panels/p2"},
{"op": "remove", "path": "/spec/layouts/0/spec/items/1"}
]`).Apply(base)
require.NoError(t, err)
assert.Len(t, out.Spec.Panels, 1)
assert.Contains(t, out.Spec.Panels, "p1")
assert.NotContains(t, out.Spec.Panels, "p2")
raw := jsonOf(t, out)
assert.NotContains(t, raw, `"$ref":"#/spec/panels/p2"`)
assert.Contains(t, raw, `"$ref":"#/spec/panels/p1"`)
})
// The headline use case: edit a single field of a single query inside
// one panel without re-sending any other part of the dashboard.
t.Run("rename single query inside panel", func(t *testing.T) {
out, err := decode(t, `[{
"op": "replace",
"path": "/spec/panels/p1/spec/queries/0/spec/plugin/spec/name",
"value": "renamed"
}]`).Apply(base)
require.NoError(t, err)
require.Len(t, out.Spec.Panels["p1"].Spec.Queries, 1)
assert.Contains(t, jsonOf(t, out), `"name":"renamed"`)
})
// Replace a query at a specific index — swaps query "A" out for "B"
// without re-sending the rest of the panel.
t.Run("replace query at index", func(t *testing.T) {
out, err := decode(t, `[{
"op": "replace",
"path": "/spec/panels/p1/spec/queries/0",
"value": {
"kind": "TimeSeriesQuery",
"spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
"name": "B",
"signal": "metrics",
"aggregations": [{
"metricName": "signoz_db_calls_total",
"temporality": "cumulative",
"timeAggregation": "rate",
"spaceAggregation": "sum"
}]
}}}
}
}]`).Apply(base)
require.NoError(t, err)
require.Len(t, out.Spec.Panels["p1"].Spec.Queries, 1)
raw := jsonOf(t, out)
assert.Contains(t, raw, `"name":"B"`)
assert.NotContains(t, raw, `"name":"A"`)
})
// ─────────────────────────────────────────────────────────────────
// Layout edits
// ─────────────────────────────────────────────────────────────────
t.Run("move panel by editing layout x coordinate", func(t *testing.T) {
out, err := decode(t, `[{"op": "replace", "path": "/spec/layouts/0/spec/items/0/x", "value": 6}]`).Apply(base)
require.NoError(t, err)
raw := jsonOf(t, out)
// The first item used to live at x=0, now lives at x=6.
assert.Contains(t, raw, `"x":6,"y":0,"width":6,"height":6,"content":{"$ref":"#/spec/panels/p1"}`)
})
t.Run("resize panel by editing layout width", func(t *testing.T) {
out, err := decode(t, `[{"op": "replace", "path": "/spec/layouts/0/spec/items/0/width", "value": 12}]`).Apply(base)
require.NoError(t, err)
raw := jsonOf(t, out)
assert.Contains(t, raw, `"width":12`)
})
t.Run("rename layout row title", func(t *testing.T) {
out, err := decode(t, `[{"op": "replace", "path": "/spec/layouts/0/spec/display/title", "value": "Latency"}]`).Apply(base)
require.NoError(t, err)
assert.Contains(t, jsonOf(t, out), `"title":"Latency"`)
})
t.Run("append layout item", func(t *testing.T) {
out, err := decode(t, `[{
"op": "add",
"path": "/spec/layouts/0/spec/items/-",
"value": {"x": 0, "y": 6, "width": 12, "height": 6, "content": {"$ref": "#/spec/panels/p1"}}
}]`).Apply(base)
require.NoError(t, err)
// Item count went 2 → 3.
raw := jsonOf(t, out)
assert.Equal(t, 3, strings.Count(raw, `"$ref":"#/spec/panels/`))
})
// Composing add-panel + add-layout-item is the realistic shape of the
// "add a new chart to my dashboard" UI flow — exercise it end-to-end.
t.Run("add panel and corresponding layout item", func(t *testing.T) {
out, err := decode(t, `[
{
"op": "add",
"path": "/spec/panels/p3",
"value": {
"kind": "Panel",
"spec": {
"plugin": {"kind": "signoz/TablePanel", "spec": {}},
"queries": [{
"kind": "TimeSeriesQuery",
"spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
"name": "A",
"signal": "logs",
"aggregations": [{"expression": "count()"}]
}}}
}]
}
}
},
{
"op": "add",
"path": "/spec/layouts/0/spec/items/-",
"value": {"x": 0, "y": 6, "width": 12, "height": 6, "content": {"$ref": "#/spec/panels/p3"}}
}
]`).Apply(base)
require.NoError(t, err)
assert.Len(t, out.Spec.Panels, 3)
raw := jsonOf(t, out)
assert.Contains(t, raw, `"$ref":"#/spec/panels/p3"`)
})
t.Run("append tag", func(t *testing.T) {
out, err := decode(t, `[{"op": "add", "path": "/tags/-", "value": {"key": "env", "value": "staging"}}]`).Apply(base)
require.NoError(t, err)
require.Len(t, out.Tags, 3)
assert.Equal(t, "env", out.Tags[2].Key)
assert.Equal(t, "staging", out.Tags[2].Value)
})
t.Run("append tag when none exist", func(t *testing.T) {
noTagsBase := &DashboardV2{
DashboardV2MetadataBase: base.DashboardV2MetadataBase,
Name: base.Name,
Tags: nil,
Spec: base.Spec,
}
out, err := decode(t, `[{"op": "add", "path": "/tags/-", "value": {"key": "team", "value": "new"}}]`).Apply(noTagsBase)
require.NoError(t, err)
require.Len(t, out.Tags, 1)
assert.Equal(t, "team", out.Tags[0].Key)
assert.Equal(t, "new", out.Tags[0].Value)
})
t.Run("replace tag value", func(t *testing.T) {
out, err := decode(t, `[{"op": "replace", "path": "/tags/0/value", "value": "beta"}]`).Apply(base)
require.NoError(t, err)
require.Len(t, out.Tags, 2)
assert.Equal(t, "team", out.Tags[0].Key)
assert.Equal(t, "beta", out.Tags[0].Value)
assert.Equal(t, "env", out.Tags[1].Key, "tag at index 1 untouched")
assert.Equal(t, "prod", out.Tags[1].Value, "tag at index 1 untouched")
for _, tag := range out.Tags {
assert.NotEqual(t, "alpha", tag.Value, "old tag value must be gone")
}
})
t.Run("multiple ops applied in order", func(t *testing.T) {
out, err := decode(t, `[
{"op": "replace", "path": "/spec/display/name", "value": "Multi-step"},
{"op": "remove", "path": "/spec/panels/p2"},
{"op": "add", "path": "/tags/-", "value": {"key": "env", "value": "staging"}}
]`).Apply(base)
require.NoError(t, err)
assert.Equal(t, "Multi-step", out.Spec.Display.Name)
assert.Len(t, out.Spec.Panels, 1)
assert.Len(t, out.Tags, 3)
})
// `test` is an RFC 6902 precondition op: aborts the patch if the value
// at the path doesn't equal the supplied value. Used for optimistic
// concurrency. Here it matches, so the subsequent ops apply.
t.Run("test op passes", func(t *testing.T) {
out, err := decode(t, `[
{"op": "test", "path": "/spec/display/name", "value": "Service overview"},
{"op": "replace", "path": "/spec/display/name", "value": "Confirmed"}
]`).Apply(base)
require.NoError(t, err)
assert.Equal(t, "Confirmed", out.Spec.Display.Name)
})
// ─────────────────────────────────────────────────────────────────
// Failure cases
// ─────────────────────────────────────────────────────────────────
t.Run("decode rejects non-array body", func(t *testing.T) {
var patch PatchableDashboardV2
err := json.Unmarshal([]byte(`{"op": "replace"}`), &patch)
require.Error(t, err)
})
t.Run("decode rejects malformed JSON", func(t *testing.T) {
var patch PatchableDashboardV2
// Outer json.Unmarshal rejects non-JSON before PatchableDashboardV2's
// UnmarshalJSON runs, so the error is a stdlib SyntaxError rather
// than the InvalidInput-classified wrap.
err := json.Unmarshal([]byte(`not json`), &patch)
require.Error(t, err)
})
// `test` precondition fails — the whole patch is rejected, including
// the subsequent replace.
t.Run("test op failure rejected", func(t *testing.T) {
_, err := decode(t, `[
{"op": "test", "path": "/spec/display/name", "value": "Wrong"},
{"op": "replace", "path": "/spec/display/name", "value": "Should not apply"}
]`).Apply(base)
require.Error(t, err)
})
// Lenient apply (AllowMissingPathOnRemove): removing a path that doesn't
// exist is a no-op rather than an error, so removes are idempotent.
t.Run("remove at missing path is a no-op", func(t *testing.T) {
out, err := decode(t, `[{"op": "remove", "path": "/spec/panels/does-not-exist"}]`).Apply(base)
require.NoError(t, err)
assert.Equal(t, len(base.Spec.Panels), len(out.Spec.Panels), "existing panels untouched")
})
t.Run("remove schemaVersion rejected", func(t *testing.T) {
_, err := decode(t, `[{"op": "remove", "path": "/schemaVersion"}]`).Apply(base)
require.Error(t, err)
})
t.Run("wrong schemaVersion rejected", func(t *testing.T) {
_, err := decode(t, `[{"op": "replace", "path": "/schemaVersion", "value": "v5"}]`).Apply(base)
require.Error(t, err)
require.Contains(t, err.Error(), SchemaVersion)
})
t.Run("empty display name defaults to dashboard name", func(t *testing.T) {
out, err := decode(t, `[{"op": "replace", "path": "/spec/display/name", "value": ""}]`).Apply(base)
require.NoError(t, err)
assert.Equal(t, base.Name, out.Spec.Display.Name, "empty display.name should default from name")
})
t.Run("unknown top-level field rejected", func(t *testing.T) {
_, err := decode(t, `[{"op": "add", "path": "/bogus", "value": 42}]`).Apply(base)
require.Error(t, err)
require.Contains(t, err.Error(), "bogus")
})
t.Run("invalid panel kind rejected", func(t *testing.T) {
_, err := decode(t, `[{
"op": "replace",
"path": "/spec/panels/p1",
"value": {
"kind": "Panel",
"spec": {"plugin": {"kind": "signoz/NotAPanel", "spec": {}}}
}
}]`).Apply(base)
require.Error(t, err)
require.Contains(t, err.Error(), "NotAPanel")
})
t.Run("query kind incompatible with panel rejected", func(t *testing.T) {
// PromQLQuery is not allowed on ListPanel — verify the cross-check
// in Validate still runs after a patch.
_, err := decode(t, `[{
"op": "replace",
"path": "/spec/panels/p2",
"value": {
"kind": "Panel",
"spec": {
"plugin": {"kind": "signoz/ListPanel", "spec": {}},
"queries": [{"kind": "TimeSeriesQuery", "spec": {"plugin": {"kind": "signoz/PromQLQuery", "spec": {"name": "A", "query": "up"}}}}]
}
}
}]`).Apply(base)
require.Error(t, err)
})
t.Run("removing the only query rejected", func(t *testing.T) {
// Validate requires exactly one query per panel — leaving zero is rejected.
_, err := decode(t, `[{"op": "remove", "path": "/spec/panels/p2/spec/queries/0"}]`).Apply(base)
require.Error(t, err)
require.Contains(t, err.Error(), "panel must have one query")
})
t.Run("two direct queries rejected", func(t *testing.T) {
// Validate requires exactly one query per panel. To display multiple
// data sources in one panel, wrap them in a CompositeQuery (see the
// "replace query with composite" subtest below).
_, err := decode(t, `[{
"op": "replace",
"path": "/spec/panels/p1",
"value": {
"kind": "Panel",
"spec": {
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [
{"kind": "TimeSeriesQuery", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
"name": "A", "signal": "metrics",
"aggregations": [{"metricName": "signoz_calls_total", "temporality": "cumulative", "timeAggregation": "rate", "spaceAggregation": "sum"}]
}}}},
{"kind": "TimeSeriesQuery", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
"name": "B", "signal": "metrics",
"aggregations": [{"metricName": "signoz_db_calls_total", "temporality": "cumulative", "timeAggregation": "rate", "spaceAggregation": "sum"}]
}}}}
]
}
}
}]`).Apply(base)
require.Error(t, err)
require.Contains(t, err.Error(), "panel must have one query")
})
t.Run("too many tags rejected", func(t *testing.T) {
// Base already has 2 tags; add 9 more to exceed MaxTagsPerDashboard (10).
_, err := decode(t, `[
{"op": "add", "path": "/tags/-", "value": {"key": "t", "value": "1"}},
{"op": "add", "path": "/tags/-", "value": {"key": "t", "value": "2"}},
{"op": "add", "path": "/tags/-", "value": {"key": "t", "value": "3"}},
{"op": "add", "path": "/tags/-", "value": {"key": "t", "value": "4"}},
{"op": "add", "path": "/tags/-", "value": {"key": "t", "value": "5"}},
{"op": "add", "path": "/tags/-", "value": {"key": "t", "value": "6"}},
{"op": "add", "path": "/tags/-", "value": {"key": "t", "value": "7"}},
{"op": "add", "path": "/tags/-", "value": {"key": "t", "value": "8"}},
{"op": "add", "path": "/tags/-", "value": {"key": "t", "value": "9"}}
]`).Apply(base)
require.Error(t, err)
require.Contains(t, err.Error(), "at most")
})
}

View File

@@ -236,6 +236,7 @@ type RawStream struct {
Error chan error
}
func roundToNonZeroDecimals(val float64, n int) float64 {
if val == 0 || math.IsNaN(val) || math.IsInf(val, 0) {
return val

View File

@@ -0,0 +1,54 @@
package spantypes
import "encoding/json"
// The Event struct has the data exactly store in the db, while EventV2 is more of what we want to send to client.
type EventV2 struct {
Name string `json:"name"`
TimeUnixNano uint64 `json:"timeUnixNano"`
Attributes map[string]any `json:"attributes,omitempty"`
IsError bool `json:"isError,omitempty"`
}
// Link is the response shape for a span link.
// The refType field is intentionally not decoded; it's a Jaeger-era
// concept that OTel doesn't model, so we drop it on the way out.
type Link struct {
TraceID string `json:"traceId,omitempty"`
SpanID string `json:"spanId,omitempty"`
}
// ParseEvents column (Array(String) of JSON-encoded events) into a slice of Event values.
// Malformed entries are skipped.
func ParseEvents(raw any) []EventV2 {
strs, ok := raw.([]string)
if !ok {
return []EventV2{}
}
events := make([]EventV2, 0, len(strs))
for _, s := range strs {
var e Event
if err := json.Unmarshal([]byte(s), &e); err != nil {
continue
}
events = append(events, EventV2{
Name: e.Name,
TimeUnixNano: e.TimeUnixNano,
Attributes: e.AttributeMap,
IsError: false,
})
}
return events
}
func ParseLinks(raw any) []Link {
s, ok := raw.(string)
if !ok || s == "" {
return []Link{}
}
var links []Link
if err := json.Unmarshal([]byte(s), &links); err != nil {
return []Link{}
}
return links
}

View File

@@ -47,6 +47,7 @@ func (p *PostableWaterfall) Validate() error {
}
// Event represents a span event.
// todo():depricate this and use EventV2 everywhere instead while sending to client.
type Event struct {
Name string `json:"name,omitempty"`
TimeUnixNano uint64 `json:"timeUnixNano,omitempty"`

View File

@@ -16,6 +16,42 @@ from fixtures import types
from fixtures.fingerprint import LogsOrTracesFingerprint
from fixtures.time import parse_duration, parse_timestamp
# All keys returned by the trace list endpoint when selectFields is empty:
# every intrinsic and calculated column, plus the merged `attributes` and
# `resource` maps that wrap the contextual columns in the response layer.
ALL_SELECT_FIELDS = [
# all intrinsic columns
"timestamp",
"trace_id",
"span_id",
"trace_state",
"parent_span_id",
"flags",
"name",
"kind",
"kind_string",
"duration_nano",
"status_code",
"status_message",
"status_code_string",
"events",
"links",
# all calculated columns
"response_status_code",
"external_http_url",
"http_url",
"external_http_method",
"http_method",
"http_host",
"db_name",
"db_operation",
"has_error",
"is_remote",
# all contextual columns (merged in response layer)
"attributes",
"resource",
]
class TracesKind(Enum):
SPAN_KIND_UNSPECIFIED = 0
@@ -236,9 +272,10 @@ class Traces(ABC):
attributes_number: dict[str, np.float64]
attributes_bool: dict[str, bool]
resources_string: dict[str, str]
# Accepting parsed events and links, but will be stored as list[str], str in db
events: list[dict[str, Any]]
links: list[dict[str, Any]]
resource_json: dict[str, str]
events: list[str]
links: str
response_status_code: str
external_http_url: str
http_url: str
@@ -428,10 +465,17 @@ class Traces(ABC):
)
)
# Process events and derive error events
# Process events and derive error events. self.events holds the parsed
# response shape; np_arr() encodes back to the DB format on insert.
self.events = []
for event in events:
self.events.append(json.dumps([event.name, event.time_unix_nano, event.attribute_map]))
self.events.append(
{
"name": event.name,
"timeUnixNano": int(event.time_unix_nano),
"attributes": dict(event.attribute_map),
}
)
# Create error events for exception events (following Go exporter logic)
if event.name == "exception":
@@ -453,7 +497,26 @@ class Traces(ABC):
),
)
self.links = json.dumps([link.__dict__() for link in links_copy], separators=(",", ":"))
# self.links holds the parsed response shape (trace_id/span_id only;
# ref_type is dropped to match the API). np_arr() re-encodes for DB insert.
self.links = [{"traceId": link.trace_id, "spanId": link.span_id} for link in links_copy]
self._links_db = json.dumps(
[link.__dict__() for link in links_copy],
separators=(",", ":"),
)
# DB shape per event: {"name", "timeUnixNano", "attributeMap"}. Must match
# what the consume-layer parser in pkg/types/spantypes expects.
self._events_db = [
json.dumps(
{
"name": event.name,
"timeUnixNano": int(event.time_unix_nano),
"attributeMap": dict(event.attribute_map),
},
separators=(",", ":"),
)
for event in events
]
# Initialize resource
self.resource = []
@@ -568,8 +631,8 @@ class Traces(ABC):
self.attributes_number,
self.attributes_bool,
self.resources_string,
self.events,
self.links,
self._events_db,
self._links_db,
self.response_status_code,
self.external_http_url,
self.http_url,

View File

@@ -17,7 +17,16 @@ from fixtures.querier import (
index_series_by_label,
make_query_request,
)
from fixtures.traces import TraceIdGenerator, Traces, TracesKind, TracesStatusCode
from fixtures.traces import (
ALL_SELECT_FIELDS,
TraceIdGenerator,
Traces,
TracesEvent,
TracesKind,
TracesLink,
TracesRefType,
TracesStatusCode,
)
def test_traces_list(
@@ -473,7 +482,9 @@ def test_traces_list(
@pytest.mark.parametrize(
"payload,status_code,results",
[
# Case 1: order by timestamp field which there in attributes as well
# Case 1: order by timestamp; empty selectFields returns the full
# response shape (all intrinsic + calculated columns plus the merged
# `attributes` and `resource` maps). x[3] (topic-service) is latest.
pytest.param(
{
"type": "builder_query",
@@ -487,19 +498,42 @@ def test_traces_list(
},
HTTPStatus.OK,
lambda x: [
x[3].duration_nano,
{
**x[3].attribute_string,
**x[3].attributes_number,
**x[3].attributes_bool,
}, # attributes
x[3].db_name,
x[3].db_operation,
int(x[3].duration_nano),
x[3].events,
x[3].external_http_method,
x[3].external_http_url,
int(x[3].flags),
x[3].has_error,
x[3].http_host,
x[3].http_method,
x[3].http_url,
x[3].is_remote,
int(x[3].kind),
x[3].kind_string,
x[3].links,
x[3].name,
x[3].parent_span_id,
x[3].resources_string,
x[3].response_status_code,
x[3].service_name,
x[3].span_id,
int(x[3].status_code),
x[3].status_code_string,
x[3].status_message,
format_timestamp(x[3].timestamp),
x[3].trace_id,
x[3].trace_state,
], # type: Callable[[List[Traces]], List[Any]]
),
# Case 2: order by attribute timestamp field which is there in attributes as well
# This should break but it doesn't because attribute.timestamp gets adjusted to timestamp
# because of default trace.timestamp gets added by default and bug in field mapper picks
# instrinsic field
# Case 2: order by attribute.timestamp. The key resolves to the
# intrinsic span.timestamp column, so the latest span (x[3]) is
# returned with the same full response shape as Case 1.
pytest.param(
{
"type": "builder_query",
@@ -513,13 +547,37 @@ def test_traces_list(
},
HTTPStatus.OK,
lambda x: [
x[3].duration_nano,
{
**x[3].attribute_string,
**x[3].attributes_number,
**x[3].attributes_bool,
}, # attributes
x[3].db_name,
x[3].db_operation,
int(x[3].duration_nano),
x[3].events,
x[3].external_http_method,
x[3].external_http_url,
int(x[3].flags),
x[3].has_error,
x[3].http_host,
x[3].http_method,
x[3].http_url,
x[3].is_remote,
int(x[3].kind),
x[3].kind_string,
x[3].links,
x[3].name,
x[3].parent_span_id,
x[3].resources_string,
x[3].response_status_code,
x[3].service_name,
x[3].span_id,
int(x[3].status_code),
x[3].status_code_string,
x[3].status_message,
format_timestamp(x[3].timestamp),
x[3].trace_id,
x[3].trace_state,
], # type: Callable[[List[Traces]], List[Any]]
),
# Case 3: select timestamp with empty order by
@@ -542,7 +600,7 @@ def test_traces_list(
], # type: Callable[[List[Traces]], List[Any]]
),
# Case 4: select attribute.timestamp with empty order by
# This doesn't return any data because of where_clause using aliased timestamp
# This returns the one span which has attribute.timestamp
pytest.param(
{
"type": "builder_query",
@@ -556,7 +614,11 @@ def test_traces_list(
},
},
HTTPStatus.OK,
lambda x: [], # type: Callable[[List[Traces]], List[Any]]
lambda x: [
x[0].span_id,
format_timestamp(x[0].timestamp),
x[0].trace_id,
], # type: Callable[[List[Traces]], List[Any]]
),
# Case 5: select timestamp with timestamp order by
pytest.param(
@@ -693,6 +755,159 @@ def test_traces_list_with_corrupt_data(
assert data[key] == value
def _verify_events_links_full(rows: list[dict], traces: list[Traces]) -> None:
"""Empty-selectFields case: events/links arrive parsed into structured objects.
Every row's events/links should match the fixture's stored parsed shape
(the fixture's `.events`/`.links` mirror the API response shape directly).
"""
for row, trace in zip(rows, traces, strict=True):
assert row["data"]["events"] == trace.events
assert row["data"]["links"] == trace.links
# Jaeger-era `refType` is dropped at the consume layer.
for link in row["data"]["links"]:
assert "refType" not in link
def _verify_events_links_skip(rows: list[dict], traces: list[Traces]) -> None:
"""Projected-selectFields case: nothing to verify beyond the key set."""
@pytest.mark.parametrize(
"select_fields,status_code,expected_keys,verify_values",
[
pytest.param(
[],
HTTPStatus.OK,
ALL_SELECT_FIELDS,
_verify_events_links_full,
),
pytest.param(
[
{"name": "service.name"},
],
HTTPStatus.OK,
["timestamp", "trace_id", "span_id", "service.name"],
_verify_events_links_skip,
),
],
)
def test_traces_list_with_select_fields(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
select_fields: list[dict],
status_code: HTTPStatus,
expected_keys: list[str],
verify_values: Callable[[list[dict], list[Traces]], None],
) -> None:
"""
Setup:
Insert a root span with no events/links and a child span carrying two
events and one user-supplied link.
Tests:
1. Empty select fields should return all the fields, and the `events` /
`links` columns should arrive parsed into structured objects (events
carry `attributes`, links carry only `traceId`/`spanId` — refType is
dropped at the consume layer).
2. Non-empty select field should return the select field along with
timestamp, trace_id and span_id.
"""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
parent_trace_id = TraceIdGenerator.trace_id()
parent_span_id = TraceIdGenerator.span_id()
child_span_id = TraceIdGenerator.span_id()
linked_trace_id = TraceIdGenerator.trace_id()
linked_span_id = TraceIdGenerator.span_id()
event_one = TracesEvent(
name="request_received",
timestamp=now - timedelta(seconds=3, microseconds=500_000),
attribute_map={"http.method": "GET", "http.route": "/api/chat"},
)
event_two = TracesEvent(
name="cache_lookup",
timestamp=now - timedelta(seconds=3, microseconds=400_000),
attribute_map={"cache.hit": "true", "cache.key": "user:123:prompt"},
)
user_link = TracesLink(
trace_id=linked_trace_id,
span_id=linked_span_id,
ref_type=TracesRefType.REF_TYPE_FOLLOWS_FROM,
)
traces = [
# Root span: no events, no links. Verifies the empty-case parsed shape.
Traces(
timestamp=now - timedelta(seconds=4),
duration=timedelta(seconds=3),
trace_id=parent_trace_id,
span_id=parent_span_id,
parent_span_id="",
name="root span",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
resources={"service.name": "events-links-service"},
attributes={"http.request.method": "GET"},
),
# Child span: two events + one user-supplied link. The fixture
# auto-inserts a CHILD_OF link for the parent, so the parsed response
# contains two links total — the auto-inserted one first.
Traces(
timestamp=now - timedelta(seconds=3),
duration=timedelta(seconds=1),
trace_id=parent_trace_id,
span_id=child_span_id,
parent_span_id=parent_span_id,
name="child span",
kind=TracesKind.SPAN_KIND_INTERNAL,
status_code=TracesStatusCode.STATUS_CODE_OK,
resources={"service.name": "events-links-service"},
attributes={"http.request.method": "GET"},
events=[event_one, event_two],
links=[user_link],
),
]
insert_traces(traces)
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
payload = {
"type": "builder_query",
"spec": {
"name": "A",
"signal": "traces",
"filter": {"expression": "resource.service.name = 'events-links-service'"},
"selectFields": select_fields,
"order": [{"key": {"name": "timestamp"}, "direction": "asc"}],
"limit": 10,
},
}
response = make_query_request(
signoz,
token,
start_ms=int((datetime.now(tz=UTC) - timedelta(minutes=5)).timestamp() * 1000),
end_ms=int(datetime.now(tz=UTC).timestamp() * 1000),
request_type="raw",
queries=[payload],
)
assert response.status_code == status_code
if response.status_code != HTTPStatus.OK:
return
rows = response.json()["data"]["data"]["results"][0]["rows"]
assert len(rows) == 2
for row in rows:
assert set(row["data"].keys()) == set(expected_keys)
verify_values(rows, traces)
@pytest.mark.parametrize(
"order_by,aggregation_alias,expected_status",
[

View File

@@ -36,12 +36,22 @@ from fixtures.querier import (
assert_grouped_scalar,
assert_raw_row_subset,
assert_scalar_value,
find_named_result,
format_timestamp,
generate_traces_with_corrupt_metadata,
get_rows,
make_query_request,
)
from fixtures.traces import TraceIdGenerator, Traces, TracesKind, TracesStatusCode
from fixtures.traces import (
ALL_SELECT_FIELDS,
TraceIdGenerator,
Traces,
TracesEvent,
TracesKind,
TracesLink,
TracesRefType,
TracesStatusCode,
)
def _names(response: requests.Response) -> set:
@@ -613,3 +623,178 @@ def test_trace_operator_with_adjusted_keys(
assert response.status_code == HTTPStatus.OK, response.text
assert_result(response, traces)
# Hardcoded core columns the trace_operator buildListQuery always projects,
# in addition to any user-supplied selectFields.
TRACE_OPERATOR_CORE_FIELDS = [
"timestamp",
"trace_id",
"span_id",
"name",
"duration_nano",
"parent_span_id",
]
def _verify_full_expansion(rows: list[dict], parent_trace: Traces) -> None:
"""Empty-selectFields case: every column from the builder_query parity set
arrives, and events/links are parsed into structured form (refType is
dropped at the consume layer).
"""
assert len(rows) == 1
parent_row = rows[0]["data"]
assert set(parent_row.keys()) == set(ALL_SELECT_FIELDS)
assert parent_row["events"] == parent_trace.events
assert parent_row["links"] == parent_trace.links
for link in parent_row["links"]:
assert "refType" not in link
def _verify_explicit_projection(rows: list[dict], parent_trace: Traces) -> None: # pylint: disable=unused-argument
"""Explicit-selectFields case: only the 6 hardcoded core fields plus the
user-supplied resource.service.name come back. Contextual columns
(events/links/attributes/resource) and the rest of the intrinsics never
appear because the consume-layer merge isn't triggered.
"""
assert len(rows) == 1
parent_row = rows[0]["data"]
assert set(parent_row.keys()) == set(TRACE_OPERATOR_CORE_FIELDS + ["service.name"])
@pytest.mark.parametrize(
"select_fields,verify_values",
[
pytest.param([], _verify_full_expansion, id="empty-select-fields"),
pytest.param(
[{"name": "service.name", "fieldContext": "resource"}],
_verify_explicit_projection,
id="explicit-service-name",
),
],
)
def test_trace_operator_select_fields(
signoz: types.SigNoz,
create_user_admin: None, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
insert_traces: Callable[[list[Traces]], None],
select_fields: list[dict[str, Any]],
verify_values: Callable[[list[dict], Traces], None],
) -> None:
"""
Setup:
Insert a parent (operation.type = 'parent') with one event and one
user-supplied link, plus a child span (operation.type = 'child').
Tests:
1. With selectFields=[], the `A => B` trace_operator returns every column
in ALL_SELECT_FIELDS, mirroring the builder_query path. Events arrive
as {name, timeUnixNano, attributes} and links as {traceId, spanId}
with refType dropped at the consume layer.
2. With an explicit selectFields=[{"name": "service.name"}], only the 6
hardcoded core columns plus service.name come back — no auto-expansion
to the full set.
See:
- pkg/telemetrytraces/trace_operator_cte_builder.go::buildFinalQuery for
the expansion gate.
- pkg/telemetrytraces/trace_operator_cte_builder.go::buildListQuery for
the per-row SELECT.
"""
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
trace_id = TraceIdGenerator.trace_id()
parent_span_id = TraceIdGenerator.span_id()
child_span_id = TraceIdGenerator.span_id()
parent_event = TracesEvent(
name="request_received",
timestamp=now - timedelta(seconds=4, microseconds=500_000),
attribute_map={"http.method": "GET"},
)
linked_trace_id = TraceIdGenerator.trace_id()
linked_span_id = TraceIdGenerator.span_id()
user_link = TracesLink(
trace_id=linked_trace_id,
span_id=linked_span_id,
ref_type=TracesRefType.REF_TYPE_FOLLOWS_FROM,
)
parent_trace = Traces(
timestamp=now - timedelta(seconds=5),
duration=timedelta(seconds=4),
trace_id=trace_id,
span_id=parent_span_id,
parent_span_id="",
name="parent-operation",
kind=TracesKind.SPAN_KIND_SERVER,
status_code=TracesStatusCode.STATUS_CODE_OK,
resources={"service.name": "trace-operator-query"},
attributes={"operation.type": "parent"},
events=[parent_event],
links=[user_link],
)
child_trace = Traces(
timestamp=now - timedelta(seconds=4),
duration=timedelta(seconds=1),
trace_id=trace_id,
span_id=child_span_id,
parent_span_id=parent_span_id,
name="child-operation",
kind=TracesKind.SPAN_KIND_INTERNAL,
status_code=TracesStatusCode.STATUS_CODE_OK,
resources={"service.name": "trace-operator-query"},
attributes={"operation.type": "child"},
)
insert_traces([parent_trace, child_trace])
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
operator_spec: dict[str, Any] = {
"name": "C",
"expression": "A => B",
"limit": 10,
"order": [{"key": {"name": "timestamp"}, "direction": "asc"}],
}
if select_fields:
operator_spec["selectFields"] = select_fields
queries = [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "traces",
"filter": {"expression": "operation.type = 'parent'"},
"limit": 100,
"disabled": True,
},
},
{
"type": "builder_query",
"spec": {
"name": "B",
"signal": "traces",
"filter": {"expression": "operation.type = 'child'"},
"limit": 100,
"disabled": True,
},
},
{"type": "builder_trace_operator", "spec": operator_spec},
]
response = make_query_request(
signoz,
token,
start_ms=int((datetime.now(tz=UTC) - timedelta(minutes=5)).timestamp() * 1000),
end_ms=int(datetime.now(tz=UTC).timestamp() * 1000),
request_type="raw",
queries=queries,
)
assert response.status_code == HTTPStatus.OK
results = response.json()["data"]["data"]["results"]
trace_operator_result = find_named_result(results, "C")
rows = trace_operator_result["rows"]
verify_values(rows, parent_trace)