Compare commits

..

1 Commits

Author SHA1 Message Date
Naman Verma
bbaf64c4f0 feat: openapi spec generation 2026-04-21 13:41:06 +05:30
42 changed files with 1862 additions and 2824 deletions

View File

@@ -52,7 +52,6 @@ jobs:
- ingestionkeys
- rootuser
- serviceaccount
- querier_json_body
sqlstore-provider:
- postgres
- sqlite
@@ -62,7 +61,7 @@ jobs:
- 25.5.6
- 25.12.5
schema-migrator-version:
- v0.144.3
- v0.142.0
postgres-version:
- 15
if: |

View File

@@ -1035,6 +1035,18 @@ components:
required:
- config
type: object
CommonDisplay:
properties:
description:
type: string
name:
type: string
type: object
CommonJSONRef:
properties:
$ref:
type: string
type: object
ConfigAuthorization:
properties:
credentials:
@@ -1986,6 +1998,43 @@ components:
to_user:
type: string
type: object
DashboardGridItem:
properties:
content:
$ref: '#/components/schemas/CommonJSONRef'
height:
type: integer
width:
type: integer
x:
type: integer
"y":
type: integer
type: object
DashboardGridLayoutCollapse:
properties:
open:
type: boolean
type: object
DashboardGridLayoutDisplay:
properties:
collapse:
$ref: '#/components/schemas/DashboardGridLayoutCollapse'
title:
type: string
type: object
DashboardGridLayoutSpec:
properties:
display:
$ref: '#/components/schemas/DashboardGridLayoutDisplay'
items:
items:
$ref: '#/components/schemas/DashboardGridItem'
nullable: true
type: array
repeatVariable:
type: string
type: object
DashboardtypesDashboard:
properties:
createdAt:
@@ -2040,6 +2089,749 @@ components:
timeRangeEnabled:
type: boolean
type: object
Dashboardtypesv2Axes:
properties:
isLogScale:
type: boolean
softMax:
nullable: true
type: number
softMin:
nullable: true
type: number
type: object
Dashboardtypesv2BarChartPanelSpec:
properties:
axes:
$ref: '#/components/schemas/Dashboardtypesv2Axes'
formatting:
$ref: '#/components/schemas/Dashboardtypesv2PanelFormatting'
legend:
$ref: '#/components/schemas/Dashboardtypesv2Legend'
thresholds:
items:
$ref: '#/components/schemas/Dashboardtypesv2ThresholdWithLabel'
nullable: true
type: array
visualization:
$ref: '#/components/schemas/Dashboardtypesv2BarChartVisualization'
type: object
Dashboardtypesv2BarChartVisualization:
properties:
fillSpans:
type: boolean
stackedBarChart:
type: boolean
timePreference:
$ref: '#/components/schemas/Dashboardtypesv2TimePreference'
type: object
Dashboardtypesv2BasicVisualization:
properties:
timePreference:
$ref: '#/components/schemas/Dashboardtypesv2TimePreference'
type: object
Dashboardtypesv2BuilderQuerySpec:
oneOf:
- $ref: '#/components/schemas/Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5LogAggregation'
- $ref: '#/components/schemas/Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5MetricAggregation'
- $ref: '#/components/schemas/Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5TraceAggregation'
Dashboardtypesv2ComparisonOperator:
enum:
- '>'
- <
- '>='
- <=
- =
- above
- below
- above_or_equal
- below_or_equal
- equal
- not_equal
type: string
Dashboardtypesv2ComparisonThreshold:
properties:
color:
type: string
format:
$ref: '#/components/schemas/Dashboardtypesv2ThresholdFormat'
operator:
$ref: '#/components/schemas/Dashboardtypesv2ComparisonOperator'
unit:
type: string
value:
format: double
type: number
required:
- value
- color
type: object
Dashboardtypesv2CustomVariableSpec:
properties:
customValue:
type: string
required:
- customValue
type: object
Dashboardtypesv2DashboardData:
properties:
datasources:
additionalProperties:
$ref: '#/components/schemas/Dashboardtypesv2DatasourceSpec'
type: object
display:
$ref: '#/components/schemas/CommonDisplay'
duration:
type: string
layouts:
items:
$ref: '#/components/schemas/Dashboardtypesv2Layout'
nullable: true
type: array
links:
items:
$ref: '#/components/schemas/V1Link'
type: array
panels:
additionalProperties:
$ref: '#/components/schemas/Dashboardtypesv2Panel'
nullable: true
type: object
refreshInterval:
type: string
variables:
items:
$ref: '#/components/schemas/Dashboardtypesv2Variable'
type: array
type: object
Dashboardtypesv2DatasourcePlugin:
oneOf:
- $ref: '#/components/schemas/Dashboardtypesv2DatasourcePluginVariantStruct'
Dashboardtypesv2DatasourcePluginKind:
enum:
- signoz/Datasource
type: string
Dashboardtypesv2DatasourcePluginVariantStruct:
properties:
kind:
enum:
- signoz/Datasource
type: string
spec:
nullable: true
type: object
required:
- kind
- spec
type: object
Dashboardtypesv2DatasourceSpec:
properties:
default:
type: boolean
display:
$ref: '#/components/schemas/CommonDisplay'
plugin:
$ref: '#/components/schemas/Dashboardtypesv2DatasourcePlugin'
type: object
Dashboardtypesv2DynamicVariableSpec:
properties:
name:
type: string
signal:
$ref: '#/components/schemas/TelemetrytypesSignal'
required:
- name
type: object
Dashboardtypesv2FillMode:
enum:
- solid
- gradient
- none
type: string
Dashboardtypesv2HistogramBuckets:
properties:
bucketCount:
nullable: true
type: number
bucketWidth:
nullable: true
type: number
mergeAllActiveQueries:
type: boolean
type: object
Dashboardtypesv2HistogramPanelSpec:
properties:
histogramBuckets:
$ref: '#/components/schemas/Dashboardtypesv2HistogramBuckets'
legend:
$ref: '#/components/schemas/Dashboardtypesv2Legend'
type: object
Dashboardtypesv2Layout:
oneOf:
- $ref: '#/components/schemas/Dashboardtypesv2LayoutEnvelopeGithubComPersesPersesPkgModelApiV1DashboardGridLayoutSpec'
Dashboardtypesv2LayoutEnvelopeGithubComPersesPersesPkgModelApiV1DashboardGridLayoutSpec:
properties:
kind:
enum:
- Grid
type: string
spec:
$ref: '#/components/schemas/DashboardGridLayoutSpec'
required:
- kind
- spec
type: object
Dashboardtypesv2Legend:
properties:
customColors:
additionalProperties:
type: string
nullable: true
type: object
position:
$ref: '#/components/schemas/Dashboardtypesv2LegendPosition'
type: object
Dashboardtypesv2LegendPosition:
enum:
- bottom
- right
type: string
Dashboardtypesv2LineInterpolation:
enum:
- linear
- spline
- step_after
- step_before
type: string
Dashboardtypesv2LineStyle:
enum:
- solid
- dashed
type: string
Dashboardtypesv2ListPanelSpec:
properties:
selectFields:
items:
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
type: array
type: object
Dashboardtypesv2ListVariableSpec:
properties:
allowAllValue:
type: boolean
allowMultiple:
type: boolean
capturingRegexp:
type: string
customAllValue:
type: string
defaultValue:
$ref: '#/components/schemas/VariableDefaultValue'
display:
$ref: '#/components/schemas/VariableDisplay'
name:
type: string
plugin:
$ref: '#/components/schemas/Dashboardtypesv2VariablePlugin'
sort:
nullable: true
type: string
type: object
Dashboardtypesv2NumberPanelSpec:
properties:
formatting:
$ref: '#/components/schemas/Dashboardtypesv2PanelFormatting'
thresholds:
items:
$ref: '#/components/schemas/Dashboardtypesv2ComparisonThreshold'
nullable: true
type: array
visualization:
$ref: '#/components/schemas/Dashboardtypesv2BasicVisualization'
type: object
Dashboardtypesv2Panel:
properties:
kind:
type: string
spec:
$ref: '#/components/schemas/Dashboardtypesv2PanelSpec'
type: object
Dashboardtypesv2PanelFormatting:
properties:
decimalPrecision:
$ref: '#/components/schemas/Dashboardtypesv2PrecisionOption'
unit:
type: string
type: object
Dashboardtypesv2PanelPlugin:
oneOf:
- $ref: '#/components/schemas/Dashboardtypesv2PanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDashboardtypesv2TimeSeriesPanelSpec'
- $ref: '#/components/schemas/Dashboardtypesv2PanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDashboardtypesv2BarChartPanelSpec'
- $ref: '#/components/schemas/Dashboardtypesv2PanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDashboardtypesv2NumberPanelSpec'
- $ref: '#/components/schemas/Dashboardtypesv2PanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDashboardtypesv2PieChartPanelSpec'
- $ref: '#/components/schemas/Dashboardtypesv2PanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDashboardtypesv2TablePanelSpec'
- $ref: '#/components/schemas/Dashboardtypesv2PanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDashboardtypesv2HistogramPanelSpec'
- $ref: '#/components/schemas/Dashboardtypesv2PanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDashboardtypesv2ListPanelSpec'
Dashboardtypesv2PanelPluginKind:
enum:
- signoz/TimeSeriesPanel
- signoz/BarChartPanel
- signoz/NumberPanel
- signoz/PieChartPanel
- signoz/TablePanel
- signoz/HistogramPanel
- signoz/ListPanel
type: string
Dashboardtypesv2PanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDashboardtypesv2BarChartPanelSpec:
properties:
kind:
enum:
- signoz/BarChartPanel
type: string
spec:
$ref: '#/components/schemas/Dashboardtypesv2BarChartPanelSpec'
required:
- kind
- spec
type: object
Dashboardtypesv2PanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDashboardtypesv2HistogramPanelSpec:
properties:
kind:
enum:
- signoz/HistogramPanel
type: string
spec:
$ref: '#/components/schemas/Dashboardtypesv2HistogramPanelSpec'
required:
- kind
- spec
type: object
Dashboardtypesv2PanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDashboardtypesv2ListPanelSpec:
properties:
kind:
enum:
- signoz/ListPanel
type: string
spec:
$ref: '#/components/schemas/Dashboardtypesv2ListPanelSpec'
required:
- kind
- spec
type: object
Dashboardtypesv2PanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDashboardtypesv2NumberPanelSpec:
properties:
kind:
enum:
- signoz/NumberPanel
type: string
spec:
$ref: '#/components/schemas/Dashboardtypesv2NumberPanelSpec'
required:
- kind
- spec
type: object
Dashboardtypesv2PanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDashboardtypesv2PieChartPanelSpec:
properties:
kind:
enum:
- signoz/PieChartPanel
type: string
spec:
$ref: '#/components/schemas/Dashboardtypesv2PieChartPanelSpec'
required:
- kind
- spec
type: object
Dashboardtypesv2PanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDashboardtypesv2TablePanelSpec:
properties:
kind:
enum:
- signoz/TablePanel
type: string
spec:
$ref: '#/components/schemas/Dashboardtypesv2TablePanelSpec'
required:
- kind
- spec
type: object
Dashboardtypesv2PanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDashboardtypesv2TimeSeriesPanelSpec:
properties:
kind:
enum:
- signoz/TimeSeriesPanel
type: string
spec:
$ref: '#/components/schemas/Dashboardtypesv2TimeSeriesPanelSpec'
required:
- kind
- spec
type: object
Dashboardtypesv2PanelSpec:
properties:
display:
$ref: '#/components/schemas/V1PanelDisplay'
links:
items:
$ref: '#/components/schemas/V1Link'
type: array
plugin:
$ref: '#/components/schemas/Dashboardtypesv2PanelPlugin'
queries:
items:
$ref: '#/components/schemas/Dashboardtypesv2Query'
type: array
type: object
Dashboardtypesv2PieChartPanelSpec:
properties:
formatting:
$ref: '#/components/schemas/Dashboardtypesv2PanelFormatting'
legend:
$ref: '#/components/schemas/Dashboardtypesv2Legend'
visualization:
$ref: '#/components/schemas/Dashboardtypesv2BasicVisualization'
type: object
Dashboardtypesv2PrecisionOption:
enum:
- "0"
- "1"
- "2"
- "3"
- "4"
- full
type: string
Dashboardtypesv2Query:
properties:
kind:
type: string
spec:
$ref: '#/components/schemas/Dashboardtypesv2QuerySpec'
type: object
Dashboardtypesv2QueryPlugin:
oneOf:
- $ref: '#/components/schemas/Dashboardtypesv2QueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDashboardtypesv2BuilderQuerySpec'
- $ref: '#/components/schemas/Dashboardtypesv2QueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5CompositeQuery'
- $ref: '#/components/schemas/Dashboardtypesv2QueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5QueryBuilderFormula'
- $ref: '#/components/schemas/Dashboardtypesv2QueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5PromQuery'
- $ref: '#/components/schemas/Dashboardtypesv2QueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5ClickHouseQuery'
- $ref: '#/components/schemas/Dashboardtypesv2QueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5QueryBuilderTraceOperator'
Dashboardtypesv2QueryPluginKind:
enum:
- signoz/BuilderQuery
- signoz/CompositeQuery
- signoz/Formula
- signoz/PromQLQuery
- signoz/ClickHouseSQL
- signoz/TraceOperator
type: string
Dashboardtypesv2QueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDashboardtypesv2BuilderQuerySpec:
properties:
kind:
enum:
- signoz/BuilderQuery
type: string
spec:
$ref: '#/components/schemas/Dashboardtypesv2BuilderQuerySpec'
required:
- kind
- spec
type: object
Dashboardtypesv2QueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5ClickHouseQuery:
properties:
kind:
enum:
- signoz/ClickHouseSQL
type: string
spec:
$ref: '#/components/schemas/Querybuildertypesv5ClickHouseQuery'
required:
- kind
- spec
type: object
Dashboardtypesv2QueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5CompositeQuery:
properties:
kind:
enum:
- signoz/CompositeQuery
type: string
spec:
$ref: '#/components/schemas/Querybuildertypesv5CompositeQuery'
required:
- kind
- spec
type: object
Dashboardtypesv2QueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5PromQuery:
properties:
kind:
enum:
- signoz/PromQLQuery
type: string
spec:
$ref: '#/components/schemas/Querybuildertypesv5PromQuery'
required:
- kind
- spec
type: object
Dashboardtypesv2QueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5QueryBuilderFormula:
properties:
kind:
enum:
- signoz/Formula
type: string
spec:
$ref: '#/components/schemas/Querybuildertypesv5QueryBuilderFormula'
required:
- kind
- spec
type: object
Dashboardtypesv2QueryPluginVariantGithubComSigNozSignozPkgTypesQuerybuildertypesQuerybuildertypesv5QueryBuilderTraceOperator:
properties:
kind:
enum:
- signoz/TraceOperator
type: string
spec:
$ref: '#/components/schemas/Querybuildertypesv5QueryBuilderTraceOperator'
required:
- kind
- spec
type: object
Dashboardtypesv2QuerySpec:
properties:
name:
type: string
plugin:
$ref: '#/components/schemas/Dashboardtypesv2QueryPlugin'
type: object
Dashboardtypesv2QueryVariableSpec:
properties:
queryValue:
type: string
required:
- queryValue
type: object
Dashboardtypesv2SpanGaps:
properties:
fillLessThan:
type: string
fillOnlyBelow:
type: boolean
type: object
Dashboardtypesv2TableFormatting:
properties:
columnUnits:
additionalProperties:
type: string
nullable: true
type: object
decimalPrecision:
$ref: '#/components/schemas/Dashboardtypesv2PrecisionOption'
type: object
Dashboardtypesv2TablePanelSpec:
properties:
formatting:
$ref: '#/components/schemas/Dashboardtypesv2TableFormatting'
thresholds:
items:
$ref: '#/components/schemas/Dashboardtypesv2TableThreshold'
nullable: true
type: array
visualization:
$ref: '#/components/schemas/Dashboardtypesv2BasicVisualization'
type: object
Dashboardtypesv2TableThreshold:
properties:
color:
type: string
columnName:
type: string
format:
$ref: '#/components/schemas/Dashboardtypesv2ThresholdFormat'
operator:
$ref: '#/components/schemas/Dashboardtypesv2ComparisonOperator'
unit:
type: string
value:
format: double
type: number
required:
- value
- color
- columnName
type: object
Dashboardtypesv2TextVariableSpec:
properties:
constant:
type: boolean
display:
$ref: '#/components/schemas/VariableDisplay'
name:
type: string
value:
type: string
type: object
Dashboardtypesv2TextboxVariableSpec:
type: object
Dashboardtypesv2ThresholdFormat:
enum:
- text
- background
type: string
Dashboardtypesv2ThresholdWithLabel:
properties:
color:
type: string
label:
type: string
unit:
type: string
value:
format: double
type: number
required:
- value
- color
- label
type: object
Dashboardtypesv2TimePreference:
enum:
- global_time
- last_5_min
- last_15_min
- last_30_min
- last_1_hr
- last_6_hr
- last_1_day
- last_3_days
- last_1_week
- last_1_month
type: string
Dashboardtypesv2TimeSeriesChartAppearance:
properties:
fillMode:
$ref: '#/components/schemas/Dashboardtypesv2FillMode'
lineInterpolation:
$ref: '#/components/schemas/Dashboardtypesv2LineInterpolation'
lineStyle:
$ref: '#/components/schemas/Dashboardtypesv2LineStyle'
showPoints:
type: boolean
spanGaps:
$ref: '#/components/schemas/Dashboardtypesv2SpanGaps'
type: object
Dashboardtypesv2TimeSeriesPanelSpec:
properties:
axes:
$ref: '#/components/schemas/Dashboardtypesv2Axes'
chartAppearance:
$ref: '#/components/schemas/Dashboardtypesv2TimeSeriesChartAppearance'
formatting:
$ref: '#/components/schemas/Dashboardtypesv2PanelFormatting'
legend:
$ref: '#/components/schemas/Dashboardtypesv2Legend'
thresholds:
items:
$ref: '#/components/schemas/Dashboardtypesv2ThresholdWithLabel'
nullable: true
type: array
visualization:
$ref: '#/components/schemas/Dashboardtypesv2TimeSeriesVisualization'
type: object
Dashboardtypesv2TimeSeriesVisualization:
properties:
fillSpans:
type: boolean
timePreference:
$ref: '#/components/schemas/Dashboardtypesv2TimePreference'
type: object
Dashboardtypesv2Variable:
oneOf:
- $ref: '#/components/schemas/Dashboardtypesv2VariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesDashboardtypesv2ListVariableSpec'
- $ref: '#/components/schemas/Dashboardtypesv2VariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesDashboardtypesv2TextVariableSpec'
Dashboardtypesv2VariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesDashboardtypesv2ListVariableSpec:
properties:
kind:
enum:
- ListVariable
type: string
spec:
$ref: '#/components/schemas/Dashboardtypesv2ListVariableSpec'
required:
- kind
- spec
type: object
Dashboardtypesv2VariableEnvelopeGithubComSigNozSignozPkgTypesDashboardtypesDashboardtypesv2TextVariableSpec:
properties:
kind:
enum:
- TextVariable
type: string
spec:
$ref: '#/components/schemas/Dashboardtypesv2TextVariableSpec'
required:
- kind
- spec
type: object
Dashboardtypesv2VariablePlugin:
oneOf:
- $ref: '#/components/schemas/Dashboardtypesv2VariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDashboardtypesv2DynamicVariableSpec'
- $ref: '#/components/schemas/Dashboardtypesv2VariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDashboardtypesv2QueryVariableSpec'
- $ref: '#/components/schemas/Dashboardtypesv2VariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDashboardtypesv2CustomVariableSpec'
- $ref: '#/components/schemas/Dashboardtypesv2VariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDashboardtypesv2TextboxVariableSpec'
Dashboardtypesv2VariablePluginKind:
enum:
- signoz/DynamicVariable
- signoz/QueryVariable
- signoz/CustomVariable
- signoz/TextboxVariable
type: string
Dashboardtypesv2VariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDashboardtypesv2CustomVariableSpec:
properties:
kind:
enum:
- signoz/CustomVariable
type: string
spec:
$ref: '#/components/schemas/Dashboardtypesv2CustomVariableSpec'
required:
- kind
- spec
type: object
Dashboardtypesv2VariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDashboardtypesv2DynamicVariableSpec:
properties:
kind:
enum:
- signoz/DynamicVariable
type: string
spec:
$ref: '#/components/schemas/Dashboardtypesv2DynamicVariableSpec'
required:
- kind
- spec
type: object
Dashboardtypesv2VariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDashboardtypesv2QueryVariableSpec:
properties:
kind:
enum:
- signoz/QueryVariable
type: string
spec:
$ref: '#/components/schemas/Dashboardtypesv2QueryVariableSpec'
required:
- kind
- spec
type: object
Dashboardtypesv2VariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDashboardtypesv2TextboxVariableSpec:
properties:
kind:
enum:
- signoz/TextboxVariable
type: string
spec:
$ref: '#/components/schemas/Dashboardtypesv2TextboxVariableSpec'
required:
- kind
- spec
type: object
ErrorsJSON:
properties:
code:
@@ -4551,6 +5343,37 @@ components:
required:
- id
type: object
V1Link:
properties:
name:
type: string
renderVariables:
type: boolean
targetBlank:
type: boolean
tooltip:
type: string
url:
type: string
type: object
V1PanelDisplay:
properties:
description:
type: string
name:
type: string
type: object
VariableDefaultValue:
type: object
VariableDisplay:
properties:
description:
type: string
hidden:
type: boolean
name:
type: string
type: object
ZeustypesGettableHost:
properties:
hosts:
@@ -9345,6 +10168,58 @@ paths:
summary: Update user preference
tags:
- preferences
/api/v2/dashboards:
post:
deprecated: false
description: 'TEMP: dummy endpoint to exercise v2 spec shape while we iterate
on plugin schema.'
operationId: CreateDashboardV2
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/Dashboardtypesv2DashboardData'
responses:
"201":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/Dashboardtypesv2DashboardData'
status:
type: string
required:
- status
- data
type: object
description: Created
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Create dashboard v2
tags:
- dashboard
/api/v2/factor_password/forgot:
post:
deprecated: false

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useRef } from 'react';
import { useCallback, useRef } from 'react';
import ChartLayout from 'container/DashboardContainer/visualization/layout/ChartLayout/ChartLayout';
import Legend from 'lib/uPlotV2/components/Legend/Legend';
import {
@@ -30,7 +30,6 @@ export default function ChartWrapper({
onDestroy = noop,
children,
layoutChildren,
yAxisUnit,
customTooltip,
pinnedTooltipElement,
'data-testid': testId,
@@ -63,13 +62,6 @@ export default function ChartWrapper({
[customTooltip],
);
const syncMetadata = useMemo(
() => ({
yAxisUnit,
}),
[yAxisUnit],
);
return (
<PlotContextProvider>
<ChartLayout
@@ -107,7 +99,6 @@ export default function ChartWrapper({
averageLegendWidth + TOOLTIP_WIDTH_PADDING,
)}
syncKey={syncKey}
syncMetadata={syncMetadata}
render={renderTooltipCallback}
pinnedTooltipElement={pinnedTooltipElement}
/>

View File

@@ -24,12 +24,13 @@ export default function Histogram(props: HistogramChartProps): JSX.Element {
}
const tooltipProps: HistogramTooltipProps = {
...props,
timezone: rest.timezone,
yAxisUnit: rest.yAxisUnit,
decimalPrecision: rest.decimalPrecision,
};
return <HistogramTooltip {...tooltipProps} />;
},
[customTooltip, rest.yAxisUnit, rest.decimalPrecision],
[customTooltip, rest.timezone, rest.yAxisUnit, rest.decimalPrecision],
);
return (

View File

@@ -12,7 +12,10 @@ interface BaseChartProps {
height: number;
showTooltip?: boolean;
showLegend?: boolean;
timezone?: Timezone;
canPinTooltip?: boolean;
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
pinnedTooltipElement?: (clickData: TooltipClickData) => React.ReactNode;
customTooltip?: (props: TooltipRenderArgs) => React.ReactNode;
'data-testid'?: string;
@@ -29,31 +32,18 @@ interface UPlotBasedChartProps {
layoutChildren?: React.ReactNode;
}
interface UPlotChartDataProps {
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
}
export interface TimeSeriesChartProps
extends BaseChartProps,
UPlotBasedChartProps,
UPlotChartDataProps {
timezone?: Timezone;
}
UPlotBasedChartProps {}
export interface HistogramChartProps
extends BaseChartProps,
UPlotBasedChartProps,
UPlotChartDataProps {
UPlotBasedChartProps {
isQueriesMerged?: boolean;
}
export interface BarChartProps
extends BaseChartProps,
UPlotBasedChartProps,
UPlotChartDataProps {
export interface BarChartProps extends BaseChartProps, UPlotBasedChartProps {
isStackedBarChart?: boolean;
timezone?: Timezone;
}
export type ChartProps =

View File

@@ -123,13 +123,13 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
}}
plotRef={onPlotRef}
onDestroy={onPlotDestroy}
yAxisUnit={widget.yAxisUnit}
decimalPrecision={widget.decimalPrecision}
data={chartData as uPlot.AlignedData}
width={containerDimensions.width}
height={containerDimensions.height}
layoutChildren={layoutChildren}
isStackedBarChart={widget.stackedBarChart ?? false}
yAxisUnit={widget.yAxisUnit}
decimalPrecision={widget.decimalPrecision}
timezone={timezone}
>
<ContextMenu

View File

@@ -3,6 +3,8 @@ import { PanelWrapperProps } from 'container/PanelWrapper/panelWrapper.types';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { useTimezone } from 'providers/Timezone';
import uPlot from 'uplot';
import Histogram from '../../charts/Histogram/Histogram';
@@ -27,6 +29,7 @@ function HistogramPanel(props: PanelWrapperProps): JSX.Element {
const containerDimensions = useResizeObserver(graphRef);
const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone();
const config = useMemo(() => {
return prepareHistogramPanelConfig({
@@ -89,9 +92,11 @@ function HistogramPanel(props: PanelWrapperProps): JSX.Element {
onDestroy={(): void => {
uPlotRef.current = null;
}}
isQueriesMerged={widget.mergeAllActiveQueries}
yAxisUnit={widget.yAxisUnit}
decimalPrecision={widget.decimalPrecision}
isQueriesMerged={widget.mergeAllActiveQueries}
syncMode={DashboardCursorSync.Crosshair}
timezone={timezone}
data={chartData as uPlot.AlignedData}
width={containerDimensions.width}
height={containerDimensions.height}

View File

@@ -48,8 +48,8 @@ jest.mock(
{JSON.stringify({
legendPosition: props.legendConfig?.position,
isQueriesMerged: props.isQueriesMerged,
yAxisUnit: props?.yAxisUnit,
decimalPrecision: props?.decimalPrecision,
yAxisUnit: props.yAxisUnit,
decimalPrecision: props.decimalPrecision,
})}
</div>
{props.layoutChildren}

View File

@@ -112,9 +112,9 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
legendConfig={{
position: widget?.legendPosition ?? LegendPosition.BOTTOM,
}}
timezone={timezone}
yAxisUnit={widget.yAxisUnit}
decimalPrecision={widget.decimalPrecision}
timezone={timezone}
data={chartData as uPlot.AlignedData}
width={containerDimensions.width}
height={containerDimensions.height}

View File

@@ -62,10 +62,10 @@ export interface TooltipRenderArgs {
export interface BaseTooltipProps {
showTooltipHeader?: boolean;
timezone?: Timezone;
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
content?: TooltipContentItem[];
timezone?: Timezone;
}
export interface TimeSeriesTooltipProps

View File

@@ -4,7 +4,6 @@ import cx from 'classnames';
import { getFocusedSeriesAtPosition } from 'lib/uPlotLib/plugins/onClickPlugin';
import uPlot from 'uplot';
import { syncCursorRegistry } from './syncCursorRegistry';
import {
createInitialControllerState,
createSetCursorHandler,
@@ -41,7 +40,6 @@ export default function TooltipPlugin({
maxHeight = 600,
syncMode = DashboardCursorSync.None,
syncKey = '_tooltip_sync_global_',
syncMetadata,
pinnedTooltipElement,
canPinTooltip = false,
}: TooltipPluginProps): JSX.Element | null {
@@ -102,29 +100,7 @@ export default function TooltipPlugin({
// crosshair / tooltip can follow the dashboard-wide cursor.
if (syncMode !== DashboardCursorSync.None && config.scales[0]?.props.time) {
config.setCursor({
sync: { key: syncKey, scales: ['x', 'y'] },
});
// Show the horizontal crosshair only when the receiving panel shares
// the same y-axis unit as the source panel. When this panel is the
// source (cursor.event != null) the line is always shown and this
// panel's metadata is written to the registry so receivers can read it.
config.addHook('setCursor', (u: uPlot): void => {
const yCursorEl = u.root.querySelector<HTMLElement>('.u-cursor-y');
if (!yCursorEl) {
return;
}
if (u.cursor.event != null) {
// This panel is the source — publish metadata and always show line.
syncCursorRegistry.setMetadata(syncKey, syncMetadata);
yCursorEl.style.display = '';
} else {
// This panel is receiving sync — show only if units match.
const sourceMeta = syncCursorRegistry.getMetadata(syncKey);
yCursorEl.style.display =
sourceMeta?.yAxisUnit === syncMetadata?.yAxisUnit ? '' : 'none';
}
sync: { key: syncKey, scales: ['x', null] },
});
}

View File

@@ -1,24 +0,0 @@
import type { TooltipSyncMetadata } from './types';
/**
* Module-level registry that tracks the metadata of the panel currently
* acting as the cursor source (the one being hovered) per sync group.
*
* uPlot fires the source panel's setCursor hook before broadcasting to
* receivers, so the registry is always populated before receivers read it.
*
* Receivers use this to make decisions such as:
* - Whether to show the horizontal crosshair line (matching yAxisUnit)
* - Future: what to render inside the tooltip (matching groupBy, etc.)
*/
const metadataBySyncKey = new Map<string, TooltipSyncMetadata | undefined>();
export const syncCursorRegistry = {
setMetadata(syncKey: string, metadata: TooltipSyncMetadata | undefined): void {
metadataBySyncKey.set(syncKey, metadata);
},
getMetadata(syncKey: string): TooltipSyncMetadata | undefined {
return metadataBySyncKey.get(syncKey);
},
};

View File

@@ -34,16 +34,11 @@ export interface TooltipLayoutInfo {
height: number;
}
export interface TooltipSyncMetadata {
yAxisUnit?: string;
}
export interface TooltipPluginProps {
config: UPlotConfigBuilder;
canPinTooltip?: boolean;
syncMode?: DashboardCursorSync;
syncKey?: string;
syncMetadata?: TooltipSyncMetadata;
render: (args: TooltipRenderArgs) => ReactNode;
pinnedTooltipElement?: (clickData: TooltipClickData) => ReactNode;
maxWidth?: number;

View File

@@ -516,7 +516,7 @@ describe('TooltipPlugin', () => {
);
expect(setCursorSpy).toHaveBeenCalledWith({
sync: { key: 'dashboard-sync', scales: ['x', 'y'] },
sync: { key: 'dashboard-sync', scales: ['x', null] },
});
});

2
go.mod
View File

@@ -8,7 +8,7 @@ require (
github.com/ClickHouse/clickhouse-go/v2 v2.40.1
github.com/DATA-DOG/go-sqlmock v1.5.2
github.com/SigNoz/govaluate v0.0.0-20240203125216-988004ccc7fd
github.com/SigNoz/signoz-otel-collector v0.144.3
github.com/SigNoz/signoz-otel-collector v0.144.3-rc.4
github.com/antlr4-go/antlr/v4 v4.13.1
github.com/antonmedv/expr v1.15.3
github.com/cespare/xxhash/v2 v2.3.0

4
go.sum
View File

@@ -108,8 +108,8 @@ github.com/SigNoz/expr v1.17.7-beta h1:FyZkleM5dTQ0O6muQfwGpoH5A2ohmN/XTasRCO72g
github.com/SigNoz/expr v1.17.7-beta/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
github.com/SigNoz/govaluate v0.0.0-20240203125216-988004ccc7fd h1:Bk43AsDYe0fhkbj57eGXx8H3ZJ4zhmQXBnrW523ktj8=
github.com/SigNoz/govaluate v0.0.0-20240203125216-988004ccc7fd/go.mod h1:nxRcH/OEdM8QxzH37xkGzomr1O0JpYBRS6pwjsWW6Pc=
github.com/SigNoz/signoz-otel-collector v0.144.3 h1:/7PPIqIpPsaWtrgnfHam2XVYP41ZlgEKLHzQO8oVxcA=
github.com/SigNoz/signoz-otel-collector v0.144.3/go.mod h1:9pLVpcIQvUT88ZiNnZN/MI+XLruvwC+Xm2UzPmGjNfA=
github.com/SigNoz/signoz-otel-collector v0.144.3-rc.4 h1:EskJkEMfuuIyArWhV8SleDV/fuKxiaEGTXrCZIFqDT4=
github.com/SigNoz/signoz-otel-collector v0.144.3-rc.4/go.mod h1:9pLVpcIQvUT88ZiNnZN/MI+XLruvwC+Xm2UzPmGjNfA=
github.com/Yiling-J/theine-go v0.6.2 h1:1GeoXeQ0O0AUkiwj2S9Jc0Mzx+hpqzmqsJ4kIC4M9AY=
github.com/Yiling-J/theine-go v0.6.2/go.mod h1:08QpMa5JZ2pKN+UJCRrCasWYO1IKCdl54Xa836rpmDU=
github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5/go.mod h1:SkGFH1ia65gfNATL8TAiHDNxPzPdmEL5uirI2Uyuz6c=

View File

@@ -7,6 +7,7 @@ import (
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes/dashboardtypesv2"
"github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
@@ -137,5 +138,25 @@ func (provider *provider) addDashboardRoutes(router *mux.Router) error {
return err
}
// TEMP dummy route to exercise the dashboard v2 schema in OpenAPI generation.
// Handler function is not invoked at spec-gen time; only OpenAPIDef types are
// walked. Reuses provider.dashboardHandler.Create to avoid wiring a real handler.
if err := router.Handle("/api/v2/dashboards", handler.New(provider.authZ.AdminAccess(provider.dashboardHandler.Create), handler.OpenAPIDef{
ID: "CreateDashboardV2",
Tags: []string{"dashboard"},
Summary: "Create dashboard v2",
Description: "TEMP: dummy endpoint to exercise v2 spec shape while we iterate on plugin schema.",
Request: new(dashboardtypesv2.DashboardData),
RequestContentType: "application/json",
Response: new(dashboardtypesv2.DashboardData),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
return nil
}

View File

@@ -32,16 +32,26 @@ func NewModule(metadataStore telemetrytypes.MetadataStore, telemetrystore teleme
}
func (m *module) ListPromotedAndIndexedPaths(ctx context.Context) ([]promotetypes.PromotePath, error) {
indexes, err := m.metadataStore.ListLogsJSONIndexes(ctx)
logsIndexes, err := m.metadataStore.ListLogsJSONIndexes(ctx)
if err != nil {
return nil, err
}
// Flatten the map values (which are slices) into a single slice
indexes := slices.Concat(slices.Collect(maps.Values(logsIndexes))...)
aggr := map[string][]promotetypes.WrappedIndex{}
for _, index := range indexes {
aggr[index.Name] = append(aggr[index.Name], promotetypes.WrappedIndex{
ColumnType: telemetrytypes.MappingFieldDataTypeToJSONDataType[index.FieldDataType].StringValue(),
Type: index.IndexType,
path, columnType, err := schemamigrator.UnfoldJSONSubColumnIndexExpr(index.Expression)
if err != nil {
return nil, err
}
// clean backticks from the path
path = strings.ReplaceAll(path, "`", "")
aggr[path] = append(aggr[path], promotetypes.WrappedIndex{
ColumnType: columnType,
Type: index.Type,
Granularity: index.Granularity,
})
}

View File

@@ -204,7 +204,7 @@ func AdjustKey(key *telemetrytypes.TelemetryFieldKey, keys map[string][]*telemet
// Downstream query builder should handle multiple matching keys with their own metadata
// and not rely on this function to do so.
materialized := true
indexes := []telemetrytypes.TelemetryFieldKeySkipIndex{}
indexes := []telemetrytypes.JSONDataTypeIndex{}
fieldContextsSeen := map[telemetrytypes.FieldContext]bool{}
dataTypesSeen := map[telemetrytypes.FieldDataType]bool{}
for _, matchingKey := range matchingKeys {

View File

@@ -195,8 +195,8 @@ func (c *jsonConditionBuilder) buildPrimitiveTerminalCondition(node *telemetryty
// the field genuinely holds the empty/zero value.
//
// Note: indexing is also skipped for Array Nested fields because they cannot be indexed.
indexed := slices.ContainsFunc(node.TerminalConfig.Key.Indexes, func(index telemetrytypes.TelemetryFieldKeySkipIndex) bool {
return telemetrytypes.MappingFieldDataTypeToJSONDataType[index.FieldDataType] == node.TerminalConfig.ElemType
indexed := slices.ContainsFunc(node.TerminalConfig.Key.Indexes, func(index telemetrytypes.JSONDataTypeIndex) bool {
return index.Type == node.TerminalConfig.ElemType
})
isExistsCheck := operator == qbtypes.FilterOperatorExists || operator == qbtypes.FilterOperatorNotExists
if node.TerminalConfig.ElemType.IndexSupported && indexed && !isExistsCheck {

View File

@@ -1127,12 +1127,9 @@ func buildTestTelemetryMetadataStore(t *testing.T, addIndexes bool) *telemetryty
return entry.Path == path && entry.Type == jsonType
})
if idx >= 0 {
key.Indexes = append(key.Indexes, telemetrytypes.TelemetryFieldKeySkipIndex{
Name: path,
FieldContext: telemetrytypes.FieldContextBody,
FieldDataType: fdt,
BaseColumn: LogsV2BodyV2Column,
IndexExpression: schemamigrator.JSONSubColumnIndexExpr(LogsV2BodyV2Column, path, jsonType.StringValue()),
key.Indexes = append(key.Indexes, telemetrytypes.JSONDataTypeIndex{
Type: jsonType,
ColumnExpression: schemamigrator.JSONSubColumnIndexExpr(LogsV2BodyV2Column, path, jsonType.StringValue()),
})
}
}

View File

@@ -106,7 +106,7 @@ func (t *telemetryMetaStore) buildJSONPlans(keys []*telemetrytypes.TelemetryFiel
return nil
}
func (t *telemetryMetaStore) getJSONPathIndexes(ctx context.Context, paths ...string) (map[string][]telemetrytypes.TelemetryFieldKeySkipIndex, error) {
func (t *telemetryMetaStore) getJSONPathIndexes(ctx context.Context, paths ...string) (map[string][]telemetrytypes.JSONDataTypeIndex, error) {
filteredPaths := []string{}
for _, path := range paths {
// skip array paths; since they don't have any indexes
@@ -116,22 +116,47 @@ func (t *telemetryMetaStore) getJSONPathIndexes(ctx context.Context, paths ...st
filteredPaths = append(filteredPaths, path)
}
if len(filteredPaths) == 0 {
return make(map[string][]telemetrytypes.TelemetryFieldKeySkipIndex), nil
return make(map[string][]telemetrytypes.JSONDataTypeIndex), nil
}
// list indexes for the paths
indexes, err := t.ListLogsJSONIndexes(ctx, filteredPaths...)
indexesMap, err := t.ListLogsJSONIndexes(ctx, filteredPaths...)
if err != nil {
return nil, errors.WrapInternalf(err, CodeFailLoadLogsJSONIndexes, "failed to list JSON path indexes")
}
// build a set of indexes
fieldPathToIndexes := make(map[string][]telemetrytypes.TelemetryFieldKeySkipIndex)
for _, index := range indexes {
fieldPathToIndexes[index.Name] = append(fieldPathToIndexes[index.Name], index)
cleanIndexes := make(map[string][]telemetrytypes.JSONDataTypeIndex)
for path, indexes := range indexesMap {
for _, index := range indexes {
columnExpr, columnType, err := schemamigrator.UnfoldJSONSubColumnIndexExpr(index.Expression)
if err != nil {
return nil, errors.WrapInternalf(err, CodeFailLoadLogsJSONIndexes, "failed to unfold JSON sub column index expression: %s", index.Expression)
}
jsonDataType, found := telemetrytypes.MappingStringToJSONDataType[columnType]
if !found {
t.logger.ErrorContext(ctx, "failed to map column type to JSON data type", slog.String("column_type", columnType), slog.String("column_expr", columnExpr))
continue
}
if jsonDataType == telemetrytypes.String {
cleanIndexes[path] = append(cleanIndexes[path], telemetrytypes.JSONDataTypeIndex{
Type: telemetrytypes.String,
ColumnExpression: columnExpr,
IndexExpression: index.Expression,
})
} else if strings.HasPrefix(index.Type, "minmax") {
cleanIndexes[path] = append(cleanIndexes[path], telemetrytypes.JSONDataTypeIndex{
Type: jsonDataType,
ColumnExpression: columnExpr,
IndexExpression: index.Expression,
})
}
}
}
return fieldPathToIndexes, nil
return cleanIndexes, nil
}
func buildListLogsJSONIndexesQuery(cluster string, filters ...string) (string, []any) {
@@ -155,7 +180,7 @@ func buildListLogsJSONIndexesQuery(cluster string, filters ...string) (string, [
return sb.BuildWithFlavor(sqlbuilder.ClickHouse)
}
func (t *telemetryMetaStore) ListLogsJSONIndexes(ctx context.Context, filters ...string) ([]telemetrytypes.TelemetryFieldKeySkipIndex, error) {
func (t *telemetryMetaStore) ListLogsJSONIndexes(ctx context.Context, filters ...string) (map[string][]schemamigrator.Index, error) {
ctx = withTelemetryContext(ctx, "ListLogsJSONIndexes")
query, args := buildListLogsJSONIndexesQuery(t.telemetrystore.Cluster(), filters...)
rows, err := t.telemetrystore.ClickhouseDB().Query(ctx, query, args...)
@@ -164,7 +189,7 @@ func (t *telemetryMetaStore) ListLogsJSONIndexes(ctx context.Context, filters ..
}
defer rows.Close()
indexes := []telemetrytypes.TelemetryFieldKeySkipIndex{}
indexes := make(map[string][]schemamigrator.Index)
for rows.Next() {
var name string
var typeFull string
@@ -173,42 +198,11 @@ func (t *telemetryMetaStore) ListLogsJSONIndexes(ctx context.Context, filters ..
if err := rows.Scan(&name, &typeFull, &expr, &granularity); err != nil {
return nil, errors.WrapInternalf(err, CodeFailLoadLogsJSONIndexes, "failed to scan string indexed column")
}
columnExpr, columnType, err := schemamigrator.UnfoldJSONSubColumnIndexExpr(expr)
if err != nil {
return nil, errors.WrapInternalf(err, CodeFailLoadLogsJSONIndexes, "failed to unfold JSON sub column index expression: %s", expr)
}
fdt, found := telemetrytypes.MappingJSONDataTypeToFieldDataType[columnType]
if !found {
t.logger.ErrorContext(ctx, "failed to map JSON data type to field data type", slog.String("column_type", columnType), slog.String("column_expr", columnExpr))
continue
}
// Key by the field path (e.g. "user.name"), not by the ClickHouse index name.
// columnExpr is the full column reference like "body_v2.user.name"; strip the
// column prefix and any backticks added for special-character path segments.
baseColumn := ""
fieldName := ""
switch {
case strings.HasPrefix(columnExpr, telemetrylogs.BodyV2ColumnPrefix):
baseColumn = telemetrylogs.BodyV2ColumnPrefix
fieldName = strings.TrimPrefix(columnExpr, telemetrylogs.BodyV2ColumnPrefix)
case strings.HasPrefix(columnExpr, telemetrylogs.BodyPromotedColumnPrefix):
baseColumn = telemetrylogs.BodyPromotedColumnPrefix
fieldName = strings.TrimPrefix(columnExpr, telemetrylogs.BodyPromotedColumnPrefix)
}
fieldName = strings.ReplaceAll(fieldName, "`", "")
indexes = append(indexes, telemetrytypes.TelemetryFieldKeySkipIndex{
Name: fieldName,
FieldContext: telemetrytypes.FieldContextBody,
FieldDataType: fdt,
BaseColumn: baseColumn,
IndexName: name,
IndexType: typeFull,
IndexExpression: expr,
Granularity: int(granularity),
indexes[name] = append(indexes[name], schemamigrator.Index{
Name: name,
Type: typeFull,
Expression: expr,
Granularity: int(granularity),
})
}

View File

@@ -1,262 +0,0 @@
package dashboardtypes
import (
"bytes"
"encoding/json"
"fmt"
"slices"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
qb "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/go-playground/validator/v10"
v1 "github.com/perses/perses/pkg/model/api/v1"
"github.com/perses/perses/pkg/model/api/v1/common"
"github.com/perses/perses/pkg/model/api/v1/dashboard"
)
// StorableDashboardDataV2 wraps v1.DashboardSpec (Perses) with additional SigNoz-specific fields.
//
// We embed DashboardSpec (not v1.Dashboard) to avoid carrying Perses's Metadata
// (Name, Project, CreatedAt, UpdatedAt, Tags, Version) and Kind field. SigNoz
// manages identity (ID), timestamps (TimeAuditable), and multi-tenancy (OrgID)
// separately on StorableDashboardV2/DashboardV2.
//
// The following v1 request fields map to locations inside v1.DashboardSpec:
// - title → Display.Name (common.Display)
// - description → Display.Description (common.Display)
//
// Fields that have no Perses equivalent will be added in this wrapper (like image, uploadGrafana, etc.)
type StorableDashboardDataV2 = v1.DashboardSpec
// UnmarshalAndValidateDashboardV2JSON unmarshals the JSON into a StorableDashboardDataV2
// (= PostableDashboardV2 = UpdatableDashboardV2) and validates plugin kinds and specs.
func UnmarshalAndValidateDashboardV2JSON(data []byte) (*StorableDashboardDataV2, error) {
var d StorableDashboardDataV2
// Note: DashboardSpec has a custom UnmarshalJSON which prevents
// DisallowUnknownFields from working at the top level. Unknown
// fields in plugin specs are still rejected by validateAndNormalizePluginSpec.
if err := json.Unmarshal(data, &d); err != nil {
return nil, err
}
if err := validateDashboardV2(d); err != nil {
return nil, err
}
return &d, nil
}
// Plugin kind → spec type factory. Each value is a pointer to the zero value of the
// expected spec struct. validatePluginSpec marshals plugin.Spec back to JSON and
// unmarshals into the typed struct to catch field-level errors.
var (
panelPluginSpecs = map[PanelPluginKind]func() any{
PanelKindTimeSeries: func() any { return new(TimeSeriesPanelSpec) },
PanelKindBarChart: func() any { return new(BarChartPanelSpec) },
PanelKindNumber: func() any { return new(NumberPanelSpec) },
PanelKindPieChart: func() any { return new(PieChartPanelSpec) },
PanelKindTable: func() any { return new(TablePanelSpec) },
PanelKindHistogram: func() any { return new(HistogramPanelSpec) },
PanelKindList: func() any { return new(ListPanelSpec) },
}
queryPluginSpecs = map[QueryPluginKind]func() any{
QueryKindBuilder: func() any { return new(BuilderQuerySpec) },
QueryKindComposite: func() any { return new(CompositeQuerySpec) },
QueryKindFormula: func() any { return new(FormulaSpec) },
QueryKindPromQL: func() any { return new(PromQLQuerySpec) },
QueryKindClickHouseSQL: func() any { return new(ClickHouseSQLQuerySpec) },
QueryKindTraceOperator: func() any { return new(TraceOperatorSpec) },
}
variablePluginSpecs = map[VariablePluginKind]func() any{
VariableKindDynamic: func() any { return new(DynamicVariableSpec) },
VariableKindQuery: func() any { return new(QueryVariableSpec) },
VariableKindCustom: func() any { return new(CustomVariableSpec) },
VariableKindTextbox: func() any { return new(TextboxVariableSpec) },
}
datasourcePluginSpecs = map[DatasourcePluginKind]func() any{
DatasourceKindSigNoz: func() any { return new(struct{}) },
}
// allowedQueryKinds maps each panel plugin kind to the query plugin
// kinds it supports. Composite sub-query types are mapped to these
// same kind strings via compositeSubQueryTypeToPluginKind.
allowedQueryKinds = map[PanelPluginKind][]QueryPluginKind{
PanelKindTimeSeries: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindBarChart: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindNumber: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindHistogram: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindPieChart: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
PanelKindTable: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
PanelKindList: {QueryKindBuilder},
}
// compositeSubQueryTypeToPluginKind maps CompositeQuery sub-query type
// strings to the equivalent top-level query plugin kind for validation.
compositeSubQueryTypeToPluginKind = map[qb.QueryType]QueryPluginKind{
qb.QueryTypeBuilder: QueryKindBuilder,
qb.QueryTypeFormula: QueryKindFormula,
qb.QueryTypeTraceOperator: QueryKindTraceOperator,
qb.QueryTypePromQL: QueryKindPromQL,
qb.QueryTypeClickHouseSQL: QueryKindClickHouseSQL,
}
)
func validateDashboardV2(d StorableDashboardDataV2) error {
for name, ds := range d.Datasources {
if err := validateDatasourcePlugin(&ds.Plugin, fmt.Sprintf("spec.datasources.%s.plugin", name)); err != nil {
return err
}
}
for i, v := range d.Variables {
if err := validateVariablePlugin(v, fmt.Sprintf("spec.variables[%d]", i)); err != nil {
return err
}
}
for key, panel := range d.Panels {
if panel == nil {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.panels.%s: panel must not be null", key)
}
path := fmt.Sprintf("spec.panels.%s", key)
if err := validatePanelPlugin(&panel.Spec.Plugin, path+".spec.plugin"); err != nil {
return err
}
panelKind := PanelPluginKind(panel.Spec.Plugin.Kind)
allowed := allowedQueryKinds[panelKind]
for qi := range panel.Spec.Queries {
queryPath := fmt.Sprintf("%s.spec.queries[%d].spec.plugin", path, qi)
if err := validateQueryPlugin(&panel.Spec.Queries[qi].Spec.Plugin, queryPath); err != nil {
return err
}
if err := validateQueryAllowedForPanel(panel.Spec.Queries[qi].Spec.Plugin, allowed, panelKind, queryPath); err != nil {
return err
}
}
}
return nil
}
func validateDatasourcePlugin(plugin *common.Plugin, path string) error {
kind := DatasourcePluginKind(plugin.Kind)
factory, ok := datasourcePluginSpecs[kind]
if !ok {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput,
"%s: unknown datasource plugin kind %q; allowed values: %s", path, kind, formatEnum(kind.Enum()))
}
return validateAndNormalizePluginSpec(plugin, factory, path)
}
func validateVariablePlugin(v dashboard.Variable, path string) error {
switch spec := v.Spec.(type) {
case *dashboard.ListVariableSpec:
pluginPath := path + ".spec.plugin"
kind := VariablePluginKind(spec.Plugin.Kind)
factory, ok := variablePluginSpecs[kind]
if !ok {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput,
"%s: unknown variable plugin kind %q; allowed values: %s", pluginPath, kind, formatEnum(kind.Enum()))
}
return validateAndNormalizePluginSpec(&spec.Plugin, factory, pluginPath)
case *dashboard.TextVariableSpec:
// TextVariables have no plugin, nothing to validate.
return nil
default:
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s: unsupported variable kind %q", path, v.Kind)
}
}
func validatePanelPlugin(plugin *common.Plugin, path string) error {
kind := PanelPluginKind(plugin.Kind)
factory, ok := panelPluginSpecs[kind]
if !ok {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput,
"%s: unknown panel plugin kind %q; allowed values: %s", path, kind, formatEnum(kind.Enum()))
}
return validateAndNormalizePluginSpec(plugin, factory, path)
}
func validateQueryPlugin(plugin *common.Plugin, path string) error {
kind := QueryPluginKind(plugin.Kind)
factory, ok := queryPluginSpecs[kind]
if !ok {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput,
"%s: unknown query plugin kind %q; allowed values: %s", path, kind, formatEnum(kind.Enum()))
}
return validateAndNormalizePluginSpec(plugin, factory, path)
}
func formatEnum(values []any) string {
parts := make([]string, len(values))
for i, v := range values {
parts[i] = fmt.Sprintf("`%v`", v)
}
return strings.Join(parts, ", ")
}
// validateAndNormalizePluginSpec validates the plugin spec and writes the typed
// struct (with defaults) back into plugin.Spec so that DB storage and API
// responses contain normalized values.
func validateAndNormalizePluginSpec(plugin *common.Plugin, factory func() any, path string) error {
if plugin.Kind == "" {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s: plugin kind is required", path)
}
if plugin.Spec == nil {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "%s: plugin spec is required", path)
}
// Re-marshal the spec and unmarshal into the typed struct.
specJSON, err := json.Marshal(plugin.Spec)
if err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s.spec", path)
}
target := factory()
decoder := json.NewDecoder(bytes.NewReader(specJSON))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s.spec", path)
}
if err := validator.New().Struct(target); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s.spec", path)
}
// Write the typed struct back so defaults are included.
plugin.Spec = target
return nil
}
// validateQueryAllowedForPanel checks that the query plugin kind is permitted
// for the given panel. For composite queries it recurses into sub-queries.
func validateQueryAllowedForPanel(plugin common.Plugin, allowed []QueryPluginKind, panelKind PanelPluginKind, path string) error {
queryKind := QueryPluginKind(plugin.Kind)
if !slices.Contains(allowed, queryKind) {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput,
"%s: query kind %q is not supported by panel kind %q", path, queryKind, panelKind)
}
// For composite queries, validate each sub-query type.
if queryKind == QueryKindComposite && plugin.Spec != nil {
specJSON, err := json.Marshal(plugin.Spec)
if err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s.spec", path)
}
var composite struct {
Queries []struct {
Type qb.QueryType `json:"type"`
} `json:"queries"`
}
if err := json.Unmarshal(specJSON, &composite); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s.spec", path)
}
for si, sub := range composite.Queries {
pluginKind, ok := compositeSubQueryTypeToPluginKind[sub.Type]
if !ok {
continue
}
if !slices.Contains(allowed, pluginKind) {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput,
"%s.spec.queries[%d]: sub-query type %q is not supported by panel kind %q",
path, si, sub.Type, panelKind)
}
}
}
return nil
}

View File

@@ -0,0 +1,274 @@
package dashboardtypesv2
import (
"bytes"
"encoding/json"
"fmt"
"slices"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
qb "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
v1 "github.com/perses/perses/pkg/model/api/v1"
"github.com/perses/perses/pkg/model/api/v1/common"
"github.com/perses/perses/pkg/model/api/v1/dashboard"
"github.com/perses/perses/pkg/model/api/v1/variable"
)
// DashboardData is the SigNoz dashboard v2 spec shape. It mirrors
// v1.DashboardSpec (Perses) field-for-field, except every common.Plugin
// occurrence is replaced with a typed SigNoz plugin whose OpenAPI schema is a
// per-site discriminated oneOf.
//
// Drift against Perses is guarded by TestDashboardDataMatchesPerses.
// Leaf types (common.Display, v1.Link, dashboard.Layout, variable.*) are reused
// directly — changes in those flow through automatically, and breaking changes
// surface as compile errors in code that uses them.
type DashboardData struct {
Display *common.Display `json:"display,omitempty"`
Datasources map[string]*DatasourceSpec `json:"datasources,omitempty"`
Variables []Variable `json:"variables,omitempty"`
Panels map[string]*Panel `json:"panels"`
Layouts []Layout `json:"layouts"`
Duration common.DurationString `json:"duration"`
RefreshInterval common.DurationString `json:"refreshInterval,omitempty"`
Links []v1.Link `json:"links,omitempty"`
}
// ══════════════════════════════════════════════
// Panel
// ══════════════════════════════════════════════
type Panel struct {
Kind string `json:"kind"`
Spec PanelSpec `json:"spec"`
}
type PanelSpec struct {
Display *v1.PanelDisplay `json:"display,omitempty"`
Plugin PanelPlugin `json:"plugin"`
Queries []Query `json:"queries,omitempty"`
Links []v1.Link `json:"links,omitempty"`
}
// ══════════════════════════════════════════════
// Query
// ══════════════════════════════════════════════
type Query struct {
Kind string `json:"kind"`
Spec QuerySpec `json:"spec"`
}
type QuerySpec struct {
Name string `json:"name,omitempty"`
Plugin QueryPlugin `json:"plugin"`
}
// ══════════════════════════════════════════════
// Variable
// ══════════════════════════════════════════════
// Variable is the list/text sum type. Spec is set to *ListVariableSpec or
// *TextVariableSpec by UnmarshalJSON based on Kind. The schema is a
// discriminated oneOf (see JSONSchemaOneOf).
type Variable struct {
Kind variable.Kind `json:"kind"`
Spec any `json:"spec"`
}
// ListVariableSpec mirrors dashboard.ListVariableSpec (variable.ListSpec
// fields + Name) but with a typed VariablePlugin replacing common.Plugin.
type ListVariableSpec struct {
Display *variable.Display `json:"display,omitempty"`
DefaultValue *variable.DefaultValue `json:"defaultValue,omitempty"`
AllowAllValue bool `json:"allowAllValue"`
AllowMultiple bool `json:"allowMultiple"`
CustomAllValue string `json:"customAllValue,omitempty"`
CapturingRegexp string `json:"capturingRegexp,omitempty"`
Sort *variable.Sort `json:"sort,omitempty"`
Plugin VariablePlugin `json:"plugin"`
Name string `json:"name"`
}
// TextVariableSpec mirrors dashboard.TextVariableSpec (variable.TextSpec +
// Name). No plugin.
type TextVariableSpec struct {
Display *variable.Display `json:"display,omitempty"`
Value string `json:"value"`
Constant bool `json:"constant,omitempty"`
Name string `json:"name"`
}
// ══════════════════════════════════════════════
// Layout
// ══════════════════════════════════════════════
// Layout is the dashboard layout sum type. Spec is populated by UnmarshalJSON
// with the concrete layout spec struct (today only dashboard.GridLayoutSpec)
// based on Kind. No plugin is involved, so we reuse the Perses spec types as
// leaf imports.
type Layout struct {
Kind dashboard.LayoutKind `json:"kind"`
Spec any `json:"spec"`
}
// ══════════════════════════════════════════════
// Datasource
// ══════════════════════════════════════════════
type DatasourceSpec struct {
Display *common.Display `json:"display,omitempty"`
Default bool `json:"default"`
Plugin DatasourcePlugin `json:"plugin"`
}
// ══════════════════════════════════════════════
// Unmarshal + validate entry point
// ══════════════════════════════════════════════
// UnmarshalAndValidateJSON unmarshals the JSON into a
// DashboardData and validates cross-field rules. Plugin kind and
// plugin-spec shape are already enforced by the typed plugin UnmarshalJSON
// paths, so only rules that can't be expressed in the type system run here.
func UnmarshalAndValidateJSON(data []byte) (*DashboardData, error) {
dec := json.NewDecoder(bytes.NewReader(data))
dec.DisallowUnknownFields()
var d DashboardData
if err := dec.Decode(&d); err != nil {
return nil, err
}
if err := validateDashboard(d); err != nil {
return nil, err
}
return &d, nil
}
// ══════════════════════════════════════════════
// Plugin kind → spec factory maps
// ══════════════════════════════════════════════
// Each plugin's UnmarshalJSON uses its factory map to instantiate a typed
// Spec based on the Kind field. Single source of truth for "which kinds
// exist" — JSONSchemaOneOf iterates the same maps.
var (
panelPluginSpecs = map[PanelPluginKind]func() any{
PanelKindTimeSeries: func() any { return new(TimeSeriesPanelSpec) },
PanelKindBarChart: func() any { return new(BarChartPanelSpec) },
PanelKindNumber: func() any { return new(NumberPanelSpec) },
PanelKindPieChart: func() any { return new(PieChartPanelSpec) },
PanelKindTable: func() any { return new(TablePanelSpec) },
PanelKindHistogram: func() any { return new(HistogramPanelSpec) },
PanelKindList: func() any { return new(ListPanelSpec) },
}
queryPluginSpecs = map[QueryPluginKind]func() any{
QueryKindBuilder: func() any { return new(BuilderQuerySpec) },
QueryKindComposite: func() any { return new(CompositeQuerySpec) },
QueryKindFormula: func() any { return new(FormulaSpec) },
QueryKindPromQL: func() any { return new(PromQLQuerySpec) },
QueryKindClickHouseSQL: func() any { return new(ClickHouseSQLQuerySpec) },
QueryKindTraceOperator: func() any { return new(TraceOperatorSpec) },
}
variablePluginSpecs = map[VariablePluginKind]func() any{
VariableKindDynamic: func() any { return new(DynamicVariableSpec) },
VariableKindQuery: func() any { return new(QueryVariableSpec) },
VariableKindCustom: func() any { return new(CustomVariableSpec) },
VariableKindTextbox: func() any { return new(TextboxVariableSpec) },
}
datasourcePluginSpecs = map[DatasourcePluginKind]func() any{
DatasourceKindSigNoz: func() any { return new(struct{}) },
}
// layoutSpecs is the layout sum type factory. Perses only defines
// KindGridLayout today; adding a new kind upstream surfaces as an
// "unknown layout kind" runtime error here until we add it.
layoutSpecs = map[dashboard.LayoutKind]func() any{
dashboard.KindGridLayout: func() any { return new(dashboard.GridLayoutSpec) },
}
// allowedQueryKinds maps each panel plugin kind to the query plugin
// kinds it supports. Composite sub-query types are mapped to these
// same kind strings via compositeSubQueryTypeToPluginKind.
allowedQueryKinds = map[PanelPluginKind][]QueryPluginKind{
PanelKindTimeSeries: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindBarChart: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindNumber: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindHistogram: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindPromQL, QueryKindClickHouseSQL},
PanelKindPieChart: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
PanelKindTable: {QueryKindBuilder, QueryKindComposite, QueryKindFormula, QueryKindTraceOperator, QueryKindClickHouseSQL},
PanelKindList: {QueryKindBuilder},
}
// compositeSubQueryTypeToPluginKind maps CompositeQuery sub-query type
// strings to the equivalent top-level query plugin kind for validation.
compositeSubQueryTypeToPluginKind = map[qb.QueryType]QueryPluginKind{
qb.QueryTypeBuilder: QueryKindBuilder,
qb.QueryTypeFormula: QueryKindFormula,
qb.QueryTypeTraceOperator: QueryKindTraceOperator,
qb.QueryTypePromQL: QueryKindPromQL,
qb.QueryTypeClickHouseSQL: QueryKindClickHouseSQL,
}
)
// ══════════════════════════════════════════════
// Cross-field validation
// ══════════════════════════════════════════════
func validateDashboard(d DashboardData) error {
for key, panel := range d.Panels {
if panel == nil {
return errors.NewInvalidInputf(dashboardtypes.ErrCodeDashboardInvalidInput, "spec.panels.%s: panel must not be null", key)
}
path := fmt.Sprintf("spec.panels.%s", key)
panelKind := panel.Spec.Plugin.Kind
allowed := allowedQueryKinds[panelKind]
for qi, q := range panel.Spec.Queries {
queryPath := fmt.Sprintf("%s.spec.queries[%d].spec.plugin", path, qi)
if err := validateQueryAllowedForPanel(q.Spec.Plugin, allowed, panelKind, queryPath); err != nil {
return err
}
}
}
return nil
}
// validateQueryAllowedForPanel checks that the query plugin kind is permitted
// for the given panel. For composite queries it recurses into sub-queries.
func validateQueryAllowedForPanel(plugin QueryPlugin, allowed []QueryPluginKind, panelKind PanelPluginKind, path string) error {
if !slices.Contains(allowed, plugin.Kind) {
return errors.NewInvalidInputf(dashboardtypes.ErrCodeDashboardInvalidInput,
"%s: query kind %q is not supported by panel kind %q", path, plugin.Kind, panelKind)
}
if plugin.Kind != QueryKindComposite {
return nil
}
composite, ok := plugin.Spec.(*CompositeQuerySpec)
if !ok || composite == nil {
return nil
}
specJSON, err := json.Marshal(composite)
if err != nil {
return errors.WrapInvalidInputf(err, dashboardtypes.ErrCodeDashboardInvalidInput, "%s.spec", path)
}
var subs struct {
Queries []struct {
Type qb.QueryType `json:"type"`
} `json:"queries"`
}
if err := json.Unmarshal(specJSON, &subs); err != nil {
return errors.WrapInvalidInputf(err, dashboardtypes.ErrCodeDashboardInvalidInput, "%s.spec", path)
}
for si, sub := range subs.Queries {
subKind, ok := compositeSubQueryTypeToPluginKind[sub.Type]
if !ok {
continue
}
if !slices.Contains(allowed, subKind) {
return errors.NewInvalidInputf(dashboardtypes.ErrCodeDashboardInvalidInput,
"%s.spec.queries[%d]: sub-query type %q is not supported by panel kind %q",
path, si, sub.Type, panelKind)
}
}
return nil
}

View File

@@ -1,4 +1,4 @@
package dashboardtypes
package dashboardtypesv2
import (
"encoding/json"
@@ -14,26 +14,26 @@ import (
func TestValidateBigExample(t *testing.T) {
data, err := os.ReadFile("testdata/perses.json")
require.NoError(t, err, "reading example file")
_, err = UnmarshalAndValidateDashboardV2JSON(data)
_, err = UnmarshalAndValidateJSON(data)
require.NoError(t, err, "expected valid dashboard")
}
func TestValidateDashboardWithSections(t *testing.T) {
data, err := os.ReadFile("testdata/perses_with_sections.json")
require.NoError(t, err, "reading example file")
_, err = UnmarshalAndValidateDashboardV2JSON(data)
_, err = UnmarshalAndValidateJSON(data)
require.NoError(t, err, "expected valid dashboard")
}
func TestInvalidateNotAJSON(t *testing.T) {
_, err := UnmarshalAndValidateDashboardV2JSON([]byte("not json"))
_, err := UnmarshalAndValidateJSON([]byte("not json"))
require.Error(t, err, "expected error for invalid JSON")
}
func TestValidateEmptySpec(t *testing.T) {
// no variables no panels
data := []byte(`{}`)
_, err := UnmarshalAndValidateDashboardV2JSON(data)
_, err := UnmarshalAndValidateJSON(data)
require.NoError(t, err, "expected valid")
}
@@ -69,7 +69,7 @@ func TestValidateOnlyVariables(t *testing.T) {
],
"layouts": []
}`)
_, err := UnmarshalAndValidateDashboardV2JSON(data)
_, err := UnmarshalAndValidateJSON(data)
require.NoError(t, err, "expected valid")
}
@@ -148,7 +148,7 @@ func TestInvalidateUnknownPluginKind(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := UnmarshalAndValidateDashboardV2JSON([]byte(tt.data))
_, err := UnmarshalAndValidateJSON([]byte(tt.data))
require.Error(t, err, "expected error containing %q, got nil", tt.wantContain)
require.Contains(t, err.Error(), tt.wantContain, "error should mention %q", tt.wantContain)
})
@@ -169,7 +169,7 @@ func TestInvalidateOneInvalidPanel(t *testing.T) {
},
"layouts": []
}`)
_, err := UnmarshalAndValidateDashboardV2JSON(data)
_, err := UnmarshalAndValidateJSON(data)
require.Error(t, err, "expected error for invalid panel plugin kind")
require.Contains(t, err.Error(), "FakePanel", "error should mention FakePanel")
}
@@ -245,7 +245,7 @@ func TestRejectUnknownFieldsInPluginSpec(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := UnmarshalAndValidateDashboardV2JSON([]byte(tt.data))
_, err := UnmarshalAndValidateJSON([]byte(tt.data))
require.Error(t, err, "expected error for unknown field")
require.Contains(t, err.Error(), tt.wantContain, "error should mention %q", tt.wantContain)
})
@@ -323,7 +323,7 @@ func TestInvalidateWrongFieldTypeInPluginSpec(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := UnmarshalAndValidateDashboardV2JSON([]byte(tt.data))
_, err := UnmarshalAndValidateJSON([]byte(tt.data))
require.Error(t, err, "expected validation error")
if tt.wantContain != "" {
require.Contains(t, err.Error(), tt.wantContain, "error should mention %q", tt.wantContain)
@@ -531,7 +531,7 @@ func TestInvalidateBadPanelSpecValues(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := UnmarshalAndValidateDashboardV2JSON([]byte(tt.data))
_, err := UnmarshalAndValidateJSON([]byte(tt.data))
require.Error(t, err, "expected error containing %q, got nil", tt.wantContain)
require.Contains(t, err.Error(), tt.wantContain, "error should mention %q", tt.wantContain)
})
@@ -626,7 +626,7 @@ func TestValidateRequiredFields(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := UnmarshalAndValidateDashboardV2JSON([]byte(tt.data))
_, err := UnmarshalAndValidateJSON([]byte(tt.data))
require.Error(t, err, "expected error containing %q, got nil", tt.wantContain)
require.Contains(t, err.Error(), tt.wantContain, "error should mention %q", tt.wantContain)
})
@@ -648,7 +648,7 @@ func TestTimeSeriesPanelDefaults(t *testing.T) {
},
"layouts": []
}`)
d, err := UnmarshalAndValidateDashboardV2JSON(data)
d, err := UnmarshalAndValidateJSON(data)
require.NoError(t, err, "unmarshal and validate failed")
// After validation+normalization, the plugin spec should be a typed struct.
@@ -695,7 +695,7 @@ func TestNumberPanelDefaults(t *testing.T) {
},
"layouts": []
}`)
d, err := UnmarshalAndValidateDashboardV2JSON(data)
d, err := UnmarshalAndValidateJSON(data)
require.NoError(t, err, "unmarshal and validate failed")
require.IsType(t, &NumberPanelSpec{}, d.Panels["p1"].Spec.Plugin.Spec)
@@ -745,7 +745,7 @@ func TestStorageRoundTrip(t *testing.T) {
}`)
// Step 1: Unmarshal + validate + normalize (what the API handler does).
d, err := UnmarshalAndValidateDashboardV2JSON(input)
d, err := UnmarshalAndValidateJSON(input)
require.NoError(t, err, "unmarshal and validate failed")
// Step 1.5: Verify struct fields have correct defaults (extra validation before storing).
@@ -765,7 +765,7 @@ func TestStorageRoundTrip(t *testing.T) {
require.NoError(t, err, "marshal for storage failed")
// Step 3: Unmarshal from JSON (simulates reading from DB).
loaded, err := UnmarshalAndValidateDashboardV2JSON(stored)
loaded, err := UnmarshalAndValidateJSON(stored)
require.NoError(t, err, "unmarshal from storage failed")
// Step 3.5: Verify struct fields have correct defaults after loading (before returning in API).
@@ -878,7 +878,7 @@ func TestPanelTypeQueryTypeCompatibility(t *testing.T) {
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, err := UnmarshalAndValidateDashboardV2JSON(tc.data)
_, err := UnmarshalAndValidateJSON(tc.data)
if tc.wantErr {
require.Error(t, err)
} else {

View File

@@ -0,0 +1,106 @@
package dashboardtypesv2
// TestDashboardDataMatchesPerses asserts that DashboardData
// and every nested SigNoz-owned type cover the JSON field set of their Perses
// counterpart. It fails loud if Perses adds, renames, or removes a field
// upstream — turning silent drift into a CI signal on the next Perses bump.
//
// The test does NOT check field *types* (plugin fields intentionally diverge:
// our typed plugins vs Perses's common.Plugin). It only checks that every
// json-tagged field in the Perses struct exists in ours under the same tag.
//
// Wrapper types we re-derive (variable.ListSpec is flattened into our
// ListVariableSpec, same for TextSpec) are compared against the flattened
// field set.
import (
"reflect"
"sort"
"strings"
"testing"
v1 "github.com/perses/perses/pkg/model/api/v1"
"github.com/perses/perses/pkg/model/api/v1/dashboard"
"github.com/stretchr/testify/assert"
)
func TestDashboardDataMatchesPerses(t *testing.T) {
cases := []struct {
name string
ours reflect.Type
perses reflect.Type
}{
{"DashboardSpec", typeOf[DashboardData](), typeOf[v1.DashboardSpec]()},
{"PanelSpec", typeOf[PanelSpec](), typeOf[v1.PanelSpec]()},
{"QuerySpec", typeOf[QuerySpec](), typeOf[v1.QuerySpec]()},
{"DatasourceSpec", typeOf[DatasourceSpec](), typeOf[v1.DatasourceSpec]()},
// ListVariableSpec/TextVariableSpec embed variable.ListSpec/TextSpec
// plus a Name field. We flatten the Perses shape to compare.
{"ListVariableSpec", typeOf[ListVariableSpec](), typeOf[dashboard.ListVariableSpec]()},
{"TextVariableSpec", typeOf[TextVariableSpec](), typeOf[dashboard.TextVariableSpec]()},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
ours := jsonFields(c.ours)
perses := jsonFields(c.perses)
missing := sortedDiff(perses, ours)
extra := sortedDiff(ours, perses)
assert.Empty(t, missing,
"DashboardData (%s) is missing json fields present on Perses %s — upstream likely added or renamed a field",
c.ours.Name(), c.perses.Name())
assert.Empty(t, extra,
"DashboardData (%s) has json fields absent on Perses %s — upstream likely removed a field or we added one without the counterpart",
c.ours.Name(), c.perses.Name())
})
}
}
// jsonFields returns the set of json tag names for a struct, flattening
// anonymous embedded fields (matching encoding/json behavior).
func jsonFields(t reflect.Type) map[string]struct{} {
out := map[string]struct{}{}
if t.Kind() != reflect.Struct {
return out
}
for i := 0; i < t.NumField(); i++ {
f := t.Field(i)
// Skip unexported fields (e.g., dashboard.TextVariableSpec has an
// unexported `variableSpec` interface tag).
if !f.IsExported() && !f.Anonymous {
continue
}
tag := f.Tag.Get("json")
name := strings.Split(tag, ",")[0]
// Anonymous embed with empty json name (no tag, or `json:",inline"` /
// `json:",omitempty"`-style options-only tag) is flattened by encoding/json.
if f.Anonymous && name == "" {
for k := range jsonFields(f.Type) {
out[k] = struct{}{}
}
continue
}
if tag == "-" || name == "" {
continue
}
out[name] = struct{}{}
}
return out
}
// sortedDiff returns keys in a but not in b, sorted.
func sortedDiff(a, b map[string]struct{}) []string {
var diff []string
for k := range a {
if _, ok := b[k]; !ok {
diff = append(diff, k)
}
}
sort.Strings(diff)
return diff
}
func typeOf[T any]() reflect.Type { return reflect.TypeOf((*T)(nil)).Elem() }

View File

@@ -1,13 +1,15 @@
package dashboardtypes
package dashboardtypesv2
import (
"encoding/json"
"strconv"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
qb "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/swaggest/jsonschema-go"
)
// ══════════════════════════════════════════════
@@ -81,12 +83,28 @@ type BuilderQuerySpec struct {
func (b *BuilderQuerySpec) UnmarshalJSON(data []byte) error {
spec, err := qb.UnmarshalBuilderQueryBySignal(data)
if err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid builder query spec")
return errors.WrapInvalidInputf(err, dashboardtypes.ErrCodeDashboardInvalidInput, "invalid builder query spec")
}
b.Spec = spec
return nil
}
// PrepareJSONSchema drops the reflected struct shape so only the
// JSONSchemaOneOf result binds.
func (BuilderQuerySpec) PrepareJSONSchema(s *jsonschema.Schema) error {
return clearOneOfParentShape(s)
}
// JSONSchemaOneOf exposes the three signal-dispatched shapes a builder query
// can take. Mirrors qb.UnmarshalBuilderQueryBySignal's runtime dispatch.
func (BuilderQuerySpec) JSONSchemaOneOf() []any {
return []any{
qb.QueryBuilderQuery[qb.LogAggregation]{},
qb.QueryBuilderQuery[qb.MetricAggregation]{},
qb.QueryBuilderQuery[qb.TraceAggregation]{},
}
}
// ══════════════════════════════════════════════
// SigNoz panel plugin specs
// ══════════════════════════════════════════════
@@ -272,7 +290,7 @@ func (t TimePreference) MarshalJSON() ([]byte, error) {
func (t *TimePreference) UnmarshalJSON(data []byte) error {
var v string
if err := json.Unmarshal(data, &v); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid timePreference: must be a string, one of `global_time`, `last_5_min`, `last_15_min`, `last_30_min`, `last_1_hr`, `last_6_hr`, `last_1_day`, `last_3_days`, `last_1_week`, or `last_1_month`")
return errors.WrapInvalidInputf(err, dashboardtypes.ErrCodeDashboardInvalidInput, "invalid timePreference: must be a string, one of `global_time`, `last_5_min`, `last_15_min`, `last_30_min`, `last_1_hr`, `last_6_hr`, `last_1_day`, `last_3_days`, `last_1_week`, or `last_1_month`")
}
if v == "" {
*t = TimePreferenceGlobalTime
@@ -284,7 +302,7 @@ func (t *TimePreference) UnmarshalJSON(data []byte) error {
*t = tp
return nil
default:
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid timePreference %q: must be `global_time`, `last_5_min`, `last_15_min`, `last_30_min`, `last_1_hr`, `last_6_hr`, `last_1_day`, `last_3_days`, `last_1_week`, or `last_1_month`", v)
return errors.NewInvalidInputf(dashboardtypes.ErrCodeDashboardInvalidInput, "invalid timePreference %q: must be `global_time`, `last_5_min`, `last_15_min`, `last_30_min`, `last_1_hr`, `last_6_hr`, `last_1_day`, `last_3_days`, `last_1_week`, or `last_1_month`", v)
}
}
@@ -313,7 +331,7 @@ func (l LegendPosition) MarshalJSON() ([]byte, error) {
func (l *LegendPosition) UnmarshalJSON(data []byte) error {
var v string
if err := json.Unmarshal(data, &v); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid legend position: must be a string, one of `bottom` or `right`")
return errors.WrapInvalidInputf(err, dashboardtypes.ErrCodeDashboardInvalidInput, "invalid legend position: must be a string, one of `bottom` or `right`")
}
if v == "" {
*l = LegendPositionBottom
@@ -325,7 +343,7 @@ func (l *LegendPosition) UnmarshalJSON(data []byte) error {
*l = lp
return nil
default:
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid legend position %q: must be `bottom` or `right`", v)
return errors.NewInvalidInputf(dashboardtypes.ErrCodeDashboardInvalidInput, "invalid legend position %q: must be `bottom` or `right`", v)
}
}
@@ -354,7 +372,7 @@ func (f ThresholdFormat) MarshalJSON() ([]byte, error) {
func (f *ThresholdFormat) UnmarshalJSON(data []byte) error {
var v string
if err := json.Unmarshal(data, &v); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid threshold format: must be a string, one of `text` or `background`")
return errors.WrapInvalidInputf(err, dashboardtypes.ErrCodeDashboardInvalidInput, "invalid threshold format: must be a string, one of `text` or `background`")
}
if v == "" {
*f = ThresholdFormatText
@@ -366,7 +384,7 @@ func (f *ThresholdFormat) UnmarshalJSON(data []byte) error {
*f = tf
return nil
default:
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid threshold format %q: must be `text` or `background`", v)
return errors.NewInvalidInputf(dashboardtypes.ErrCodeDashboardInvalidInput, "invalid threshold format %q: must be `text` or `background`", v)
}
}
@@ -406,7 +424,7 @@ func (o ComparisonOperator) MarshalJSON() ([]byte, error) {
func (o *ComparisonOperator) UnmarshalJSON(data []byte) error {
var v string
if err := json.Unmarshal(data, &v); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid comparison operator: must be a string, one of `>`, `<`, `>=`, `<=`, `=`, `above`, `below`, `above_or_equal`, `below_or_equal`, `equal`, or `not_equal`")
return errors.WrapInvalidInputf(err, dashboardtypes.ErrCodeDashboardInvalidInput, "invalid comparison operator: must be a string, one of `>`, `<`, `>=`, `<=`, `=`, `above`, `below`, `above_or_equal`, `below_or_equal`, `equal`, or `not_equal`")
}
if v == "" {
*o = ComparisonOperatorGT
@@ -420,7 +438,7 @@ func (o *ComparisonOperator) UnmarshalJSON(data []byte) error {
*o = co
return nil
default:
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid comparison operator %q: must be `>`, `<`, `>=`, `<=`, `=`, `above`, `below`, `above_or_equal`, `below_or_equal`, `equal`, or `not_equal`", v)
return errors.NewInvalidInputf(dashboardtypes.ErrCodeDashboardInvalidInput, "invalid comparison operator %q: must be `>`, `<`, `>=`, `<=`, `=`, `above`, `below`, `above_or_equal`, `below_or_equal`, `equal`, or `not_equal`", v)
}
}
@@ -451,7 +469,7 @@ func (li LineInterpolation) MarshalJSON() ([]byte, error) {
func (li *LineInterpolation) UnmarshalJSON(data []byte) error {
var v string
if err := json.Unmarshal(data, &v); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid line interpolation: must be a string, one of `linear`, `spline`, `step_after`, or `step_before`")
return errors.WrapInvalidInputf(err, dashboardtypes.ErrCodeDashboardInvalidInput, "invalid line interpolation: must be a string, one of `linear`, `spline`, `step_after`, or `step_before`")
}
if v == "" {
*li = LineInterpolationSpline
@@ -463,7 +481,7 @@ func (li *LineInterpolation) UnmarshalJSON(data []byte) error {
*li = val
return nil
default:
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid line interpolation %q: must be `linear`, `spline`, `step_after`, or `step_before`", v)
return errors.NewInvalidInputf(dashboardtypes.ErrCodeDashboardInvalidInput, "invalid line interpolation %q: must be `linear`, `spline`, `step_after`, or `step_before`", v)
}
}
@@ -492,7 +510,7 @@ func (ls LineStyle) MarshalJSON() ([]byte, error) {
func (ls *LineStyle) UnmarshalJSON(data []byte) error {
var v string
if err := json.Unmarshal(data, &v); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid line style: must be a string, one of `solid` or `dashed`")
return errors.WrapInvalidInputf(err, dashboardtypes.ErrCodeDashboardInvalidInput, "invalid line style: must be a string, one of `solid` or `dashed`")
}
if v == "" {
*ls = LineStyleSolid
@@ -504,7 +522,7 @@ func (ls *LineStyle) UnmarshalJSON(data []byte) error {
*ls = val
return nil
default:
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid line style %q: must be `solid` or `dashed`", v)
return errors.NewInvalidInputf(dashboardtypes.ErrCodeDashboardInvalidInput, "invalid line style %q: must be `solid` or `dashed`", v)
}
}
@@ -534,7 +552,7 @@ func (fm FillMode) MarshalJSON() ([]byte, error) {
func (fm *FillMode) UnmarshalJSON(data []byte) error {
var v string
if err := json.Unmarshal(data, &v); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid fill mode: must be a string, one of `solid`, `gradient`, or `none`")
return errors.WrapInvalidInputf(err, dashboardtypes.ErrCodeDashboardInvalidInput, "invalid fill mode: must be a string, one of `solid`, `gradient`, or `none`")
}
if v == "" {
*fm = FillModeSolid
@@ -546,7 +564,7 @@ func (fm *FillMode) UnmarshalJSON(data []byte) error {
*fm = val
return nil
default:
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid fill mode %q: must be `solid`, `gradient`, or `none`", v)
return errors.NewInvalidInputf(dashboardtypes.ErrCodeDashboardInvalidInput, "invalid fill mode %q: must be `solid`, `gradient`, or `none`", v)
}
}
@@ -593,12 +611,12 @@ func (p *PrecisionOption) UnmarshalJSON(data []byte) error {
p.String = valuer.NewString(strconv.Itoa(n))
return nil
default:
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid precision option %d: must be `0`, `1`, `2`, `3`, `4`, or `full`", n)
return errors.NewInvalidInputf(dashboardtypes.ErrCodeDashboardInvalidInput, "invalid precision option %d: must be `0`, `1`, `2`, `3`, `4`, or `full`", n)
}
}
var v string
if err := json.Unmarshal(data, &v); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid precision option: must be `0`, `1`, `2`, `3`, `4`, or `full`")
return errors.WrapInvalidInputf(err, dashboardtypes.ErrCodeDashboardInvalidInput, "invalid precision option: must be `0`, `1`, `2`, `3`, `4`, or `full`")
}
if v == "" {
*p = PrecisionOption2
@@ -610,6 +628,6 @@ func (p *PrecisionOption) UnmarshalJSON(data []byte) error {
*p = val
return nil
default:
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "invalid precision option %q: must be `0`, `1`, `2`, `3`, `4`, or `full`", v)
return errors.NewInvalidInputf(dashboardtypes.ErrCodeDashboardInvalidInput, "invalid precision option %q: must be `0`, `1`, `2`, `3`, `4`, or `full`", v)
}
}

View File

@@ -0,0 +1,388 @@
package dashboardtypesv2
// Typed plugin envelopes for the four plugin sites in DashboardData.
// Each plugin:
// - Has Kind (typed enum) and Spec (any, resolved at runtime via UnmarshalJSON)
// - Implements JSONSchemaOneOf so the reflector emits a per-site discriminated
// oneOf over only the kinds valid at that site
// - Implements UnmarshalJSON that dispatches Spec to the concrete type based
// on Kind, using the factory maps in dashboard_v2.go
import (
"bytes"
"encoding/json"
"fmt"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/go-playground/validator/v10"
"github.com/perses/perses/pkg/model/api/v1/dashboard"
"github.com/perses/perses/pkg/model/api/v1/variable"
"github.com/swaggest/jsonschema-go"
)
// ══════════════════════════════════════════════
// Panel plugin
// ══════════════════════════════════════════════
type PanelPlugin struct {
Kind PanelPluginKind `json:"kind"`
Spec any `json:"spec"`
}
// PrepareJSONSchema drops the reflected struct shape (type: object, properties)
// from the envelope so that only the JSONSchemaOneOf result binds. Mirrors the
// pattern in swaggest/jsonschema-go's built-in oneOf helper.
func (PanelPlugin) PrepareJSONSchema(s *jsonschema.Schema) error {
return clearOneOfParentShape(s)
}
func (PanelPlugin) JSONSchemaOneOf() []any {
return []any{
panelPluginVariant[TimeSeriesPanelSpec]{Kind: string(PanelKindTimeSeries)},
panelPluginVariant[BarChartPanelSpec]{Kind: string(PanelKindBarChart)},
panelPluginVariant[NumberPanelSpec]{Kind: string(PanelKindNumber)},
panelPluginVariant[PieChartPanelSpec]{Kind: string(PanelKindPieChart)},
panelPluginVariant[TablePanelSpec]{Kind: string(PanelKindTable)},
panelPluginVariant[HistogramPanelSpec]{Kind: string(PanelKindHistogram)},
panelPluginVariant[ListPanelSpec]{Kind: string(PanelKindList)},
}
}
func (p *PanelPlugin) UnmarshalJSON(data []byte) error {
kind, specJSON, err := splitKindSpec(data)
if err != nil {
return err
}
factory, ok := panelPluginSpecs[PanelPluginKind(kind)]
if !ok {
return errors.NewInvalidInputf(dashboardtypes.ErrCodeDashboardInvalidInput, "unknown panel plugin kind %q", kind)
}
spec, err := decodePluginSpec(specJSON, factory())
if err != nil {
return err
}
p.Kind = PanelPluginKind(kind)
p.Spec = spec
return nil
}
type panelPluginVariant[S any] struct {
Kind string `json:"kind" required:"true"`
Spec S `json:"spec" required:"true"`
}
func (v panelPluginVariant[S]) PrepareJSONSchema(s *jsonschema.Schema) error {
return applyKindEnum(s, v.Kind)
}
// ══════════════════════════════════════════════
// Query plugin
// ══════════════════════════════════════════════
type QueryPlugin struct {
Kind QueryPluginKind `json:"kind"`
Spec any `json:"spec"`
}
func (QueryPlugin) PrepareJSONSchema(s *jsonschema.Schema) error {
return clearOneOfParentShape(s)
}
func (QueryPlugin) JSONSchemaOneOf() []any {
return []any{
queryPluginVariant[BuilderQuerySpec]{Kind: string(QueryKindBuilder)},
queryPluginVariant[CompositeQuerySpec]{Kind: string(QueryKindComposite)},
queryPluginVariant[FormulaSpec]{Kind: string(QueryKindFormula)},
queryPluginVariant[PromQLQuerySpec]{Kind: string(QueryKindPromQL)},
queryPluginVariant[ClickHouseSQLQuerySpec]{Kind: string(QueryKindClickHouseSQL)},
queryPluginVariant[TraceOperatorSpec]{Kind: string(QueryKindTraceOperator)},
}
}
func (p *QueryPlugin) UnmarshalJSON(data []byte) error {
kind, specJSON, err := splitKindSpec(data)
if err != nil {
return err
}
factory, ok := queryPluginSpecs[QueryPluginKind(kind)]
if !ok {
return errors.NewInvalidInputf(dashboardtypes.ErrCodeDashboardInvalidInput, "unknown query plugin kind %q", kind)
}
spec, err := decodePluginSpec(specJSON, factory())
if err != nil {
return err
}
p.Kind = QueryPluginKind(kind)
p.Spec = spec
return nil
}
type queryPluginVariant[S any] struct {
Kind string `json:"kind" required:"true"`
Spec S `json:"spec" required:"true"`
}
func (v queryPluginVariant[S]) PrepareJSONSchema(s *jsonschema.Schema) error {
return applyKindEnum(s, v.Kind)
}
// ══════════════════════════════════════════════
// Variable plugin
// ══════════════════════════════════════════════
type VariablePlugin struct {
Kind VariablePluginKind `json:"kind"`
Spec any `json:"spec"`
}
func (VariablePlugin) PrepareJSONSchema(s *jsonschema.Schema) error {
return clearOneOfParentShape(s)
}
func (VariablePlugin) JSONSchemaOneOf() []any {
return []any{
variablePluginVariant[DynamicVariableSpec]{Kind: string(VariableKindDynamic)},
variablePluginVariant[QueryVariableSpec]{Kind: string(VariableKindQuery)},
variablePluginVariant[CustomVariableSpec]{Kind: string(VariableKindCustom)},
variablePluginVariant[TextboxVariableSpec]{Kind: string(VariableKindTextbox)},
}
}
func (p *VariablePlugin) UnmarshalJSON(data []byte) error {
kind, specJSON, err := splitKindSpec(data)
if err != nil {
return err
}
factory, ok := variablePluginSpecs[VariablePluginKind(kind)]
if !ok {
return errors.NewInvalidInputf(dashboardtypes.ErrCodeDashboardInvalidInput, "unknown variable plugin kind %q", kind)
}
spec, err := decodePluginSpec(specJSON, factory())
if err != nil {
return err
}
p.Kind = VariablePluginKind(kind)
p.Spec = spec
return nil
}
type variablePluginVariant[S any] struct {
Kind string `json:"kind" required:"true"`
Spec S `json:"spec" required:"true"`
}
func (v variablePluginVariant[S]) PrepareJSONSchema(s *jsonschema.Schema) error {
return applyKindEnum(s, v.Kind)
}
// ══════════════════════════════════════════════
// Datasource plugin
// ══════════════════════════════════════════════
type DatasourcePlugin struct {
Kind DatasourcePluginKind `json:"kind"`
Spec any `json:"spec"`
}
func (DatasourcePlugin) PrepareJSONSchema(s *jsonschema.Schema) error {
return clearOneOfParentShape(s)
}
func (DatasourcePlugin) JSONSchemaOneOf() []any {
return []any{
datasourcePluginVariant[struct{}]{Kind: string(DatasourceKindSigNoz)},
}
}
func (p *DatasourcePlugin) UnmarshalJSON(data []byte) error {
kind, specJSON, err := splitKindSpec(data)
if err != nil {
return err
}
factory, ok := datasourcePluginSpecs[DatasourcePluginKind(kind)]
if !ok {
return errors.NewInvalidInputf(dashboardtypes.ErrCodeDashboardInvalidInput, "unknown datasource plugin kind %q", kind)
}
spec, err := decodePluginSpec(specJSON, factory())
if err != nil {
return err
}
p.Kind = DatasourcePluginKind(kind)
p.Spec = spec
return nil
}
type datasourcePluginVariant[S any] struct {
Kind string `json:"kind" required:"true"`
Spec S `json:"spec" required:"true"`
}
func (v datasourcePluginVariant[S]) PrepareJSONSchema(s *jsonschema.Schema) error {
return applyKindEnum(s, v.Kind)
}
// ══════════════════════════════════════════════
// Variable envelope (list/text sum type)
// ══════════════════════════════════════════════
func (Variable) PrepareJSONSchema(s *jsonschema.Schema) error {
return clearOneOfParentShape(s)
}
func (Variable) JSONSchemaOneOf() []any {
return []any{
variableEnvelope[ListVariableSpec]{Kind: string(variable.KindList)},
variableEnvelope[TextVariableSpec]{Kind: string(variable.KindText)},
}
}
func (v *Variable) UnmarshalJSON(data []byte) error {
kind, specJSON, err := splitKindSpec(data)
if err != nil {
return err
}
switch kind {
case string(variable.KindList):
spec, err := decodeVariableSpec(specJSON, new(ListVariableSpec))
if err != nil {
return err
}
v.Kind = variable.KindList
v.Spec = spec
case string(variable.KindText):
spec, err := decodeVariableSpec(specJSON, new(TextVariableSpec))
if err != nil {
return err
}
v.Kind = variable.KindText
v.Spec = spec
default:
return errors.NewInvalidInputf(dashboardtypes.ErrCodeDashboardInvalidInput, "unknown variable kind %q", kind)
}
return nil
}
type variableEnvelope[S any] struct {
Kind string `json:"kind" required:"true"`
Spec S `json:"spec" required:"true"`
}
func (v variableEnvelope[S]) PrepareJSONSchema(s *jsonschema.Schema) error {
return applyKindEnum(s, v.Kind)
}
// ══════════════════════════════════════════════
// Layout envelope (grid, potentially more in the future)
// ══════════════════════════════════════════════
func (Layout) PrepareJSONSchema(s *jsonschema.Schema) error {
return clearOneOfParentShape(s)
}
func (Layout) JSONSchemaOneOf() []any {
return []any{
layoutEnvelope[dashboard.GridLayoutSpec]{Kind: string(dashboard.KindGridLayout)},
}
}
func (l *Layout) UnmarshalJSON(data []byte) error {
kind, specJSON, err := splitKindSpec(data)
if err != nil {
return err
}
factory, ok := layoutSpecs[dashboard.LayoutKind(kind)]
if !ok {
return errors.NewInvalidInputf(dashboardtypes.ErrCodeDashboardInvalidInput, "unknown layout kind %q", kind)
}
spec, err := decodeVariableSpec(specJSON, factory())
if err != nil {
return err
}
l.Kind = dashboard.LayoutKind(kind)
l.Spec = spec
return nil
}
type layoutEnvelope[S any] struct {
Kind string `json:"kind" required:"true"`
Spec S `json:"spec" required:"true"`
}
func (v layoutEnvelope[S]) PrepareJSONSchema(s *jsonschema.Schema) error {
return applyKindEnum(s, v.Kind)
}
// ══════════════════════════════════════════════
// Helpers
// ══════════════════════════════════════════════
// splitKindSpec parses a {"kind": "...", "spec": {...}} envelope and returns
// kind and the raw spec bytes for typed decoding.
func splitKindSpec(data []byte) (string, []byte, error) {
var head struct {
Kind string `json:"kind"`
Spec json.RawMessage `json:"spec"`
}
if err := json.Unmarshal(data, &head); err != nil {
return "", nil, err
}
return head.Kind, head.Spec, nil
}
// decodePluginSpec strict-decodes a plugin spec JSON into target and runs
// struct-tag validation (go-playground/validator). Strictness matches the
// previous validateAndNormalizePluginSpec contract: unknown fields rejected,
// required-field violations surfaced. Returns target on success.
func decodePluginSpec(specJSON []byte, target any) (any, error) {
if len(specJSON) == 0 {
return nil, errors.NewInvalidInputf(dashboardtypes.ErrCodeDashboardInvalidInput, "spec is required")
}
dec := json.NewDecoder(bytes.NewReader(specJSON))
dec.DisallowUnknownFields()
if err := dec.Decode(target); err != nil {
return nil, fmt.Errorf("decode spec: %w", err)
}
if err := validator.New().Struct(target); err != nil {
return nil, fmt.Errorf("validate spec: %w", err)
}
return target, nil
}
// decodeVariableSpec is lenient: used for ListVariableSpec / TextVariableSpec
// where Perses historically ignored unknown fields (e.g., stray `plugin` on
// text variables in existing fixtures).
func decodeVariableSpec(specJSON []byte, target any) (any, error) {
if len(specJSON) == 0 {
return nil, errors.NewInvalidInputf(dashboardtypes.ErrCodeDashboardInvalidInput, "spec is required")
}
if err := json.Unmarshal(specJSON, target); err != nil {
return nil, fmt.Errorf("decode spec: %w", err)
}
return target, nil
}
// clearOneOfParentShape drops Type and Properties on a schema that also has a
// JSONSchemaOneOf. Swaggest emits both the reflected struct shape and the
// oneOf side by side; OAS-wise the oneOf is the binding constraint so the
// properties/type are redundant noise. This mirrors swaggest's own built-in
// oneOf helper's PrepareJSONSchema.
func clearOneOfParentShape(s *jsonschema.Schema) error {
s.Type = nil
s.Properties = nil
return nil
}
// applyKindEnum narrows the `kind` property of a oneOf variant schema to a
// single permitted string, producing `kind: { type: string, enum: [kind] }`.
// Each variant calls this from PrepareJSONSchema because Go generics can't
// propagate struct tag values (so we can't write enum:"..." on Kind).
func applyKindEnum(schema *jsonschema.Schema, kind string) error {
kindProp, ok := schema.Properties["kind"]
if !ok || kindProp.TypeObject == nil {
return fmt.Errorf("variant schema missing `kind` property")
}
kindProp.TypeObject.WithEnum(kind)
schema.Properties["kind"] = kindProp
return nil
}

View File

@@ -60,7 +60,7 @@ func (i *PromotePath) ValidateAndSetDefaults() error {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "index granularity must be greater than 0")
}
jsonDataType, ok := telemetrytypes.MappingFieldDataTypeToJSONDataType[telemetrytypes.MappingJSONDataTypeToFieldDataType[index.ColumnType]]
jsonDataType, ok := telemetrytypes.MappingStringToJSONDataType[index.ColumnType]
if !ok {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid column type: %s", index.ColumnType)
}

View File

@@ -37,9 +37,9 @@ type TelemetryFieldKey struct {
FieldContext FieldContext `json:"fieldContext,omitzero"`
FieldDataType FieldDataType `json:"fieldDataType,omitzero"`
JSONPlan JSONAccessPlan `json:"-"`
Indexes []TelemetryFieldKeySkipIndex `json:"-"`
Materialized bool `json:"-"` // refers to promoted in case of body.... fields
JSONPlan JSONAccessPlan `json:"-"`
Indexes []JSONDataTypeIndex `json:"-"`
Materialized bool `json:"-"` // refers to promoted in case of body.... fields
Evolutions []*EvolutionEntry `json:"-"`
}
@@ -102,7 +102,7 @@ func (f TelemetryFieldKey) String() string {
if i > 0 {
sb.WriteString("; ")
}
fmt.Fprintf(&sb, "{type=%s, indexExpr=%s}", MappingFieldDataTypeToJSONDataType[index.FieldDataType].StringValue(), index.IndexExpression)
fmt.Fprintf(&sb, "{type=%s, columnExpr=%s, indexExpr=%s}", index.Type.StringValue(), index.ColumnExpression, index.IndexExpression)
}
sb.WriteString("]")
}
@@ -400,14 +400,3 @@ func NewFieldValueSelectorFromPostableFieldValueParams(params PostableFieldValue
return fieldValueSelector
}
type TelemetryFieldKeySkipIndex struct {
Name string `json:"name"` // Name is TelemetryFieldKey.Name not IndexName from ClickHouse
FieldContext FieldContext `json:"fieldContext,omitzero"`
FieldDataType FieldDataType `json:"fieldDataType,omitzero"`
BaseColumn string `json:"baseColumn"`
IndexName string `json:"indexName"`
IndexType string `json:"indexType"`
IndexExpression string `json:"indexExpression"`
Granularity int `json:"granularity"`
}

View File

@@ -1,5 +1,11 @@
package telemetrytypes
type JSONDataTypeIndex struct {
Type JSONDataType
ColumnExpression string
IndexExpression string
}
type JSONDataType struct {
str string // Store the correct case for ClickHouse
IsArray bool
@@ -26,17 +32,18 @@ var (
ArrayJSON = JSONDataType{"Array(JSON)", true, "JSON", false}
)
var MappingJSONDataTypeToFieldDataType = map[string]FieldDataType{
"String": FieldDataTypeString,
"Int64": FieldDataTypeInt64,
"Float64": FieldDataTypeFloat64,
"Bool": FieldDataTypeBool,
"Array(Nullable(String))": FieldDataTypeArrayString,
"Array(Nullable(Int64))": FieldDataTypeArrayInt64,
"Array(Nullable(Float64))": FieldDataTypeArrayFloat64,
"Array(Nullable(Bool))": FieldDataTypeArrayBool,
"Array(Dynamic)": FieldDataTypeArrayDynamic,
"Array(JSON)": FieldDataTypeArrayJSON,
var MappingStringToJSONDataType = map[string]JSONDataType{
"String": String,
"Int64": Int64,
"Float64": Float64,
"Bool": Bool,
"Dynamic": Dynamic,
"Array(Nullable(String))": ArrayString,
"Array(Nullable(Int64))": ArrayInt64,
"Array(Nullable(Float64))": ArrayFloat64,
"Array(Nullable(Bool))": ArrayBool,
"Array(Dynamic)": ArrayDynamic,
"Array(JSON)": ArrayJSON,
}
var MappingFieldDataTypeToJSONDataType = map[FieldDataType]JSONDataType{

View File

@@ -3,6 +3,7 @@ package telemetrytypes
import (
"context"
schemamigrator "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator"
"github.com/SigNoz/signoz/pkg/types/metrictypes"
)
@@ -34,7 +35,7 @@ type MetadataStore interface {
FetchTemporalityAndTypeMulti(ctx context.Context, queryTimeRangeStartTs, queryTimeRangeEndTs uint64, metricNames ...string) (map[string]metrictypes.Temporality, map[string]metrictypes.Type, error)
// ListLogsJSONIndexes lists the JSON indexes for the logs table.
ListLogsJSONIndexes(ctx context.Context, filters ...string) ([]TelemetryFieldKeySkipIndex, error)
ListLogsJSONIndexes(ctx context.Context, filters ...string) (map[string][]schemamigrator.Index, error)
// ListPromotedPaths lists the promoted paths.
GetPromotedPaths(ctx context.Context, paths ...string) (map[string]bool, error)

View File

@@ -4,6 +4,7 @@ import (
"context"
"strings"
schemamigrator "github.com/SigNoz/signoz-otel-collector/cmd/signozschemamigrator/schema_migrator"
"github.com/SigNoz/signoz/pkg/types/metrictypes"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
@@ -17,7 +18,7 @@ type MockMetadataStore struct {
TemporalityMap map[string]metrictypes.Temporality
TypeMap map[string]metrictypes.Type
PromotedPathsMap map[string]bool
LogsJSONIndexes []telemetrytypes.TelemetryFieldKeySkipIndex
LogsJSONIndexesMap map[string][]schemamigrator.Index
ColumnEvolutionMetadataMap map[string][]*telemetrytypes.EvolutionEntry
LookupKeysMap map[telemetrytypes.MetricMetadataLookupKey]int64
// StaticFields holds signal-specific intrinsic field definitions (e.g. telemetrylogs.IntrinsicFields).
@@ -33,7 +34,7 @@ func NewMockMetadataStore() *MockMetadataStore {
TemporalityMap: make(map[string]metrictypes.Temporality),
TypeMap: make(map[string]metrictypes.Type),
PromotedPathsMap: make(map[string]bool),
LogsJSONIndexes: []telemetrytypes.TelemetryFieldKeySkipIndex{},
LogsJSONIndexesMap: make(map[string][]schemamigrator.Index),
ColumnEvolutionMetadataMap: make(map[string][]*telemetrytypes.EvolutionEntry),
LookupKeysMap: make(map[telemetrytypes.MetricMetadataLookupKey]int64),
StaticFields: make(map[string]telemetrytypes.TelemetryFieldKey),
@@ -368,8 +369,8 @@ func (m *MockMetadataStore) GetPromotedPaths(ctx context.Context, paths ...strin
}
// ListLogsJSONIndexes lists the JSON indexes for the logs table.
func (m *MockMetadataStore) ListLogsJSONIndexes(ctx context.Context, filters ...string) ([]telemetrytypes.TelemetryFieldKeySkipIndex, error) {
return m.LogsJSONIndexes, nil
func (m *MockMetadataStore) ListLogsJSONIndexes(ctx context.Context, filters ...string) (map[string][]schemamigrator.Index, error) {
return m.LogsJSONIndexesMap, nil
}
func (m *MockMetadataStore) updateColumnEvolutionMetadataForKeys(_ context.Context, keysToUpdate []*telemetrytypes.TelemetryFieldKey) map[string][]*telemetrytypes.EvolutionEntry {

View File

@@ -23,7 +23,6 @@ pytest_plugins = [
"fixtures.notification_channel",
"fixtures.alerts",
"fixtures.cloudintegrations",
"fixtures.jsontypeexporter",
]
@@ -79,6 +78,6 @@ def pytest_addoption(parser: pytest.Parser):
parser.addoption(
"--schema-migrator-version",
action="store",
default="v0.144.3",
default="v0.144.2",
help="schema migrator version",
)

View File

@@ -1,473 +0,0 @@
"""
Simpler version of jsontypeexporter for test fixtures.
This exports JSON type metadata to the path_types table by parsing JSON bodies
and extracting all paths with their types, similar to how the real jsontypeexporter works.
"""
import datetime
import json
from abc import ABC
from http import HTTPStatus
from typing import (
Any,
Callable,
Dict,
Generator,
List,
Optional,
Set,
Union,
)
import numpy as np
import pytest
import requests
from fixtures import types
class JSONPathType(ABC):
"""Represents a JSON path with its type information"""
field_name: str
field_data_type: str
last_seen: np.uint64
signal: str = "logs"
field_context: str = "body"
def __init__(
self,
field_name: str,
field_data_type: str,
last_seen: Optional[datetime.datetime] = None,
) -> None:
self.field_name = field_name
self.field_data_type = field_data_type
self.signal = "logs"
self.field_context = "body"
if last_seen is None:
last_seen = datetime.datetime.now()
self.last_seen = np.uint64(int(last_seen.timestamp() * 1e9))
def np_arr(self) -> np.array:
"""Return path type data as numpy array for database insertion"""
return np.array([self.signal, self.field_context, self.field_name, self.field_data_type, self.last_seen])
# Constants matching jsontypeexporter
ARRAY_SEPARATOR = "[]." # Used in paths like "education[].name"
ARRAY_SUFFIX = "[]" # Used when traversing into array element objects
def _infer_array_type_from_type_strings(types: List[str]) -> Optional[str]:
"""
Infer array type from a list of pre-classified type strings.
Matches metadataexporter's inferArrayMask logic.
Internal type strings are: "JSON", "String", "Bool", "Float64", "Int64"
SuperTyping rules (matching Go inferArrayMask):
- JSON alone → []json
- JSON + any primitive → []dynamic
- String alone → []string; String + other → []dynamic
- Float64 wins over Int64 and Bool
- Int64 wins over Bool
- Bool alone → []bool
"""
if len(types) == 0:
return None
unique = set(types)
has_json = "JSON" in unique
# hasPrimitive mirrors Go: (hasJSON && len(unique) > 1) || (!hasJSON && len(unique) > 0)
has_primitive = (has_json and len(unique) > 1) or (not has_json and len(unique) > 0)
if has_json:
if not has_primitive:
return "[]json"
return "[]dynamic"
# ---- Primitive Type Resolution (Float > Int > Bool) ----
if "String" in unique:
if len(unique) > 1:
return "[]dynamic"
return "[]string"
if "Float64" in unique:
return "[]float64"
if "Int64" in unique:
return "[]int64"
if "Bool" in unique:
return "[]bool"
return "[]dynamic"
def _infer_array_type(elements: List[Any]) -> Optional[str]:
"""
Infer array type from raw Python list elements.
Classifies each element then delegates to _infer_array_type_from_type_strings.
"""
if len(elements) == 0:
return None
types = []
for elem in elements:
if elem is None:
continue
if isinstance(elem, dict):
types.append("JSON")
elif isinstance(elem, str):
types.append("String")
elif isinstance(elem, bool): # must be before int (bool is subclass of int)
types.append("Bool")
elif isinstance(elem, float):
types.append("Float64")
elif isinstance(elem, int):
types.append("Int64")
return _infer_array_type_from_type_strings(types)
def _python_type_to_clickhouse_type(value: Any) -> str:
"""
Convert Python type to ClickHouse JSON type string.
Maps Python types to ClickHouse JSON data types.
Matches metadataexporter's mapPCommonValueTypeToDataType.
"""
if isinstance(value, bool):
return "bool"
elif isinstance(value, int):
return "int64"
elif isinstance(value, float):
return "float64"
elif isinstance(value, str):
return "string"
elif isinstance(value, list):
# Use the sophisticated array type inference
array_type = _infer_array_type(value)
return array_type if array_type else "[]dynamic"
elif isinstance(value, dict):
return "json"
else:
return "string" # Default fallback
def _extract_json_paths(
obj: Any,
current_path: str = "",
path_types: Optional[Dict[str, Set[str]]] = None,
level: int = 0,
) -> Dict[str, Set[str]]:
"""
Recursively extract all paths and their types from a JSON object.
Matches jsontypeexporter's analyzePValue logic.
Args:
obj: The JSON object to traverse
current_path: Current path being built (e.g., "user.name")
path_types: Dictionary mapping paths to sets of types found
level: Current nesting level (for depth limiting)
Returns:
Dictionary mapping paths to sets of type strings
"""
if path_types is None:
path_types = {}
if obj is None:
# Skip null values — matches Go walkNode which errors on ValueTypeEmpty
return path_types
if isinstance(obj, dict):
# For objects, recurse into keys without recording the object itself as a type.
# Matches Go walkMap which recurses without calling ta.record on the map node.
for key, value in obj.items():
# Build the path for this key
if current_path:
new_path = f"{current_path}.{key}"
else:
new_path = key
# Recurse into the value
_extract_json_paths(value, new_path, path_types, level + 1)
elif isinstance(obj, list):
# Skip empty arrays
if len(obj) == 0:
return path_types
# Collect types from array elements (matching Go: types := make([]pcommon.ValueType, 0, s.Len()))
types = []
for item in obj:
if isinstance(item, dict):
# When traversing into array element objects, use ArraySuffix ([])
# This matches: prefix+ArraySuffix in the Go code
# Example: if current_path is "education", we use "education[]" to traverse into objects
array_prefix = current_path + ARRAY_SUFFIX if current_path else ""
for key, value in item.items():
if array_prefix:
# Use array separator: education[].name
array_path = f"{array_prefix}.{key}"
else:
array_path = key
# Recurse without increasing level (matching Go behavior)
_extract_json_paths(value, array_path, path_types, level)
types.append("JSON")
elif isinstance(item, list):
# Arrays inside arrays are not supported - skip the whole path
# Matching Go: e.logger.Error("arrays inside arrays are not supported!", ...); return nil
return path_types
elif isinstance(item, str):
types.append("String")
elif isinstance(item, bool):
types.append("Bool")
elif isinstance(item, float):
types.append("Float64")
elif isinstance(item, int):
types.append("Int64")
# Infer array type from collected types (matching Go: if mask := inferArrayMask(types); mask != 0)
if len(types) > 0:
array_type = _infer_array_type_from_type_strings(types)
if array_type and current_path:
if current_path not in path_types:
path_types[current_path] = set()
path_types[current_path].add(array_type)
else:
# Primitive value (string, number, bool)
if current_path:
if current_path not in path_types:
path_types[current_path] = set()
obj_type = _python_type_to_clickhouse_type(obj)
path_types[current_path].add(obj_type)
return path_types
def _parse_json_bodies_and_extract_paths(
json_bodies: List[str],
timestamp: Optional[datetime.datetime] = None,
) -> List[JSONPathType]:
"""
Parse JSON bodies and extract all paths with their types.
This mimics the behavior of jsontypeexporter.
Args:
json_bodies: List of JSON body strings to parse
timestamp: Timestamp to use for last_seen (defaults to now)
Returns:
List of JSONPathType objects with all discovered paths and types
"""
if timestamp is None:
timestamp = datetime.datetime.now()
# Aggregate all paths and their types across all JSON bodies
all_path_types: Dict[str, Set[str]] = {}
for json_body in json_bodies:
try:
parsed = json.loads(json_body)
_extract_json_paths(parsed, "", all_path_types, level=0)
except (json.JSONDecodeError, TypeError):
# Skip invalid JSON
continue
# Convert to list of JSONPathType objects
# Each path can have multiple types, so we create one JSONPathType per type
path_type_objects: List[JSONPathType] = []
for path, types_set in all_path_types.items():
for type_str in types_set:
path_type_objects.append(
JSONPathType(field_name=path, field_data_type=type_str, last_seen=timestamp)
)
return path_type_objects
@pytest.fixture(name="export_json_types", scope="function")
def export_json_types(
clickhouse: types.TestContainerClickhouse,
request: pytest.FixtureRequest, # To access migrator fixture
) -> Generator[
Callable[[Union[List[JSONPathType], List[str], List[Any]]], None], Any, None
]:
"""
Fixture for exporting JSON type metadata to the path_types table.
This is a simpler version of jsontypeexporter for test fixtures.
The function can accept:
1. List of JSONPathType objects (manual specification)
2. List of JSON body strings (auto-extract paths)
3. List of Logs objects (extract from body_json field)
Usage examples:
# Manual specification
export_json_types([
JSONPathType(field_name="user.name", field_data_type="string"),
JSONPathType(field_name="user.age", field_data_type="int64"),
])
# Auto-extract from JSON strings
export_json_types([
'{"user": {"name": "alice", "age": 25}}',
'{"user": {"name": "bob", "age": 30}}',
])
# Auto-extract from Logs objects
export_json_types(logs_list)
"""
# Ensure migrator has run to create the table
try:
request.getfixturevalue("migrator")
except Exception:
# If migrator fixture is not available, that's okay - table might already exist
pass
def _export_json_types(
data: Union[
List[JSONPathType], List[str], List[Any]
], # List[Logs] but avoiding circular import
) -> None:
"""
Export JSON type metadata to signoz_metadata.distributed_field_keys table.
This table stores signal, context, path, and type information for body JSON fields.
"""
path_types: List[JSONPathType] = []
if len(data) == 0:
return
# Determine input type and convert to JSONPathType list
first_item = data[0]
if isinstance(first_item, JSONPathType):
# Already JSONPathType objects
path_types = data # type: ignore
elif isinstance(first_item, str):
# List of JSON strings - parse and extract paths
path_types = _parse_json_bodies_and_extract_paths(data) # type: ignore
else:
# Assume it's a list of Logs objects - extract body_v2
json_bodies: List[str] = []
for log in data: # type: ignore
# Try to get body_v2 attribute
if hasattr(log, "body_v2") and log.body_v2:
json_bodies.append(log.body_v2)
elif hasattr(log, "body") and log.body:
# Fallback to body if body_v2 not available
try:
# Try to parse as JSON
json.loads(log.body)
json_bodies.append(log.body)
except (json.JSONDecodeError, TypeError):
pass
if json_bodies:
path_types = _parse_json_bodies_and_extract_paths(json_bodies)
if len(path_types) == 0:
return
clickhouse.conn.insert(
database="signoz_metadata",
table="distributed_field_keys",
data=[path_type.np_arr() for path_type in path_types],
column_names=[
"signal",
"field_context",
"field_name",
"field_data_type",
"last_seen",
],
)
yield _export_json_types
# Cleanup - truncate the local table after tests (following pattern from logs fixture)
clickhouse.conn.query(
f"TRUNCATE TABLE signoz_metadata.field_keys ON CLUSTER '{clickhouse.env['SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_CLUSTER']}' SYNC"
)
@pytest.fixture(name="create_json_index", scope="function")
def create_json_index(
signoz: types.SigNoz,
) -> Generator[Callable[[str, List[Dict[str, Any]]], None], None, None]:
"""
Create ClickHouse data-skipping indexes on body_v2 JSON sub-columns via
POST /api/v1/logs/promote_paths.
**Must be called BEFORE insert_logs** so that newly inserted data parts are
covered by the index and the QB uses the indexed condition path.
Each entry in `paths` follows the PromotePath API shape:
{
"path": "body.user.name", # must start with "body."
"indexes": [
{
"column_type": "String", # String | Int64 | Float64
"type": "ngrambf_v1(3, 256, 2, 0)", # or "minmax", "tokenbf_v1(...)"
"granularity": 1,
}
],
}
Teardown drops every index created during the test by querying
system.data_skipping_indices for matching expressions.
Example::
def test_foo(signoz, get_token, insert_logs, export_json_types, create_json_body_index):
token = get_token(...)
export_json_types(logs_list)
create_json_body_index(token, [
{"path": "body.user.name",
"indexes": [{"column_type": "String", "type": "ngrambf_v1(3, 256, 2, 0)", "granularity": 1}]},
{"path": "body.user.age",
"indexes": [{"column_type": "Int64", "type": "minmax", "granularity": 1}]},
])
insert_logs(logs_list) # data inserted after index exists — index is built automatically
"""
created_paths: List[str] = []
def _create_json_body_index(token: str, paths: List[Dict[str, Any]]) -> None:
response = requests.post(
signoz.self.host_configs["8080"].get("/api/v1/logs/promote_paths"),
headers={"authorization": f"Bearer {token}"},
json=paths,
timeout=30,
)
assert response.status_code == HTTPStatus.CREATED, (
f"Failed to create JSON body indexes: "
f"{response.status_code} {response.text}"
)
for path in paths:
# The API strips the "body." prefix before storing — mirror that here
# so our cleanup query uses the bare path (e.g. "user.name").
raw = path["path"].removeprefix("body.")
if raw not in created_paths:
created_paths.append(raw)
yield _create_json_body_index
if not created_paths:
return
cluster = signoz.telemetrystore.env["SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_CLUSTER"]
for path in created_paths:
result = signoz.telemetrystore.conn.query(
"SELECT name FROM system.data_skipping_indices "
"WHERE database = 'signoz_logs' AND table = 'logs_v2' "
f"AND expr LIKE '%{path}%'"
)
for (index_name,) in result.result_rows:
signoz.telemetrystore.conn.query(
f"ALTER TABLE signoz_logs.logs_v2 "
f"ON CLUSTER '{cluster}' "
f"DROP INDEX IF EXISTS `{index_name}`"
)

View File

@@ -122,8 +122,6 @@ class Logs(ABC):
resources: dict[str, Any] = {},
attributes: dict[str, Any] = {},
body: str = "default body",
body_v2: Optional[str] = None,
body_promoted: Optional[str] = None,
severity_text: str = "INFO",
trace_id: str = "",
span_id: str = "",
@@ -169,33 +167,6 @@ class Logs(ABC):
# Set body
self.body = body
# Set body_v2 - if body is JSON, parse and stringify it, otherwise use empty string
# ClickHouse accepts String input for JSON column
if body_v2 is not None:
self.body_v2 = body_v2
else:
# Try to parse body as JSON; if successful use it directly,
# otherwise wrap as {"message": body} matching the normalize operator behavior.
try:
json.loads(body)
self.body_v2 = body
except (json.JSONDecodeError, TypeError):
self.body_v2 = json.dumps({"message": body})
# Set body_promoted - must be valid JSON
# Tests will explicitly pass promoted column's content, but we validate it
if body_promoted is not None:
# Validate that it's valid JSON
try:
json.loads(body_promoted)
self.body_promoted = body_promoted
except (json.JSONDecodeError, TypeError):
# If invalid, default to empty JSON object
self.body_promoted = "{}"
else:
# Default to empty JSON object (valid JSON)
self.body_promoted = "{}"
# Process resources and attributes
self.resources_string = {k: str(v) for k, v in resources.items()}
self.resource_json = (
@@ -355,8 +326,6 @@ class Logs(ABC):
self.severity_text,
self.severity_number,
self.body,
self.body_v2,
self.body_promoted,
self.attributes_string,
self.attributes_number,
self.attributes_bool,
@@ -485,53 +454,31 @@ def insert_logs(
data=[resource_key.np_arr() for resource_key in resource_keys],
)
# All columns in insertion order (must match Logs.np_arr() order)
all_column_names = [
"ts_bucket_start",
"resource_fingerprint",
"timestamp",
"observed_timestamp",
"id",
"trace_id",
"span_id",
"trace_flags",
"severity_text",
"severity_number",
"body",
"body_v2",
"body_promoted",
"attributes_string",
"attributes_number",
"attributes_bool",
"resources_string",
"scope_name",
"scope_version",
"scope_string",
"resource",
]
# Check if body_v2 column exists (only present when ENABLE_LOGS_MIGRATIONS_V2 migration has run)
result = clickhouse.conn.query(
"SELECT count() FROM system.columns WHERE database = 'signoz_logs' AND table = 'logs_v2' AND name = 'body_v2'"
)
has_json_body = result.result_rows[0][0] > 0
if has_json_body:
column_names = all_column_names
data = [log.np_arr() for log in logs]
else:
json_body_cols = {"body_v2", "body_promoted"}
keep_indices = [
i for i, c in enumerate(all_column_names) if c not in json_body_cols
]
column_names = [all_column_names[i] for i in keep_indices]
data = [log.np_arr()[keep_indices] for log in logs]
clickhouse.conn.insert(
database="signoz_logs",
table="distributed_logs_v2",
data=data,
column_names=column_names,
data=[log.np_arr() for log in logs],
column_names=[
"ts_bucket_start",
"resource_fingerprint",
"timestamp",
"observed_timestamp",
"id",
"trace_id",
"span_id",
"trace_flags",
"severity_text",
"severity_number",
"body",
"attributes_string",
"attributes_number",
"attributes_bool",
"resources_string",
"scope_name",
"scope_version",
"scope_string",
"resource",
],
)
yield _insert_logs

View File

@@ -1,5 +1,3 @@
from typing import Optional
import docker
import pytest
from testcontainers.core.container import Network
@@ -10,32 +8,27 @@ from fixtures.logger import setup_logger
logger = setup_logger(__name__)
def create_migrator(
@pytest.fixture(name="migrator", scope="package")
def migrator(
network: Network,
clickhouse: types.TestContainerClickhouse,
request: pytest.FixtureRequest,
pytestconfig: pytest.Config,
cache_key: str = "migrator",
env_overrides: Optional[dict] = None,
) -> types.Operation:
"""
Factory function for running schema migrations.
Accepts optional env_overrides to customize the migrator environment.
Package-scoped fixture for running schema migrations.
"""
def create() -> None:
version = request.config.getoption("--schema-migrator-version")
client = docker.from_env()
environment = dict(env_overrides) if env_overrides else {}
container = client.containers.run(
image=f"signoz/signoz-schema-migrator:{version}",
command=f"sync --replication=true --cluster-name=cluster --up= --dsn={clickhouse.env["SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_DSN"]}",
detach=True,
auto_remove=False,
network=network.id,
environment=environment,
)
result = container.wait()
@@ -54,7 +47,6 @@ def create_migrator(
detach=True,
auto_remove=False,
network=network.id,
environment=environment,
)
result = container.wait()
@@ -67,7 +59,7 @@ def create_migrator(
container.remove()
return types.Operation(name=cache_key)
return types.Operation(name="migrator")
def delete(_: types.Operation) -> None:
pass
@@ -78,27 +70,9 @@ def create_migrator(
return dev.wrap(
request,
pytestconfig,
cache_key,
"migrator",
lambda: types.Operation(name=""),
create,
delete,
restore,
)
@pytest.fixture(name="migrator", scope="package")
def migrator(
network: Network,
clickhouse: types.TestContainerClickhouse,
request: pytest.FixtureRequest,
pytestconfig: pytest.Config,
) -> types.Operation:
"""
Package-scoped fixture for running schema migrations.
"""
return create_migrator(
network=network,
clickhouse=clickhouse,
request=request,
pytestconfig=pytestconfig,
)

View File

@@ -1,70 +0,0 @@
import pytest
from testcontainers.core.container import Network
from fixtures import types
from fixtures.migrator import create_migrator
from fixtures.signoz import create_signoz
UNSUPPORTED_CLICKHOUSE_VERSIONS = {"25.5.6"}
def pytest_collection_modifyitems(
config: pytest.Config, items: list[pytest.Item]
) -> None:
version = config.getoption("--clickhouse-version")
if version in UNSUPPORTED_CLICKHOUSE_VERSIONS:
skip = pytest.mark.skip(
reason=f"JSON body QB tests require ClickHouse > {version}"
)
for item in items:
item.add_marker(skip)
@pytest.fixture(name="migrator", scope="package")
def migrator_json(
network: Network,
clickhouse: types.TestContainerClickhouse,
request: pytest.FixtureRequest,
pytestconfig: pytest.Config,
) -> types.Operation:
"""
Package-scoped migrator with ENABLE_LOGS_MIGRATIONS_V2=1.
"""
return create_migrator(
network=network,
clickhouse=clickhouse,
request=request,
pytestconfig=pytestconfig,
cache_key="migrator-json-body",
env_overrides={
"ENABLE_LOGS_MIGRATIONS_V2": "1",
},
)
@pytest.fixture(name="signoz", scope="package")
def signoz_json_body(
network: Network,
zeus: types.TestContainerDocker,
gateway: types.TestContainerDocker,
sqlstore: types.TestContainerSQL,
clickhouse: types.TestContainerClickhouse,
request: pytest.FixtureRequest,
pytestconfig: pytest.Config,
) -> types.SigNoz:
"""
Package-scoped fixture for SigNoz with BODY_JSON_QUERY_ENABLED=true.
"""
return create_signoz(
network=network,
zeus=zeus,
gateway=gateway,
sqlstore=sqlstore,
clickhouse=clickhouse,
request=request,
pytestconfig=pytestconfig,
cache_key="signoz-json-body",
env_overrides={
"BODY_JSON_QUERY_ENABLED": "true",
},
)