Compare commits

..

67 Commits

Author SHA1 Message Date
Naman Verma
7d3612c10a feat: textbox variable 2026-03-25 13:21:46 +05:30
Naman Verma
c82cd32f61 Merge branch 'main' into nv/4172 2026-03-25 13:11:49 +05:30
Nityananda Gohain
658f794842 chore: add tests for trace waterfall (#10690)
* chore: add tests for trace waterfall

* chore: remove unhelpful tests
2026-03-25 07:13:13 +00:00
primus-bot[bot]
e9abd5ddfc chore(release): bump to v0.117.0 (#10707)
Co-authored-by: primus-bot[bot] <171087277+primus-bot[bot]@users.noreply.github.com>
2026-03-25 06:54:49 +00:00
Piyush Singariya
ea2663b145 fix: enrich unspecified fields in logs pipelines filters (#10686)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* fix: enrich unspecified fields

* fix: return error in enrich function

* chore: nit change asked
2026-03-25 05:01:26 +00:00
Pandey
234716df53 fix(querier): return proper HTTP status for PromQL timeout errors (#10689)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* fix(querier): return proper HTTP status for PromQL timeout errors

PromQL queries hitting the context deadline were incorrectly returning
400 Bad Request with "invalid_input" because enhancePromQLError
unconditionally wrapped all errors as TypeInvalidInput. Extract
tryEnhancePromQLExecError to properly classify timeout, cancellation,
and storage errors before falling through to parse error handling.

Also make the PromQL engine timeout configurable via prometheus.timeout
config (default 2m) instead of hardcoding it.

* chore: refactor files

* fix(prometheus): validate timeout config and fix test setups

Add validation in prometheus.Config to reject zero timeout. Update all
test files to explicitly set Timeout: 2 * time.Minute in prometheus.Config
literals to avoid immediate query timeouts.
2026-03-24 13:32:45 +00:00
Naman Verma
a3980e084c Merge branch 'main' into nv/4172 2026-03-24 16:44:50 +05:30
Naman Verma
4319dd9cef chore: doc for how to add a panel spec 2026-03-24 16:44:34 +05:30
Naman Verma
92a5e9b9c9 chore: common threshold type 2026-03-24 16:23:11 +05:30
Naman Verma
408a914129 chore: change attr name to name 2026-03-24 16:13:09 +05:30
Naman Verma
0e304b1d40 fix: normalise enum defs 2026-03-24 16:12:09 +05:30
Naman Verma
58a9be24d3 chore: single version for all schemas 2026-03-24 14:07:24 +05:30
Naman Verma
adf439fcf1 fix: proper type for selectFields 2026-03-24 13:56:14 +05:30
Naman Verma
a1a54c4bb2 fix: promql step duration schema 2026-03-24 13:53:19 +05:30
Naman Verma
3c1961d3fc fix: datasource in perses.json 2026-03-24 13:44:58 +05:30
Naman Verma
c3efa0660b fix: only allow one of metric or expr aggregation in builder query 2026-03-24 13:40:42 +05:30
Naman Verma
183dd09082 fix: functions in formula 2026-03-24 13:34:59 +05:30
Naman Verma
a351373c49 chore: common package for variables' repeated definitions 2026-03-24 13:23:53 +05:30
Naman Verma
8e7653b90d chore: common package for queries' repeated definitions 2026-03-24 13:22:49 +05:30
Naman Verma
5c40d6b68b chore: common package for panels' repeated definitions 2026-03-24 13:19:12 +05:30
Naman Verma
31115df41c chore: actually name every panel as a panel 2026-03-24 13:05:55 +05:30
Naman Verma
869c3dccb2 fix: less verbose field names in dynamic var 2026-03-24 12:57:34 +05:30
Naman Verma
c5d7a7ef8c fix: no nesting in context links 2026-03-24 12:55:40 +05:30
Naman Verma
544b87b254 chore: remove unimplemented join query schema 2026-03-24 12:48:36 +05:30
Naman Verma
e885fb98e5 chore: no need for threshold prefix inside threshold obj 2026-03-24 12:44:50 +05:30
Naman Verma
be227eec43 chore: replace yAxisUnit by unit 2026-03-24 12:41:16 +05:30
Naman Verma
13263c1f25 Merge branch 'main' into nv/4172 2026-03-23 20:01:04 +05:30
Naman Verma
ccbf410d15 fix: no more online validation 2026-03-23 16:23:51 +05:30
Naman Verma
03b98ff824 chore: remaining fields file 2026-03-23 16:10:01 +05:30
Naman Verma
2cdba0d11c chore: examples for panel types 2026-03-23 16:06:29 +05:30
Naman Verma
84d2885530 chore: a more complex example 2026-03-23 16:03:18 +05:30
Naman Verma
b82dcc6138 docs: list panel schema without upstream ref 2026-03-23 15:31:53 +05:30
Naman Verma
a14d5847b9 docs: histogram chart panel schema without upstream ref 2026-03-23 15:21:27 +05:30
Naman Verma
d184746142 docs: table chart panel schema without upstream ref 2026-03-23 15:17:21 +05:30
Naman Verma
c335e17e1d docs: pie chart panel schema without upstream ref 2026-03-23 14:33:05 +05:30
Naman Verma
433dd0b2d0 docs: number panel schema without upstream ref 2026-03-23 14:27:46 +05:30
Naman Verma
05e97e246a docs: number panel schema without upstream ref 2026-03-23 14:27:37 +05:30
Naman Verma
bddfe30f6c docs: bar chart panel schema without upstream ref 2026-03-23 14:22:00 +05:30
Naman Verma
7a01a5250d chore: object for visualization section 2026-03-23 14:16:07 +05:30
Naman Verma
09c98c830d docs: time series panel schema without upstream ref 2026-03-23 14:11:02 +05:30
Naman Verma
0fbb90cc91 fix: promql fix 2026-03-23 14:09:48 +05:30
Naman Verma
15f0787610 chore: remove upstream import 2026-03-23 13:07:48 +05:30
Naman Verma
22ebc7732c feat: promql example 2026-03-22 19:39:57 +05:30
Naman Verma
cff18edf6e chore: comment explaining when to use composite query and when not 2026-03-22 19:22:45 +05:30
Naman Verma
cb49c0bf3b feat: custom time series schema 2026-03-22 19:18:15 +05:30
Naman Verma
1cb6f94d21 fix: proper composite query schema 2026-03-19 16:18:31 +05:30
Naman Verma
68155f374b chore: folders in schemas for arranging 2026-03-19 15:15:06 +05:30
Naman Verma
696524509f chore: folders in schemas for arranging 2026-03-19 15:14:43 +05:30
Naman Verma
705cdab38c chore: rename 2026-03-19 14:24:10 +05:30
Naman Verma
ae9b881413 chore: py script not needed 2026-03-19 01:39:17 +05:30
Naman Verma
05f4e15d07 chore: rearrange specs in package.json 2026-03-19 01:39:08 +05:30
Naman Verma
1653c6d725 chore: checkpoint for half correct setup 2026-03-19 01:34:22 +05:30
Naman Verma
070b4b7061 chore: test file with way more examples 2026-03-18 22:01:16 +05:30
Naman Verma
7f4c06edd6 chore: test file with way more examples 2026-03-18 22:01:10 +05:30
Naman Verma
6bed20b5b9 fix: remove fields from variable specs that are there in ListVariable 2026-03-18 19:19:00 +05:30
Naman Verma
033bd3c9b8 feat: validation script 2026-03-18 15:52:05 +05:30
Naman Verma
d4c9a923fd chore: no commons (for now) 2026-03-18 15:28:35 +05:30
Naman Verma
387dcb529f chore: no config folder 2026-03-18 15:04:16 +05:30
Naman Verma
7a4da7bcc5 chore: no config folder 2026-03-18 15:04:02 +05:30
Naman Verma
b152fae3fa chore: remove validate file 2026-03-18 15:03:19 +05:30
Naman Verma
2ed766726c chore: remove manually written manifest and package 2026-03-18 15:02:23 +05:30
Naman Verma
8767f6a57d chore: remove stub for time series chart 2026-03-18 15:01:58 +05:30
Naman Verma
22d8c7599b chore: rm comment 2026-03-18 13:10:19 +05:30
Naman Verma
1019264272 chore: no need for PageSize type in commons, only used once 2026-03-18 13:07:31 +05:30
Naman Verma
c950d7e784 chore: no need for Signal type in commons, only used once 2026-03-18 13:05:32 +05:30
Naman Verma
1e279e6193 Merge branch 'main' into nv/4172 2026-03-18 13:03:09 +05:30
Naman Verma
d3a278c43e docs: perses schema for dashboards 2026-03-17 14:56:07 +05:30
68 changed files with 4755 additions and 61 deletions

View File

@@ -144,6 +144,8 @@ telemetrystore:
##################### Prometheus #####################
prometheus:
# The maximum time a PromQL query is allowed to run before being aborted.
timeout: 2m
active_query_tracker:
# Whether to enable the active query tracker.
enabled: true

View File

@@ -190,7 +190,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.116.1
image: signoz/signoz:v0.117.0
ports:
- "8080:8080" # signoz port
# - "6060:6060" # pprof port

View File

@@ -117,7 +117,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.116.1
image: signoz/signoz:v0.117.0
ports:
- "8080:8080" # signoz port
volumes:

View File

@@ -181,7 +181,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.116.1}
image: signoz/signoz:${VERSION:-v0.117.0}
container_name: signoz
ports:
- "8080:8080" # signoz port

View File

@@ -109,7 +109,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.116.1}
image: signoz/signoz:${VERSION:-v0.117.0}
container_name: signoz
ports:
- "8080:8080" # signoz port

View File

@@ -136,6 +136,7 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
logParsingPipelineController, err := logparsingpipeline.NewLogParsingPipelinesController(
signoz.SQLStore,
integrationsController.GetPipelinesForInstalledIntegrations,
reader,
)
if err != nil {
return nil, err

View File

@@ -257,7 +257,7 @@ func TestManager_TestNotification_SendUnmatched_PromRule(t *testing.T) {
WillReturnRows(samplesRows)
// Create Prometheus provider for this test
promProvider = prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{}, store)
promProvider = prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{Timeout: 2 * time.Minute}, store)
},
ManagerOptionsHook: func(opts *rules.ManagerOptions) {
// Set Prometheus provider for PromQL queries

115
perses/common/common.cue Normal file
View File

@@ -0,0 +1,115 @@
package common
// ──────────────────────────────────────────────
// Shared types
// ──────────────────────────────────────────────
#TelemetryFieldKey: {
name: string
key?: string
description?: string
unit?: string
signal?: string
fieldContext?: string
fieldDataType?: string
materialized?: bool
isIndexed?: bool
}
// ──────────────────────────────────────────────
// Panel types
// ──────────────────────────────────────────────
#ContextLinkProps: {
url: string
label: string
}
#TimePreference: *"globalTime" | "last5Min" | "last15Min" | "last30Min" | "last1Hr" | "last6Hr" | "last1Day" | "last3Days" | "last1Week" | "last1Month"
#PrecisionOption: *2 | 0 | 1 | 3 | 4 | "full"
#Axes: {
softMin?: number | *null
softMax?: number | *null
isLogScale?: bool | *false
}
#LegendPosition: *"bottom" | "right"
#ThresholdWithLabel: {
value: number
unit?: string
color: string
format: "Text" | "Background"
label?: string
}
#ComparisonThreshold: {
value: number
operator: ">" | "<" | ">=" | "<=" | "="
unit?: string
color: string
format: "Text" | "Background"
}
// ──────────────────────────────────────────────
// Query types
// ──────────────────────────────────────────────
#QueryName: =~"^[A-Za-z][A-Za-z0-9_]*$"
#Limit: int & >=0 & <=10000
#Offset: int & >=0
#ReduceTo: "sum" | "count" | "avg" | "min" | "max" | "last" | "median"
#MetricAggregation: close({
metricName: string & !=""
timeAggregation: "latest" | "sum" | "avg" | "min" | "max" | "count" | "rate" | "increase"
spaceAggregation: "sum" | "avg" | "min" | "max" | "count" | "p50" | "p75" | "p90" | "p95" | "p99"
reduceTo?: #ReduceTo
temporality?: "delta" | "cumulative" | "unspecified"
})
#ExpressionAggregation: close({
expression: string & !=""
alias?: string
})
#Aggregation: #MetricAggregation | #ExpressionAggregation
#FilterExpression: close({
expression: string
})
#GroupByItem: close({
name: string & !=""
fieldDataType?: string
fieldContext?: string
})
#OrderByItem: close({
columnName: string & !=""
order: "asc" | "desc"
})
#HavingExpression: close({
expression: string
})
#Function: close({
name: "cutOffMin" | "cutOffMax" | "clampMin" | "clampMax" |
"absolute" | "runningDiff" | "log2" | "log10" |
"cumulativeSum" | "ewma3" | "ewma5" | "ewma7" |
"median3" | "median5" | "median7" | "timeShift" |
"anomaly" | "fillZero"
args?: [...close({value: number | string | bool})]
})
// ──────────────────────────────────────────────
// Variable types
// ──────────────────────────────────────────────
#VariableSortOrder: *"disabled" | "asc" | "desc"

View File

@@ -0,0 +1,4 @@
module: "github.com/signoz"
language: {
version: "v0.12.0"
}

2134
perses/examples/current.json Normal file

File diff suppressed because it is too large Load Diff

882
perses/examples/perses.json Normal file
View File

@@ -0,0 +1,882 @@
{
"kind": "Dashboard",
"metadata": {
"name": "the-everything-dashboard",
"project": "signoz"
},
"spec": {
"display": {
"name": "The everything dashboard",
"description": "Trying to cover as many concepts here as possible"
},
"duration": "1h",
"datasources": {
"SigNozDatasource": {
"default": true,
"plugin": {
"kind": "SigNozDatasource",
"spec": {}
}
}
},
"variables": [
{
"kind": "ListVariable",
"spec": {
"name": "serviceName",
"display": {
"name": "serviceName"
},
"allowAllValue": true,
"allowMultiple": false,
"plugin": {
"kind": "SigNozDynamicVariable",
"spec": {
"name": "service.name",
"source": "Metrics",
"sort": "disabled"
}
}
}
},
{
"kind": "ListVariable",
"spec": {
"name": "statusCodesFromQuery",
"display": {
"name": "statusCodesFromQuery"
},
"allowAllValue": true,
"allowMultiple": true,
"plugin": {
"kind": "SigNozQueryVariable",
"spec": {
"queryValue": "SELECT JSONExtractString(labels, 'http.status_code') AS status_code FROM signoz_metrics.distributed_time_series_v4_1day WHERE status_code != '' GROUP BY status_code",
"sort": "asc"
}
}
}
},
{
"kind": "ListVariable",
"spec": {
"name": "limit",
"display": {
"name": "limit"
},
"allowAllValue": false,
"allowMultiple": false,
"plugin": {
"kind": "SigNozCustomVariable",
"spec": {
"customValue": "1,10,20,40,80,160,200",
"sort": "disabled"
}
}
}
},
{
"kind": "TextVariable",
"spec": {
"name": "textboxvar",
"display": {
"name": "textboxvar"
},
"value": "defaultvaluegoeshere",
"plugin": {
"kind": "SigNozTextboxVariable",
"spec": {}
}
}
}
],
"panels": {
"24e2697b": {
"kind": "Panel",
"spec": {
"display": {
"name": "total resp size",
"description": ""
},
"plugin": {
"kind": "SigNozTimeSeriesPanel",
"spec": {
"visualization": {
"fillSpans": true
},
"formatting": {
"unit": "By",
"decimalPrecision": 3
},
"axes": {
"softMax": 800,
"isLogScale": true
},
"legend": {
"position": "right",
"customColors": {
"{service.name=\"sampleapp-gateway\"}": "#9ea5f7"
}
},
"contextLinks": [
{
"label": "View service details",
"url": "http://localhost:8080/{{_service.name}}?dfddf=%7B%7Blimit%7D%7D"
}
],
"thresholds": [
{
"value": 1024,
"unit": "By",
"color": "Red",
"format": "Text",
"label": "upper limit"
},
{
"value": 100,
"unit": "By",
"color": "Orange",
"format": "Text",
"label": "kinda bad"
}
]
}
},
"queries": [
{
"kind": "TimeSeriesQuery",
"spec": {
"plugin": {
"kind": "SigNozBuilderQuery",
"spec": {
"name": "A",
"signal": "metrics",
"expression": "A",
"aggregations": [
{
"metricName": "http.server.response.body.size.sum",
"reduceTo": "sum",
"spaceAggregation": "sum",
"timeAggregation": "rate"
}
],
"filter": {
"expression": "http.response.status_code IN $statusCodesFromQuery"
},
"groupBy": [
{
"name": "service.name",
"fieldDataType": "string",
"fieldContext": "tag"
}
]
}
}
}
}
]
}
},
"ff2f72f1": {
"kind": "Panel",
"spec": {
"display": {
"name": "fraction of calls",
"description": ""
},
"plugin": {
"kind": "SigNozTimeSeriesPanel",
"spec": {
"visualization": {
"fillSpans": true
},
"formatting": {
"decimalPrecision": 1
},
"thresholds": [
{
"value": 1,
"color": "Blue",
"format": "Background",
"label": "max possible"
}
]
}
},
"queries": [
{
"kind": "TimeSeriesQuery",
"spec": {
"plugin": {
"kind": "SigNozCompositeQuery",
"spec": {
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"expression": "A",
"disabled": true,
"aggregations": [
{
"metricName": "signoz_calls_total",
"reduceTo": "sum",
"spaceAggregation": "sum",
"timeAggregation": "rate"
}
],
"filter": {
"expression": "service.name IN $serviceName AND http.status_code IN $statusCodesFromQuery"
}
}
},
{
"type": "builder_query",
"spec": {
"name": "B",
"signal": "metrics",
"expression": "B",
"disabled": true,
"aggregations": [
{
"metricName": "signoz_calls_total",
"reduceTo": "sum",
"spaceAggregation": "sum",
"timeAggregation": "rate"
}
],
"filter": {
"expression": "service.name in $serviceName"
}
}
},
{
"type": "builder_formula",
"spec": {
"name": "F1",
"expression": "A / B"
}
}
]
}
}
}
}
]
}
},
"011605e7": {
"kind": "Panel",
"spec": {
"display": {
"name": "total resp size"
},
"plugin": {
"kind": "SigNozBarChartPanel",
"spec": {
"visualization": {
"stackedBarChart": false
},
"formatting": {
"unit": "By"
}
}
},
"queries": [
{
"kind": "TimeSeriesQuery",
"spec": {
"plugin": {
"kind": "SigNozBuilderQuery",
"spec": {
"name": "A",
"signal": "metrics",
"expression": "A",
"aggregations": [
{
"metricName": "http.server.response.body.size.sum",
"reduceTo": "sum",
"spaceAggregation": "sum",
"timeAggregation": "rate"
}
],
"filter": {
"expression": "http.response.status_code IN $statusCodesFromQuery"
},
"groupBy": [
{
"name": "service.name",
"fieldDataType": "string",
"fieldContext": "tag"
}
]
}
}
}
}
]
}
},
"e23516fc": {
"kind": "Panel",
"spec": {
"display": {
"name": "num traces for service"
},
"plugin": {
"kind": "SigNozNumberPanel",
"spec": {
"formatting": {
"unit": "none",
"decimalPrecision": 1
},
"thresholds": [
{
"value": 1200000,
"operator": ">",
"unit": "none",
"color": "Red",
"format": "Text"
},
{
"value": 1200000,
"operator": "<=",
"unit": "none",
"color": "Green",
"format": "Text"
}
]
}
},
"queries": [
{
"kind": "TimeSeriesQuery",
"spec": {
"plugin": {
"kind": "SigNozBuilderQuery",
"spec": {
"name": "A",
"signal": "traces",
"expression": "A",
"aggregations": [
{
"expression": "count() "
}
],
"filter": {
"expression": "service.name = $serviceName "
}
}
}
}
}
]
}
},
"130c8d6b": {
"kind": "Panel",
"spec": {
"display": {
"name": "num logs for service"
},
"plugin": {
"kind": "SigNozNumberPanel",
"spec": {
"formatting": {
"unit": "none",
"decimalPrecision": 1
}
}
},
"queries": [
{
"kind": "TimeSeriesQuery",
"spec": {
"plugin": {
"kind": "SigNozBuilderQuery",
"spec": {
"name": "A",
"signal": "logs",
"expression": "A",
"aggregations": [
{
"expression": "count() "
}
],
"filter": {
"expression": "service.name = $serviceName "
}
}
}
}
}
]
}
},
"246f7c6d": {
"kind": "Panel",
"spec": {
"display": {
"name": "num traces for service per resp code"
},
"plugin": {
"kind": "SigNozPieChartPanel",
"spec": {
"formatting": {
"decimalPrecision": 1
},
"legend": {
"customColors": {
"\"201\"": "#2bc051",
"\"400\"": "#cc462e",
"\"500\"": "#ff0000"
}
}
}
},
"queries": [
{
"kind": "TimeSeriesQuery",
"spec": {
"plugin": {
"kind": "SigNozBuilderQuery",
"spec": {
"name": "A",
"signal": "traces",
"expression": "A",
"aggregations": [
{
"expression": "count() "
}
],
"filter": {
"expression": "service.name = $serviceName isEntryPoint = 'true'"
},
"groupBy": [
{
"name": "http.response.status_code",
"fieldDataType": "float64",
"fieldContext": "tag"
}
],
"legend": "\"{{http.response.status_code}}\""
}
}
}
}
]
}
},
"21f7d4d0": {
"kind": "Panel",
"spec": {
"display": {
"name": "average latency per service"
},
"plugin": {
"kind": "SigNozTablePanel",
"spec": {
"formatting": {
"columnUnits": {
"A": "s"
}
},
"thresholds": [
{
"value": 1,
"operator": ">",
"unit": "min",
"color": "Red",
"format": "Text",
"tableOptions": "A"
}
]
}
},
"queries": [
{
"kind": "TimeSeriesQuery",
"spec": {
"plugin": {
"kind": "SigNozClickHouseSQL",
"spec": {
"name": "A",
"query": "WITH\n __spatial_aggregation_cte AS\n (\n SELECT\n toStartOfInterval(toDateTime(intDiv(unix_milli, 1000)), toIntervalSecond(60)) AS ts,\n `service.name`,\n le,\n sum(value) / 60 AS value\n FROM signoz_metrics.distributed_samples_v4 AS points\n INNER JOIN\n (\n SELECT\n fingerprint,\n JSONExtractString(labels, 'service.name') AS `service.name`,\n JSONExtractString(labels, 'le') AS le\n FROM signoz_metrics.time_series_v4\n WHERE (metric_name IN ('signoz_latency.bucket')) AND (LOWER(temporality) LIKE LOWER('delta')) AND (__normalized = 0)\n GROUP BY\n fingerprint,\n `service.name`,\n le\n ) AS filtered_time_series ON points.fingerprint = filtered_time_series.fingerprint\n WHERE metric_name IN ('signoz_latency.bucket')\n GROUP BY\n ts,\n `service.name`,\n le\n ),\n __histogramCTE AS\n (\n SELECT\n ts,\n `service.name`,\n histogramQuantile(arrayMap(x -> toFloat64(x), groupArray(le)), groupArray(value), 0.9) AS value\n FROM __spatial_aggregation_cte\n GROUP BY\n `service.name`,\n ts\n ORDER BY\n `service.name` ASC,\n ts ASC\n )\nSELECT\n `service.name` AS service,\n avg(value) AS A\nFROM __histogramCTE\nGROUP BY `service.name`"
}
}
}
}
]
}
},
"ad5fd556": {
"kind": "Panel",
"spec": {
"display": {
"name": "logs from service"
},
"plugin": {
"kind": "SigNozListPanel",
"spec": {
"selectedLogFields": [
{
"name": "timestamp",
"type": "log",
"dataType": ""
},
{
"name": "body",
"type": "log",
"dataType": ""
},
{
"name": "error",
"type": "",
"dataType": "string"
}
]
}
},
"queries": [
{
"kind": "LogQuery",
"spec": {
"plugin": {
"kind": "SigNozBuilderQuery",
"spec": {
"name": "A",
"signal": "logs",
"expression": "A",
"aggregations": [
{
"expression": "count() "
}
],
"filter": {
"expression": "service.name = $serviceName"
},
"groupBy": [],
"order": [
{
"columnName": "timestamp",
"order": "desc"
},
{
"columnName": "id",
"order": "desc"
}
]
}
}
}
}
]
}
},
"f07b59ee": {
"kind": "Panel",
"spec": {
"display": {
"name": "response size buckets"
},
"plugin": {
"kind": "SigNozHistogramPanel",
"spec": {
"histogramBuckets": {
"bucketCount": 60,
"mergeAllActiveQueries": true
}
}
},
"queries": [
{
"kind": "TimeSeriesQuery",
"spec": {
"plugin": {
"kind": "SigNozBuilderQuery",
"spec": {
"name": "A",
"signal": "metrics",
"expression": "A",
"aggregations": [
{
"metricName": "http.server.response.body.size.bucket",
"reduceTo": "avg",
"spaceAggregation": "p90",
"timeAggregation": "rate"
}
]
}
}
}
}
]
}
},
"e1a41831": {
"kind": "Panel",
"spec": {
"display": {
"name": "trace operator",
"description": ""
},
"plugin": {
"kind": "SigNozTimeSeriesPanel",
"spec": {
"legend": {
"position": "right"
}
}
},
"queries": [
{
"kind": "TimeSeriesQuery",
"spec": {
"plugin": {
"kind": "SigNozCompositeQuery",
"spec": {
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "traces",
"expression": "A",
"aggregations": [
{
"expression": "count() "
}
],
"filter": {
"expression": "service.name = 'sampleapp-gateway' "
},
"legend": "Gateway"
}
},
{
"type": "builder_query",
"spec": {
"name": "B",
"signal": "traces",
"expression": "B",
"aggregations": [
{
"expression": "count() "
}
],
"filter": {
"expression": "http.response.status_code = 200"
},
"legend": "$serviceName"
}
},
{
"type": "builder_trace_operator",
"spec": {
"name": "T1",
"expression": "A -> B ",
"aggregations": [
{
"expression": "count()",
"alias": "request_count"
}
]
}
}
]
}
}
}
}
]
}
},
"f0d70491": {
"kind": "Panel",
"spec": {
"display": {
"name": "no results in this promql",
"description": ""
},
"plugin": {
"kind": "SigNozTimeSeriesPanel",
"spec": {}
},
"queries": [
{
"kind": "TimeSeriesQuery",
"spec": {
"plugin": {
"kind": "SigNozCompositeQuery",
"spec": {
"queries": [
{
"type": "promql",
"spec": {
"name": "A",
"query": "sum(rate(flask_exporter_info[5m]))"
}
},
{
"type": "promql",
"spec": {
"name": "B",
"query": "sum(increase(flask_exporter_info[5m]))"
}
}
]
}
}
}
}
]
}
},
"0e6eb4ca": {
"kind": "Panel",
"spec": {
"display": {
"name": "no results in this promql",
"description": ""
},
"plugin": {
"kind": "SigNozTimeSeriesPanel",
"spec": {}
},
"queries": [
{
"kind": "TimeSeriesQuery",
"spec": {
"plugin": {
"kind": "SigNozPromQLQuery",
"spec": {
"name": "A",
"query": "sum(rate(flask_exporter_info[5m]))"
}
}
}
}
]
}
}
},
"layouts": [
{
"kind": "Grid",
"spec": {
"items": [
{
"x": 0,
"y": 0,
"width": 6,
"height": 6,
"content": {
"$ref": "#/spec/panels/24e2697b"
}
},
{
"x": 6,
"y": 0,
"width": 6,
"height": 6,
"content": {
"$ref": "#/spec/panels/ff2f72f1"
}
},
{
"x": 0,
"y": 6,
"width": 6,
"height": 6,
"content": {
"$ref": "#/spec/panels/011605e7"
}
},
{
"x": 6,
"y": 6,
"width": 6,
"height": 3,
"content": {
"$ref": "#/spec/panels/e23516fc"
}
},
{
"x": 6,
"y": 9,
"width": 6,
"height": 3,
"content": {
"$ref": "#/spec/panels/130c8d6b"
}
},
{
"x": 0,
"y": 12,
"width": 6,
"height": 6,
"content": {
"$ref": "#/spec/panels/246f7c6d"
}
},
{
"x": 6,
"y": 12,
"width": 6,
"height": 6,
"content": {
"$ref": "#/spec/panels/21f7d4d0"
}
},
{
"x": 0,
"y": 18,
"width": 6,
"height": 6,
"content": {
"$ref": "#/spec/panels/ad5fd556"
}
},
{
"x": 6,
"y": 18,
"width": 6,
"height": 6,
"content": {
"$ref": "#/spec/panels/f07b59ee"
}
},
{
"x": 0,
"y": 24,
"width": 12,
"height": 6,
"content": {
"$ref": "#/spec/panels/e1a41831"
}
},
{
"x": 0,
"y": 30,
"width": 6,
"height": 6,
"content": {
"$ref": "#/spec/panels/f0d70491"
}
},
{
"x": 6,
"y": 30,
"width": 6,
"height": 6,
"content": {
"$ref": "#/spec/panels/0e6eb4ca"
}
}
]
}
}
]
}
}

140
perses/howToDefineAPanel.md Normal file
View File

@@ -0,0 +1,140 @@
# Adding a new panel plugin
This guide is for developers adding a new panel kind to the Perses schema. It covers the file structure you need to create, the shared types available in `common.cue`, and how to compose them into your panel's spec.
---
## 1. File structure
Create a new directory under `schemas-wrapper/schemas/` named after your panel:
```
schemas-wrapper/schemas/your-panel-name/
├── your-panel-name.cue # Schema definition
└── example.json # Example JSON that validates against the schema
```
The CUE file must use `package model` and declare a `kind` and `spec`:
```cue
package model
import "github.com/signoz/common"
kind: "YourPanelName"
spec: close({
// your fields here
})
```
Register the panel in `schemas-wrapper/schemas/package.json` by adding an entry to the `plugins` array:
```json
{
"kind": "Panel",
"spec": {
"name": "YourPanelName"
}
}
```
---
## 2. Available shared types from `common.cue`
These types are defined in `common/common.cue` and can be used via `import "github.com/signoz/common"`. Use them instead of redefining the same structures in your panel.
## 3. Defining your spec
Your spec is a `close({})` block containing the fields relevant to your panel. Pick from the shared types above and add panel-specific types as needed. Every field should be optional (suffixed with `?`) since a panel should be valid with just defaults.
### Typical patterns
**Panel with graphs** (has axes, legend, thresholds as reference lines):
```cue
spec: close({
visualization?: #Visualization
formatting?: #Formatting
axes?: common.#Axes
legend?: #Legend
contextLinks?: [...common.#ContextLinkProps]
thresholds?: [...common.#ThresholdWithLabel]
})
```
**Panel with a single value** (no axes/legend, thresholds as conditional formatting):
```cue
spec: close({
visualization?: #Visualization
formatting?: #Formatting
contextLinks?: [...common.#ContextLinkProps]
thresholds?: [...common.#ComparisonThreshold]
})
```
**Panel with custom data structure** (panel-specific fields only):
```cue
spec: close({
yourCustomConfig?: #YourCustomConfig
contextLinks?: [...common.#ContextLinkProps]
})
```
### Defining panel-local types
Types that are specific to your panel (not reusable) should be defined in your CUE file, not in common. For example, `#Visualization` varies per panel because each panel has different rendering options:
```cue
#Visualization: {
timePreference?: common.#TimePreference
yourCustomFlag?: bool | *false
}
```
If your panel needs a threshold type that extends a common one, embed it:
```cue
#YourThreshold: {
common.#ComparisonThreshold
extraField: string
}
```
---
## 4. Writing the example JSON
Create an `example.json` that exercises all fields in your spec. This file is used for validation — `./validate.sh` will check it against your schema.
```json
{
"kind": "YourPanelName",
"spec": {
...
}
}
```
Also add at least one panel using your new kind to `examples/perses.json` so it gets validated as part of a full dashboard.
---
## 5. Validation
Run `./validate.sh` from the `perses/` directory. It lints `examples/perses.json` against all registered schemas. If your schema or example has issues, the error will point to the specific field.
---
## Quick reference: existing panels and what they use
| Field | Time-series | Bar-chart | Number | Pie | Table | Histogram | List |
|-------|:-----------:|:---------:|:------:|:---:|:-----:|:---------:|:----:|
| `visualization` | yes | yes | yes | yes | yes | — | — |
| `formatting` | yes | yes | yes | yes | yes | — | — |
| `axes` | yes | yes | — | — | — | — | — |
| `legend` | yes | yes | — | yes | — | yes | — |
| `contextLinks` | yes | yes | yes | yes | yes | yes | — |
| `thresholds` | yes | yes | yes | — | yes | — | — |

View File

@@ -0,0 +1,9 @@
{
"id": "signoz",
"name": "signoz",
"metaData": {
"buildInfo": {
"buildVersion": "0.0.1"
}
}
}

View File

@@ -0,0 +1,136 @@
{
"name": "@signoz/signoz-schemas",
"version": "0.0.1",
"description": "SigNoz schema plugins",
"perses": {
"schemasPath": ".",
"plugins": [
{
"kind": "Datasource",
"spec": {
"name": "SigNozDatasource"
}
},
{
"kind": "Panel",
"spec": {
"name": "SigNozTimeSeriesPanel"
}
},
{
"kind": "Panel",
"spec": {
"name": "SigNozBarChartPanel"
}
},
{
"kind": "Panel",
"spec": {
"name": "SigNozNumberPanel"
}
},
{
"kind": "Panel",
"spec": {
"name": "SigNozPieChartPanel"
}
},
{
"kind": "Panel",
"spec": {
"name": "SigNozTablePanel"
}
},
{
"kind": "Panel",
"spec": {
"name": "SigNozHistogramPanel"
}
},
{
"kind": "Panel",
"spec": {
"name": "SigNozListPanel"
}
},
{
"kind": "Panel",
"spec": {
"name": "SigNozIgnoredFieldsChart"
}
},
{
"kind": "TimeSeriesQuery",
"spec": {
"name": "SigNozBuilderQuery"
}
},
{
"kind": "TimeSeriesQuery",
"spec": {
"name": "SigNozClickHouseSQL"
}
},
{
"kind": "TimeSeriesQuery",
"spec": {
"name": "SigNozPromQLQuery"
}
},
{
"kind": "TimeSeriesQuery",
"spec": {
"name": "SigNozCompositeQuery"
}
},
{
"kind": "TimeSeriesQuery",
"spec": {
"name": "SigNozTraceOperator"
}
},
{
"kind": "TimeSeriesQuery",
"spec": {
"name": "SigNozFormula"
}
},
{
"kind": "LogQuery",
"spec": {
"name": "SigNozBuilderQuery"
}
},
{
"kind": "TraceQuery",
"spec": {
"name": "SigNozBuilderQuery"
}
},
{
"kind": "Variable",
"spec": {
"name": "SigNozCustomVariable"
}
},
{
"kind": "Variable",
"spec": {
"name": "SigNozDynamicVariable"
}
},
{
"kind": "Variable",
"spec": {
"name": "SigNozQueryVariable"
}
},
{
"kind": "Variable",
"spec": {
"name": "SigNozTextboxVariable"
}
}
]
}
}

View File

@@ -0,0 +1,40 @@
{
"kind": "SigNozBarChartPanel",
"spec": {
"visualization": {
"timePreference": "globalTime",
"fillSpans": false,
"stackedBarChart": false
},
"formatting": {
"unit": "By",
"decimalPrecision": 2
},
"axes": {
"softMin": 0,
"softMax": 1000,
"isLogScale": false
},
"legend": {
"position": "bottom",
"customColors": {
"series-A": "#9ea5f7"
}
},
"contextLinks": [
{
"label": "View details",
"url": "http://localhost:8080/{{_service.name}}"
}
],
"thresholds": [
{
"value": 500,
"unit": "By",
"color": "Red",
"format": "Text",
"label": "upper limit"
}
]
}
}

View File

@@ -0,0 +1,29 @@
package model
import "github.com/signoz/common"
kind: "SigNozBarChartPanel"
spec: close({
visualization?: #Visualization
formatting?: #Formatting
axes?: common.#Axes
legend?: #Legend
contextLinks?: [...common.#ContextLinkProps]
thresholds?: [...common.#ThresholdWithLabel]
})
#Visualization: {
timePreference?: common.#TimePreference
fillSpans?: bool | *false
stackedBarChart?: bool | *true
}
#Formatting: {
unit?: string | *""
decimalPrecision?: common.#PrecisionOption
}
#Legend: {
position?: common.#LegendPosition
customColors?: [string]: string
}

View File

@@ -0,0 +1,35 @@
package model
import "github.com/signoz/common"
// Source: pkg/types/querybuildertypes/querybuildertypesv5/builder_query.go — QueryBuilderQuery
kind: "SigNozBuilderQuery"
spec: close({
name: common.#QueryName
signal: "metrics" | "logs" | "traces"
expression: string
disabled?: bool | *false
aggregations?: [...common.#Aggregation]
filter?: common.#FilterExpression
groupBy?: [...common.#GroupByItem]
order?: [...common.#OrderByItem]
selectFields?: [...common.#TelemetryFieldKey]
limit?: common.#Limit
limitBy?: #LimitBy
offset?: common.#Offset
cursor?: string
having?: common.#HavingExpression
// secondaryAggregations not added — not yet implemented.
functions?: [...common.#Function]
legend?: string
stepInterval?: number
reduceTo?: common.#ReduceTo
pageSize?: int & >=1
source?: string
})
#LimitBy: close({
keys: [...string]
value: string
})

View File

@@ -0,0 +1,24 @@
{
"kind": "SigNozBuilderQuery",
"spec": {
"name": "A",
"signal": "metrics",
"expression": "A",
"aggregations": [
{
"metricName": "redis_keyspace_hits",
"timeAggregation": "rate",
"spaceAggregation": "sum",
"reduceTo": "sum"
}
],
"filter": {
"expression": "host_name IN $host_name"
},
"groupBy": [],
"order": [],
"disabled": false,
"legend": "Hit/s across all hosts",
"stepInterval": 60
}
}

View File

@@ -0,0 +1,12 @@
package model
import "github.com/signoz/common"
// Source: pkg/types/querybuildertypes/querybuildertypesv5/clickhouse_query.go — ClickHouseQuery
kind: "SigNozClickHouseSQL"
spec: close({
name: common.#QueryName
query: string & !=""
disabled?: bool | *false
legend?: string
})

View File

@@ -0,0 +1,9 @@
{
"kind": "SigNozClickHouseSQL",
"spec": {
"name": "A",
"query": "SELECT toStartOfInterval(timestamp, INTERVAL 1 MINUTE) AS ts, count() AS total FROM signoz_logs.distributed_logs GROUP BY ts ORDER BY ts",
"disabled": false,
"legend": "Log count"
}
}

View File

@@ -0,0 +1,45 @@
package model
import (
bq "github.com/signoz/schemas-wrapper/schemas/signoz-builder-query:model"
f "github.com/signoz/schemas-wrapper/schemas/signoz-formula:model"
to "github.com/signoz/schemas-wrapper/schemas/signoz-trace-operator:model"
pql "github.com/signoz/schemas-wrapper/schemas/signoz-promql:model"
ch "github.com/signoz/schemas-wrapper/schemas/signoz-clickhouse-sql:model"
)
// Source: pkg/types/querybuildertypes/querybuildertypesv5/req.go — CompositeQuery
// SigNozCompositeQuery groups multiple query plugins into a single
// query request. Each entry is a typed envelope whose spec is
// validated by the corresponding plugin schema.
// this is to be used when there are multiple queries in a panel
// in most cases, there will be only one query, and there it is a better idea to
// use the corresponding kind for that query instead of this composite query
kind: "SigNozCompositeQuery"
spec: close({
queries: [...#QueryEnvelope]
})
// QueryEnvelope wraps a single query plugin with a type discriminator.
#QueryEnvelope:
close({
type: "builder_query",
spec: bq.spec
}) |
close({
type: "builder_formula",
spec: f.spec
}) |
close({
type: "builder_trace_operator",
spec: to.spec
}) |
close({
type: "promql",
spec: pql.spec
}) |
close({
type: "clickhouse_sql",
spec: ch.spec
})

View File

@@ -0,0 +1,53 @@
{
"kind": "SigNozCompositeQuery",
"spec": {
"queries": [
{
"type": "builder_query",
"spec": {
"name": "A",
"signal": "metrics",
"expression": "A",
"aggregations": [
{
"metricName": "redis_keyspace_hits",
"timeAggregation": "rate",
"spaceAggregation": "sum",
"reduceTo": "sum"
}
],
"filter": {
"expression": "host_name IN $host_name"
}
}
},
{
"type": "builder_query",
"spec": {
"name": "B",
"signal": "metrics",
"expression": "B",
"aggregations": [
{
"metricName": "redis_keyspace_misses",
"timeAggregation": "rate",
"spaceAggregation": "sum",
"reduceTo": "sum"
}
],
"filter": {
"expression": "host_name IN $host_name"
}
}
},
{
"type": "builder_formula",
"spec": {
"name": "F1",
"expression": "A / (A + B) * 100",
"legend": "Hit rate %"
}
}
]
}
}

View File

@@ -0,0 +1,10 @@
package model
import "github.com/signoz/common"
// defaultValue lives on the Perses ListVariable wrapper (spec level).
kind: "SigNozCustomVariable"
spec: close({
customValue: =~"^[^,]+(,[^,]+)*$"
sort?: common.#VariableSortOrder
})

View File

@@ -0,0 +1,7 @@
{
"kind": "SigNozCustomVariable",
"spec": {
"customValue": "production,staging,development",
"sort": "disabled"
}
}

View File

@@ -0,0 +1,9 @@
package model
kind: "SigNozDatasource"
// SigNoz has a single built-in backend — the frontend already knows
// the API endpoint, so there is no connection config to validate.
// Add fields here if SigNoz ever supports multiple backends or
// configurable API versions.
spec: close({})

View File

@@ -0,0 +1,4 @@
{
"kind": "SigNozDatasource",
"spec": {}
}

View File

@@ -0,0 +1,11 @@
package model
import "github.com/signoz/common"
// defaultValue lives on the Perses ListVariable wrapper (spec level).
kind: "SigNozDynamicVariable"
spec: close({
name: string
source: string
sort?: common.#VariableSortOrder
})

View File

@@ -0,0 +1,8 @@
{
"kind": "SigNozDynamicVariable",
"spec": {
"name": "host_name",
"source": "metrics",
"sort": "asc"
}
}

View File

@@ -0,0 +1,25 @@
package model
// Fields from IBaseWidget that are NOT in any dedicated panel CUE file.
// This file exists as a reference only and will be removed in the end.
// Needs to be removed from package.json as well.
kind: "SigNozIgnoredFieldsChart"
spec: close({
opacity?: string // not wired to chart rendering
nullZeroValues?: string // not wired to chart rendering
stepSize?: number
columnWidths?: [string]: number // not a config choice — persisted user-resized column widths
// "Chart Appearance" section in UI — could not find this section in the app.
// These 4 fields are gated behind panelTypeVs* constants (TIME_SERIES only).
lineInterpolation?: #LineInterpolation
showPoints?: bool
lineStyle?: #LineStyle
fillMode?: #FillMode
})
#LineInterpolation: "linear" | "spline" | "stepAfter" | "stepBefore"
#LineStyle: "solid" | "dashed"
#FillMode: "solid" | "gradient" | "none"

View File

@@ -0,0 +1,17 @@
package model
import "github.com/signoz/common"
// Source: pkg/types/querybuildertypes/querybuildertypesv5/formula.go — QueryBuilderFormula
kind: "SigNozFormula"
spec: close({
name: common.#QueryName
expression: string
disabled?: bool | *false
legend?: string
limit?: common.#Limit
having?: common.#HavingExpression
stepInterval?: number
order?: [...common.#OrderByItem]
functions?: [...common.#Function]
})

View File

@@ -0,0 +1,8 @@
{
"kind": "SigNozFormula",
"spec": {
"name": "F1",
"expression": "A / B * 100",
"legend": "Hit rate %"
}
}

View File

@@ -0,0 +1,21 @@
{
"kind": "SigNozHistogramPanel",
"spec": {
"histogramBuckets": {
"bucketCount": 60,
"bucketWidth": 100,
"mergeAllActiveQueries": true
},
"legend": {
"customColors": {
"series-A": "#9ea5f7"
}
},
"contextLinks": [
{
"label": "View histogram details",
"url": "http://localhost:8080/histogram"
}
]
}
}

View File

@@ -0,0 +1,20 @@
package model
import "github.com/signoz/common"
kind: "SigNozHistogramPanel"
spec: close({
histogramBuckets?: #HistogramBuckets
legend?: #Legend
contextLinks?: [...common.#ContextLinkProps]
})
#HistogramBuckets: {
bucketCount?: number | *30
bucketWidth?: number | *0
mergeAllActiveQueries?: bool | *false
}
#Legend: {
customColors?: [string]: string
}

View File

@@ -0,0 +1,54 @@
{
"kind": "SigNozListPanel",
"spec": {
"selectedLogFields": [
{
"name": "timestamp",
"type": "log",
"dataType": ""
},
{
"name": "body",
"type": "log",
"dataType": ""
},
{
"name": "error",
"type": "",
"dataType": "string"
}
],
"selectedTracesFields": [
{
"name": "service.name",
"signal": "traces",
"fieldContext": "resource",
"fieldDataType": "string"
},
{
"name": "name",
"signal": "traces",
"fieldContext": "span",
"fieldDataType": "string"
},
{
"name": "duration_nano",
"signal": "traces",
"fieldContext": "span",
"fieldDataType": ""
},
{
"name": "http_method",
"signal": "traces",
"fieldContext": "span",
"fieldDataType": ""
},
{
"name": "response_status_code",
"signal": "traces",
"fieldContext": "span",
"fieldDataType": ""
}
]
}
}

View File

@@ -0,0 +1,15 @@
package model
import "github.com/signoz/common"
kind: "SigNozListPanel"
spec: close({
selectedLogFields?: [...#LogField]
selectedTracesFields?: [...common.#TelemetryFieldKey]
})
#LogField: {
name: string
type: string
dataType: string
}

View File

@@ -0,0 +1,34 @@
{
"kind": "SigNozNumberPanel",
"spec": {
"visualization": {
"timePreference": "globalTime"
},
"formatting": {
"unit": "none",
"decimalPrecision": 1
},
"contextLinks": [
{
"label": "View details",
"url": "http://localhost:8080/details"
}
],
"thresholds": [
{
"value": 1200000,
"operator": ">",
"unit": "none",
"color": "Red",
"format": "Text"
},
{
"value": 1200000,
"operator": "<=",
"unit": "none",
"color": "Green",
"format": "Text"
}
]
}
}

View File

@@ -0,0 +1,20 @@
package model
import "github.com/signoz/common"
kind: "SigNozNumberPanel"
spec: close({
visualization?: #Visualization
formatting?: #Formatting
contextLinks?: [...common.#ContextLinkProps]
thresholds?: [...common.#ComparisonThreshold]
})
#Visualization: {
timePreference?: common.#TimePreference
}
#Formatting: {
unit?: string | *""
decimalPrecision?: common.#PrecisionOption
}

View File

@@ -0,0 +1,25 @@
{
"kind": "SigNozPieChartPanel",
"spec": {
"visualization": {
"timePreference": "globalTime"
},
"formatting": {
"unit": "none",
"decimalPrecision": 1
},
"legend": {
"customColors": {
"\"201\"": "#2bc051",
"\"400\"": "#cc462e",
"\"500\"": "#ff0000"
}
},
"contextLinks": [
{
"label": "View breakdown",
"url": "http://localhost:8080/breakdown"
}
]
}
}

View File

@@ -0,0 +1,24 @@
package model
import "github.com/signoz/common"
kind: "SigNozPieChartPanel"
spec: close({
visualization?: #Visualization
formatting?: #Formatting
legend?: #Legend
contextLinks?: [...common.#ContextLinkProps]
})
#Visualization: {
timePreference?: common.#TimePreference
}
#Formatting: {
unit?: string | *""
decimalPrecision?: common.#PrecisionOption
}
#Legend: {
customColors?: [string]: string
}

View File

@@ -0,0 +1,14 @@
package model
import "github.com/signoz/common"
// Source: pkg/types/querybuildertypes/querybuildertypesv5/prom_query.go — PromQuery
kind: "SigNozPromQLQuery"
spec: close({
name: common.#QueryName
query: string & !=""
disabled?: bool | *false
step?: =~"^([0-9]+(\\.[0-9]+)?(ns|us|µs|ms|s|m|h))+$" | number
stats?: bool
legend?: string
})

View File

@@ -0,0 +1,9 @@
{
"kind": "SigNozPromQLQuery",
"spec": {
"name": "A",
"query": "rate(http_requests_total{status=\"200\"}[5m])",
"disabled": false,
"legend": "{{method}} {{path}}"
}
}

View File

@@ -0,0 +1,10 @@
package model
import "github.com/signoz/common"
// defaultValue lives on the Perses ListVariable wrapper (spec level).
kind: "SigNozQueryVariable"
spec: close({
queryValue: string
sort?: common.#VariableSortOrder
})

View File

@@ -0,0 +1,7 @@
{
"kind": "SigNozQueryVariable",
"spec": {
"queryValue": "SELECT DISTINCT host_name FROM signoz_metrics.distributed_time_series_v4_1day WHERE metric_name = 'redis_cpu_time'",
"sort": "asc"
}
}

View File

@@ -0,0 +1,30 @@
{
"kind": "SigNozTablePanel",
"spec": {
"visualization": {
"timePreference": "globalTime"
},
"formatting": {
"columnUnits": {
"A": "s"
},
"decimalPrecision": 2
},
"contextLinks": [
{
"label": "View trace",
"url": "http://localhost:8080/trace/{{traceID}}"
}
],
"thresholds": [
{
"value": 1,
"operator": ">",
"unit": "min",
"color": "Red",
"format": "Text",
"tableOptions": "A"
}
]
}
}

View File

@@ -0,0 +1,25 @@
package model
import "github.com/signoz/common"
kind: "SigNozTablePanel"
spec: close({
visualization?: #Visualization
formatting?: #Formatting
contextLinks?: [...common.#ContextLinkProps]
thresholds?: [...#TableThreshold]
})
#Visualization: {
timePreference?: common.#TimePreference
}
#Formatting: {
columnUnits?: [string]: string
decimalPrecision?: common.#PrecisionOption
}
#TableThreshold: {
common.#ComparisonThreshold
tableOptions: string
}

View File

@@ -0,0 +1,5 @@
package model
// defaultValue lives on the Perses ListVariable wrapper (spec level).
kind: "SigNozTextboxVariable"
spec: close({})

View File

@@ -0,0 +1,4 @@
{
"kind": "SigNozTextboxVariable",
"spec": {}
}

View File

@@ -0,0 +1,46 @@
{
"kind": "SigNozTimeSeriesPanel",
"spec": {
"visualization": {
"timePreference": "globalTime",
"fillSpans": true
},
"formatting": {
"unit": "By",
"decimalPrecision": 3
},
"axes": {
"softMin": 0,
"softMax": 800,
"isLogScale": true
},
"legend": {
"position": "right",
"customColors": {
"{service.name=\"sampleapp-gateway\"}": "#9ea5f7"
}
},
"contextLinks": [
{
"label": "View service details",
"url": "http://localhost:8080/{{_service.name}}?dfddf=%7B%7Blimit%7D%7D"
}
],
"thresholds": [
{
"value": 1024,
"unit": "By",
"color": "Red",
"format": "Text",
"label": "upper limit"
},
{
"value": 100,
"unit": "By",
"color": "Orange",
"format": "Text",
"label": "kinda bad"
}
]
}
}

View File

@@ -0,0 +1,28 @@
package model
import "github.com/signoz/common"
kind: "SigNozTimeSeriesPanel"
spec: close({
visualization?: #Visualization
formatting?: #Formatting
axes?: common.#Axes
legend?: #Legend
contextLinks?: [...common.#ContextLinkProps]
thresholds?: [...common.#ThresholdWithLabel]
})
#Visualization: {
timePreference?: common.#TimePreference
fillSpans?: bool | *false
}
#Formatting: {
unit?: string | *""
decimalPrecision?: common.#PrecisionOption
}
#Legend: {
position?: common.#LegendPosition
customColors?: [string]: string
}

View File

@@ -0,0 +1,31 @@
package model
import "github.com/signoz/common"
// Source: pkg/types/querybuildertypes/querybuildertypesv5/trace_operator.go — QueryBuilderTraceOperator
// SigNozTraceOperator composes multiple trace BuilderQueries using
// relational operators (=>, ->, &&, ||, NOT) to query trace relationships.
// Signal is implicitly "traces" — all referenced queries must be trace queries.
kind: "SigNozTraceOperator"
spec: close({
name: common.#QueryName
// Operator expression composing trace queries, e.g. "A => B && C".
expression: string & !=""
disabled?: bool | *false
// Which query's spans to return (must be a query referenced in expression).
returnSpansFrom?: common.#QueryName
aggregations?: [...common.#ExpressionAggregation]
filter?: common.#FilterExpression
groupBy?: [...common.#GroupByItem]
order?: [...common.#OrderByItem]
limit?: common.#Limit
offset?: common.#Offset
cursor?: string
functions?: [...common.#Function]
stepInterval?: number
having?: common.#HavingExpression
legend?: string
selectFields?: [...common.#TelemetryFieldKey]
})

View File

@@ -0,0 +1,19 @@
{
"kind": "SigNozTraceOperator",
"spec": {
"name": "T1",
"expression": "A => B",
"returnSpansFrom": "A",
"aggregations": [
{
"expression": "count()",
"alias": "request_count"
}
],
"filter": {
"expression": "service.name = 'frontend'"
},
"groupBy": [],
"order": []
}
}

2
perses/validate.sh Executable file
View File

@@ -0,0 +1,2 @@
percli lint -f ./examples/perses.json --plugin.path ./schemas-wrapper
rm ./schemas-wrapper/plugin-modules.json

View File

@@ -3,6 +3,7 @@ package prometheus
import (
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
)
@@ -20,6 +21,9 @@ type Config struct {
//
// If not set, the prometheus default is used (currently 5m).
LookbackDelta time.Duration `mapstructure:"lookback_delta"`
// Timeout is the maximum time a query is allowed to run before being aborted.
Timeout time.Duration `mapstructure:"timeout"`
}
func NewConfigFactory() factory.ConfigFactory {
@@ -33,10 +37,14 @@ func newConfig() factory.Config {
Path: "",
MaxConcurrent: 20,
},
Timeout: 2 * time.Minute,
}
}
func (c Config) Validate() error {
if c.Timeout <= 0 {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "prometheus::timeout must be greater than 0")
}
return nil
}

View File

@@ -1,3 +0,0 @@
package prometheus
const FingerprintAsPromLabelName = "fingerprint"

View File

@@ -2,7 +2,6 @@ package prometheus
import (
"log/slog"
"time"
"github.com/prometheus/prometheus/promql"
)
@@ -21,7 +20,7 @@ func NewEngine(logger *slog.Logger, cfg Config) *Engine {
Logger: logger,
Reg: nil,
MaxSamples: 5_0000_000,
Timeout: 2 * time.Minute,
Timeout: cfg.Timeout,
ActiveQueryTracker: activeQueryTracker,
LookbackDelta: cfg.LookbackDelta,
})

View File

@@ -6,6 +6,8 @@ import (
"github.com/prometheus/prometheus/promql"
)
const FingerprintAsPromLabelName string = "fingerprint"
func RemoveExtraLabels(res *promql.Result, labelsToRemove ...string) error {
if len(labelsToRemove) == 0 || res == nil {
return nil

View File

@@ -36,6 +36,28 @@ var unquotedDottedNamePattern = regexp.MustCompile(`(?:^|[{,(\s])([a-zA-Z_][a-zA
// This is a common mistake when migrating to UTF-8 syntax.
var quotedMetricOutsideBracesPattern = regexp.MustCompile(`"([^"]+)"\s*\{`)
// tryEnhancePromQLExecError attempts to convert a PromQL execution error into
// a properly typed error. Returns nil if the error is not a recognized execution error.
func tryEnhancePromQLExecError(execErr error) error {
var eqc promql.ErrQueryCanceled
var eqt promql.ErrQueryTimeout
var es promql.ErrStorage
switch {
case errors.As(execErr, &eqc):
return errors.Newf(errors.TypeCanceled, errors.CodeCanceled, "query canceled").WithAdditional(eqc.Error())
case errors.As(execErr, &eqt):
return errors.Newf(errors.TypeTimeout, errors.CodeTimeout, "query timed out").WithAdditional(eqt.Error())
case errors.Is(execErr, context.DeadlineExceeded):
return errors.Newf(errors.TypeTimeout, errors.CodeTimeout, "query timed out")
case errors.Is(execErr, context.Canceled):
return errors.Newf(errors.TypeCanceled, errors.CodeCanceled, "query canceled")
case errors.As(execErr, &es):
return errors.Newf(errors.TypeInternal, errors.CodeInternal, "query execution error: %v", execErr)
default:
return nil
}
}
// enhancePromQLError adds helpful context to PromQL parse errors,
// particularly for UTF-8 syntax migration issues where metric and label
// names containing dots need to be quoted.
@@ -213,27 +235,20 @@ func (q *promqlQuery) Execute(ctx context.Context) (*qbv5.Result, error) {
time.Unix(0, end),
q.query.Step.Duration,
)
if err != nil {
// NewRangeQuery can fail with execution errors (e.g. context deadline exceeded)
// during the query queue/scheduling stage, not just parse errors.
if err := tryEnhancePromQLExecError(err); err != nil {
return nil, err
}
return nil, enhancePromQLError(query, err)
}
res := qry.Exec(ctx)
if res.Err != nil {
var eqc promql.ErrQueryCanceled
var eqt promql.ErrQueryTimeout
var es promql.ErrStorage
switch {
case errors.As(res.Err, &eqc):
return nil, errors.Newf(errors.TypeCanceled, errors.CodeCanceled, "query canceled")
case errors.As(res.Err, &eqt):
return nil, errors.Newf(errors.TypeTimeout, errors.CodeTimeout, "query timeout")
case errors.As(res.Err, &es):
return nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "query execution error: %v", res.Err)
}
if errors.Is(res.Err, context.Canceled) {
return nil, errors.Newf(errors.TypeCanceled, errors.CodeCanceled, "query canceled")
if err := tryEnhancePromQLExecError(res.Err); err != nil {
return nil, err
}
return nil, errors.Newf(errors.TypeInternal, errors.CodeInternal, "query execution error: %v", res.Err)

View File

@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"maps"
"slices"
"strings"
@@ -12,6 +13,7 @@ import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/query-service/agentConf"
"github.com/SigNoz/signoz/pkg/query-service/constants"
"github.com/SigNoz/signoz/pkg/query-service/interfaces"
"github.com/SigNoz/signoz/pkg/query-service/model"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/query-service/utils"
@@ -34,19 +36,101 @@ type LogParsingPipelineController struct {
Repo
GetIntegrationPipelines func(context.Context, string) ([]pipelinetypes.GettablePipeline, error)
// TODO(Piyush): remove with qbv5 migration
reader interfaces.Reader
}
func NewLogParsingPipelinesController(
sqlStore sqlstore.SQLStore,
getIntegrationPipelines func(context.Context, string) ([]pipelinetypes.GettablePipeline, error),
reader interfaces.Reader,
) (*LogParsingPipelineController, error) {
repo := NewRepo(sqlStore)
return &LogParsingPipelineController{
Repo: repo,
GetIntegrationPipelines: getIntegrationPipelines,
reader: reader,
}, nil
}
// enrichPipelinesFilters resolves the type (tag vs resource) for filter keys that are
// missing type info, by looking them up in the store.
//
// TODO(Piyush): remove with qbv5 migration
func (pc *LogParsingPipelineController) enrichPipelinesFilters(
ctx context.Context, pipelines []pipelinetypes.GettablePipeline,
) ([]pipelinetypes.GettablePipeline, error) {
// Collect names of non-static keys that are missing type info.
// Static fields (body, trace_id, etc.) are intentionally Unspecified and map
// to top-level OTEL fields — they do not need enrichment.
unspecifiedNames := map[string]struct{}{}
for _, p := range pipelines {
if p.Filter != nil {
for _, item := range p.Filter.Items {
if item.Key.Type == v3.AttributeKeyTypeUnspecified {
// Skip static fields
if _, isStatic := constants.StaticFieldsLogsV3[item.Key.Key]; isStatic {
continue
}
// Skip enrich body.* fields
if strings.HasPrefix(item.Key.Key, "body.") {
continue
}
unspecifiedNames[item.Key.Key] = struct{}{}
}
}
}
}
if len(unspecifiedNames) == 0 {
return pipelines, nil
}
logFields, apiErr := pc.reader.GetLogFieldsFromNames(ctx, slices.Collect(maps.Keys(unspecifiedNames)))
if apiErr != nil {
slog.ErrorContext(ctx, "failed to fetch log fields for pipeline filter enrichment", "error", apiErr)
return pipelines, apiErr
}
// Build a simple name → AttributeKeyType map from the response.
fieldTypes := map[string]v3.AttributeKeyType{}
for _, f := range append(logFields.Selected, logFields.Interesting...) {
switch f.Type {
case constants.Resources:
fieldTypes[f.Name] = v3.AttributeKeyTypeResource
case constants.Attributes:
fieldTypes[f.Name] = v3.AttributeKeyTypeTag
}
}
// Set the resolved type on each untyped filter key in-place.
for i := range pipelines {
if pipelines[i].Filter != nil {
for j := range pipelines[i].Filter.Items {
key := &pipelines[i].Filter.Items[j].Key
if key.Type == v3.AttributeKeyTypeUnspecified {
// Skip static fields
if _, isStatic := constants.StaticFieldsLogsV3[key.Key]; isStatic {
continue
}
// Skip enrich body.* fields
if strings.HasPrefix(key.Key, "body.") {
continue
}
if t, ok := fieldTypes[key.Key]; ok {
key.Type = t
} else {
// default to attribute
key.Type = v3.AttributeKeyTypeTag
}
}
}
}
}
return pipelines, nil
}
// PipelinesResponse is used to prepare http response for pipelines config related requests
type PipelinesResponse struct {
*opamptypes.AgentConfigVersion
@@ -256,7 +340,12 @@ func (ic *LogParsingPipelineController) PreviewLogsPipelines(
ctx context.Context,
request *PipelinesPreviewRequest,
) (*PipelinesPreviewResponse, error) {
result, collectorLogs, err := SimulatePipelinesProcessing(ctx, request.Pipelines, request.Logs)
pipelines, err := ic.enrichPipelinesFilters(ctx, request.Pipelines)
if err != nil {
return nil, err
}
result, collectorLogs, err := SimulatePipelinesProcessing(ctx, pipelines, request.Logs)
if err != nil {
return nil, err
}
@@ -293,10 +382,8 @@ func (pc *LogParsingPipelineController) RecommendAgentConfig(
if configVersion != nil {
pipelinesVersion = configVersion.Version
}
pipelinesResp, err := pc.GetPipelinesByVersion(
context.Background(), orgId, pipelinesVersion,
)
ctx := context.Background()
pipelinesResp, err := pc.GetPipelinesByVersion(ctx, orgId, pipelinesVersion)
if err != nil {
return nil, "", err
}
@@ -306,12 +393,17 @@ func (pc *LogParsingPipelineController) RecommendAgentConfig(
return nil, "", errors.WrapInternalf(err, CodeRawPipelinesMarshalFailed, "could not serialize pipelines to JSON")
}
if querybuilder.BodyJSONQueryEnabled {
// add default normalize pipeline at the beginning, only for sending to collector
pipelinesResp.Pipelines = append([]pipelinetypes.GettablePipeline{pc.getNormalizePipeline()}, pipelinesResp.Pipelines...)
enrichedPipelines, err := pc.enrichPipelinesFilters(ctx, pipelinesResp.Pipelines)
if err != nil {
return nil, "", err
}
updatedConf, err := GenerateCollectorConfigWithPipelines(currentConfYaml, pipelinesResp.Pipelines)
if querybuilder.BodyJSONQueryEnabled {
// add default normalize pipeline at the beginning, only for sending to collector
enrichedPipelines = append([]pipelinetypes.GettablePipeline{pc.getNormalizePipeline()}, enrichedPipelines...)
}
updatedConf, err := GenerateCollectorConfigWithPipelines(currentConfYaml, enrichedPipelines)
if err != nil {
return nil, "", err
}

View File

@@ -1407,7 +1407,7 @@ func Test_querier_Traces_runWindowBasedListQueryDesc(t *testing.T) {
slog.Default(),
nil,
telemetryStore,
prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{}, telemetryStore),
prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{Timeout: 2 * time.Minute}, telemetryStore),
"",
time.Duration(time.Second),
nil,
@@ -1633,7 +1633,7 @@ func Test_querier_Traces_runWindowBasedListQueryAsc(t *testing.T) {
slog.Default(),
nil,
telemetryStore,
prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{}, telemetryStore),
prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{Timeout: 2 * time.Minute}, telemetryStore),
"",
time.Duration(time.Second),
nil,
@@ -1934,7 +1934,7 @@ func Test_querier_Logs_runWindowBasedListQueryDesc(t *testing.T) {
slog.Default(),
nil,
telemetryStore,
prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{}, telemetryStore),
prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{Timeout: 2 * time.Minute}, telemetryStore),
"",
time.Duration(time.Second),
nil,
@@ -2162,7 +2162,7 @@ func Test_querier_Logs_runWindowBasedListQueryAsc(t *testing.T) {
slog.Default(),
nil,
telemetryStore,
prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{}, telemetryStore),
prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{Timeout: 2 * time.Minute}, telemetryStore),
"",
time.Duration(time.Second),
nil,

View File

@@ -1459,7 +1459,7 @@ func Test_querier_Traces_runWindowBasedListQueryDesc(t *testing.T) {
slog.Default(),
nil,
telemetryStore,
prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{}, telemetryStore),
prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{Timeout: 2 * time.Minute}, telemetryStore),
"",
time.Duration(time.Second),
nil,
@@ -1685,7 +1685,7 @@ func Test_querier_Traces_runWindowBasedListQueryAsc(t *testing.T) {
slog.Default(),
nil,
telemetryStore,
prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{}, telemetryStore),
prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{Timeout: 2 * time.Minute}, telemetryStore),
"",
time.Duration(time.Second),
nil,
@@ -1985,7 +1985,7 @@ func Test_querier_Logs_runWindowBasedListQueryDesc(t *testing.T) {
slog.Default(),
nil,
telemetryStore,
prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{}, telemetryStore),
prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{Timeout: 2 * time.Minute}, telemetryStore),
"",
time.Duration(time.Second),
nil,
@@ -2213,7 +2213,7 @@ func Test_querier_Logs_runWindowBasedListQueryAsc(t *testing.T) {
slog.Default(),
nil,
telemetryStore,
prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{}, telemetryStore),
prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{Timeout: 2 * time.Minute}, telemetryStore),
"",
time.Duration(time.Second),
nil,

View File

@@ -121,6 +121,7 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
logParsingPipelineController, err := logparsingpipeline.NewLogParsingPipelinesController(
signoz.SQLStore,
integrationsController.GetPipelinesForInstalledIntegrations,
reader,
)
if err != nil {
return nil, err

View File

@@ -67,7 +67,7 @@ func getPathFromRootToSelectedSpanId(node *model.Span, selectedSpanId string, un
spansFromRootToNode := []string{}
if node.SpanID == selectedSpanId {
if isSelectedSpanIDUnCollapsed {
if isSelectedSpanIDUnCollapsed && !slices.Contains(uncollapsedSpans, node.SpanID) {
spansFromRootToNode = append(spansFromRootToNode, node.SpanID)
}
return true, spansFromRootToNode
@@ -88,7 +88,15 @@ func getPathFromRootToSelectedSpanId(node *model.Span, selectedSpanId string, un
return isPresentInSubtreeForTheNode, spansFromRootToNode
}
func traverseTrace(span *model.Span, uncollapsedSpans []string, level uint64, isPartOfPreOrder bool, hasSibling bool, selectedSpanId string) []*model.Span {
// traverseOpts holds the traversal configuration that remains constant
// throughout the recursion. Per-call state (level, isPartOfPreOrder, etc.)
// is passed as direct arguments.
type traverseOpts struct {
uncollapsedSpans []string
selectedSpanID string
}
func traverseTrace(span *model.Span, opts traverseOpts, level uint64, isPartOfPreOrder bool, hasSibling bool) []*model.Span {
preOrderTraversal := []*model.Span{}
// sort the children to maintain the order across requests
@@ -126,8 +134,9 @@ func traverseTrace(span *model.Span, uncollapsedSpans []string, level uint64, is
preOrderTraversal = append(preOrderTraversal, &nodeWithoutChildren)
}
isAlreadyUncollapsed := slices.Contains(opts.uncollapsedSpans, span.SpanID)
for index, child := range span.Children {
_childTraversal := traverseTrace(child, uncollapsedSpans, level+1, isPartOfPreOrder && slices.Contains(uncollapsedSpans, span.SpanID), index != (len(span.Children)-1), selectedSpanId)
_childTraversal := traverseTrace(child, opts, level+1, isPartOfPreOrder && isAlreadyUncollapsed, index != (len(span.Children)-1))
preOrderTraversal = append(preOrderTraversal, _childTraversal...)
nodeWithoutChildren.SubTreeNodeCount += child.SubTreeNodeCount + 1
span.SubTreeNodeCount += child.SubTreeNodeCount + 1
@@ -168,7 +177,11 @@ func GetSelectedSpans(uncollapsedSpans []string, selectedSpanID string, traceRoo
_, spansFromRootToNode := getPathFromRootToSelectedSpanId(rootNode, selectedSpanID, updatedUncollapsedSpans, isSelectedSpanIDUnCollapsed)
updatedUncollapsedSpans = append(updatedUncollapsedSpans, spansFromRootToNode...)
_preOrderTraversal := traverseTrace(rootNode, updatedUncollapsedSpans, 0, true, false, selectedSpanID)
opts := traverseOpts{
uncollapsedSpans: updatedUncollapsedSpans,
selectedSpanID: selectedSpanID,
}
_preOrderTraversal := traverseTrace(rootNode, opts, 0, true, false)
_selectedSpanIndex := findIndexForSelectedSpanFromPreOrder(_preOrderTraversal, selectedSpanID)
if _selectedSpanIndex != -1 {

View File

@@ -0,0 +1,355 @@
// Package tracedetail tests — waterfall
//
// # Background
//
// The waterfall view renders a trace as a scrollable list of spans in
// pre-order (parent before children, siblings left-to-right). Because a trace
// can have thousands of spans, only a window of ~500 is returned per request.
// The window is centred on the selected span.
//
// # Key concepts
//
// uncollapsedSpans
//
// The set of span IDs the user has manually expanded in the UI.
// Only the direct children of an uncollapsed span are included in the
// output; grandchildren stay hidden until their parent is also uncollapsed.
// When multiple spans are uncollapsed their children are all visible at once.
//
// selectedSpanID
//
// The span currently focused — set when the user clicks a span in the
// waterfall or selects one from the flamegraph. The output window is always
// centred on this span. The path from the trace root down to the selected
// span is automatically uncollapsed so ancestors are visible even if they are
// not in uncollapsedSpans.
//
// isSelectedSpanIDUnCollapsed
//
// Controls whether the selected span's own children are shown:
// true — user expanded the span (click-to-open in waterfall or flamegraph);
// direct children of the selected span are included.
// false — user selected without expanding;
// the span is visible but its children remain hidden.
//
// traceRoots
//
// Root spans of the trace — spans with no parent in the current dataset.
// Normally one, but multiple roots are common when upstream services are
// not instrumented or their spans were not sampled/exported.
package tracedetail
import (
"fmt"
"testing"
"github.com/SigNoz/signoz/pkg/query-service/model"
"github.com/stretchr/testify/assert"
)
// Pre-order traversal is preserved: parent before children, siblings left-to-right.
func TestGetSelectedSpans_PreOrderTraversal(t *testing.T) {
root := mkSpan("root", "svc",
mkSpan("child1", "svc", mkSpan("grandchild", "svc")),
mkSpan("child2", "svc"),
)
spanMap := buildSpanMap(root)
spans, _, _, _ := GetSelectedSpans([]string{"root", "child1"}, "root", []*model.Span{root}, spanMap, false)
assert.Equal(t, []string{"root", "child1", "grandchild", "child2"}, spanIDs(spans))
}
// Multiple roots: both trees are flattened into a single pre-order list with
// root1's subtree before root2's. Service/entry-point come from the first root.
//
// root1 svc-a ← selected
// └─ child1
// root2 svc-b
// └─ child2
//
// Expected output order: root1 → child1 → root2 → child2
func TestGetSelectedSpans_MultipleRoots(t *testing.T) {
root1 := mkSpan("root1", "svc-a", mkSpan("child1", "svc-a"))
root2 := mkSpan("root2", "svc-b", mkSpan("child2", "svc-b"))
spanMap := buildSpanMap(root1, root2)
spans, _, svcName, entryPoint := GetSelectedSpans([]string{"root1", "root2"}, "root1", []*model.Span{root1, root2}, spanMap, false)
assert.Equal(t, []string{"root1", "child1", "root2", "child2"}, spanIDs(spans), "root1 subtree must precede root2 subtree")
assert.Equal(t, "svc-a", svcName, "metadata comes from first root")
assert.Equal(t, "root1-op", entryPoint, "metadata comes from first root")
}
// isSelectedSpanIDUnCollapsed=true opens only the selected span's direct children,
// not deeper descendants.
//
// root → selected (expanded)
// ├─ child1 ✓
// │ └─ grandchild ✗ (only one level opened)
// └─ child2 ✓
func TestGetSelectedSpans_ExpandedSelectedSpan(t *testing.T) {
root := mkSpan("root", "svc",
mkSpan("selected", "svc",
mkSpan("child1", "svc", mkSpan("grandchild", "svc")),
mkSpan("child2", "svc"),
),
)
spanMap := buildSpanMap(root)
spans, _, _, _ := GetSelectedSpans([]string{}, "selected", []*model.Span{root}, spanMap, true)
// root and selected are on the auto-uncollapsed path; child1/child2 are direct
// children of the expanded selected span; grandchild stays hidden.
assert.Equal(t, []string{"root", "selected", "child1", "child2"}, spanIDs(spans))
}
// Multiple spans uncollapsed simultaneously: children of all uncollapsed spans
// are visible at once.
//
// root
// ├─ childA (uncollapsed) → grandchildA ✓
// └─ childB (uncollapsed) → grandchildB ✓
func TestGetSelectedSpans_MultipleUncollapsed(t *testing.T) {
root := mkSpan("root", "svc",
mkSpan("childA", "svc", mkSpan("grandchildA", "svc")),
mkSpan("childB", "svc", mkSpan("grandchildB", "svc")),
)
spanMap := buildSpanMap(root)
spans, _, _, _ := GetSelectedSpans([]string{"root", "childA", "childB"}, "root", []*model.Span{root}, spanMap, false)
assert.Equal(t, []string{"root", "childA", "grandchildA", "childB", "grandchildB"}, spanIDs(spans))
}
// Collapsing a span with other uncollapsed spans
//
// root
// ├─ childA (previously expanded — in uncollapsedSpans)
// │ ├─ grandchild1 ✓
// │ │ └─ greatGrandchild ✗ (grandchild1 not in uncollapsedSpans)
// │ └─ grandchild2 ✓
// └─ childB ← selected (not expanded)
func TestGetSelectedSpans_ManualUncollapse(t *testing.T) {
root := mkSpan("root", "svc",
mkSpan("childA", "svc",
mkSpan("grandchild1", "svc", mkSpan("greatGrandchild", "svc")),
mkSpan("grandchild2", "svc"),
),
mkSpan("childB", "svc"),
)
spanMap := buildSpanMap(root)
// childA was expanded in a previous interaction; childB is now selected without expanding
spans, _, _, _ := GetSelectedSpans([]string{"childA"}, "childB", []*model.Span{root}, spanMap, false)
// path to childB auto-uncollpases root → childA and childB appear; childA is in
// uncollapsedSpans so its children appear; greatGrandchild stays hidden.
assert.Equal(t, []string{"root", "childA", "grandchild1", "grandchild2", "childB"}, spanIDs(spans))
}
// A collapsed span hides all children.
func TestGetSelectedSpans_CollapsedSpan(t *testing.T) {
root := mkSpan("root", "svc",
mkSpan("child1", "svc"),
mkSpan("child2", "svc"),
)
spanMap := buildSpanMap(root)
spans, _, _, _ := GetSelectedSpans([]string{}, "root", []*model.Span{root}, spanMap, false)
assert.Equal(t, []string{"root"}, spanIDs(spans))
}
// Selecting a span auto-uncollpases the path from root to that span so it is visible.
//
// root → parent → selected
func TestGetSelectedSpans_PathToSelectedIsUncollapsed(t *testing.T) {
root := mkSpan("root", "svc",
mkSpan("parent", "svc",
mkSpan("selected", "svc"),
),
)
spanMap := buildSpanMap(root)
// no manually uncollapsed spans — path should still be opened
spans, _, _, _ := GetSelectedSpans([]string{}, "selected", []*model.Span{root}, spanMap, false)
assert.Equal(t, []string{"root", "parent", "selected"}, spanIDs(spans))
}
// The path-to-selected spans are returned in updatedUncollapsedSpans.
func TestGetSelectedSpans_PathReturnedInUncollapsed(t *testing.T) {
root := mkSpan("root", "svc",
mkSpan("parent", "svc",
mkSpan("selected", "svc"),
),
)
spanMap := buildSpanMap(root)
spans, uncollapsed, _, _ := GetSelectedSpans([]string{}, "selected", []*model.Span{root}, spanMap, false)
assert.Equal(t, []string{"root", "parent"}, uncollapsed)
assert.Equal(t, []string{"root", "parent", "selected"}, spanIDs(spans))
}
// Siblings of ancestors are rendered as collapsed nodes but their subtrees
// must NOT be expanded.
//
// root
// ├─ unrelated → unrelated-child (✗)
// └─ parent → selected
func TestGetSelectedSpans_SiblingsNotExpanded(t *testing.T) {
root := mkSpan("root", "svc",
mkSpan("unrelated", "svc", mkSpan("unrelated-child", "svc")),
mkSpan("parent", "svc",
mkSpan("selected", "svc"),
),
)
spanMap := buildSpanMap(root)
spans, uncollapsed, _, _ := GetSelectedSpans([]string{}, "selected", []*model.Span{root}, spanMap, false)
// children of root sort alphabetically: parent < unrelated; unrelated-child stays hidden
assert.Equal(t, []string{"root", "parent", "selected", "unrelated"}, spanIDs(spans))
// only the path nodes are tracked as uncollapsed — unrelated is not
assert.Equal(t, []string{"root", "parent"}, uncollapsed)
}
// An unknown selectedSpanID must not panic; returns a window from index 0.
func TestGetSelectedSpans_UnknownSelectedSpan(t *testing.T) {
root := mkSpan("root", "svc", mkSpan("child", "svc"))
spanMap := buildSpanMap(root)
spans, _, _, _ := GetSelectedSpans([]string{}, "nonexistent", []*model.Span{root}, spanMap, false)
assert.Equal(t, []string{"root"}, spanIDs(spans))
}
// Test to check if Level, HasChildren, HasSiblings, and SubTreeNodeCount are populated correctly.
//
// root level=0, hasChildren=true, hasSiblings=false, subTree=4
// child1 level=1, hasChildren=true, hasSiblings=true, subTree=2
// grandchild level=2, hasChildren=false, hasSiblings=false, subTree=1
// child2 level=1, hasChildren=false, hasSiblings=false, subTree=1
func TestGetSelectedSpans_SpanMetadata(t *testing.T) {
root := mkSpan("root", "svc",
mkSpan("child1", "svc", mkSpan("grandchild", "svc")),
mkSpan("child2", "svc"),
)
spanMap := buildSpanMap(root)
spans, _, _, _ := GetSelectedSpans([]string{"root", "child1"}, "root", []*model.Span{root}, spanMap, false)
byID := map[string]*model.Span{}
for _, s := range spans {
byID[s.SpanID] = s
}
assert.Equal(t, uint64(0), byID["root"].Level)
assert.Equal(t, uint64(1), byID["child1"].Level)
assert.Equal(t, uint64(1), byID["child2"].Level)
assert.Equal(t, uint64(2), byID["grandchild"].Level)
assert.True(t, byID["root"].HasChildren)
assert.True(t, byID["child1"].HasChildren)
assert.False(t, byID["child2"].HasChildren)
assert.False(t, byID["grandchild"].HasChildren)
assert.False(t, byID["root"].HasSiblings, "root has no siblings")
assert.True(t, byID["child1"].HasSiblings, "child1 has sibling child2")
assert.False(t, byID["child2"].HasSiblings, "child2 is the last child")
assert.False(t, byID["grandchild"].HasSiblings, "grandchild has no siblings")
assert.Equal(t, uint64(4), byID["root"].SubTreeNodeCount)
assert.Equal(t, uint64(2), byID["child1"].SubTreeNodeCount)
assert.Equal(t, uint64(1), byID["grandchild"].SubTreeNodeCount)
assert.Equal(t, uint64(1), byID["child2"].SubTreeNodeCount)
}
// If the selected span is already in uncollapsedSpans AND isSelectedSpanIDUnCollapsed=true,
func TestGetSelectedSpans_DuplicateInUncollapsed(t *testing.T) {
root := mkSpan("root", "svc",
mkSpan("selected", "svc", mkSpan("child", "svc")),
)
spanMap := buildSpanMap(root)
_, uncollapsed, _, _ := GetSelectedSpans(
[]string{"selected"}, // already present
"selected",
[]*model.Span{root}, spanMap,
true,
)
count := 0
for _, id := range uncollapsed {
if id == "selected" {
count++
}
}
assert.Equal(t, 1, count, "should appear once")
}
// makeChain builds a linear trace: span0 → span1 → … → span(n-1).
// All span IDs are "span0", "span1", … so the caller can reference them by index.
func makeChain(n int) (*model.Span, map[string]*model.Span, []string) {
spans := make([]*model.Span, n)
for i := n - 1; i >= 0; i-- {
if i == n-1 {
spans[i] = mkSpan(fmt.Sprintf("span%d", i), "svc")
} else {
spans[i] = mkSpan(fmt.Sprintf("span%d", i), "svc", spans[i+1])
}
}
uncollapsed := make([]string, n)
for i := range spans {
uncollapsed[i] = fmt.Sprintf("span%d", i)
}
return spans[0], buildSpanMap(spans[0]), uncollapsed
}
// The selected span is centred: 200 spans before it, 300 after (0.4 / 0.6 split).
func TestGetSelectedSpans_WindowCentredOnSelected(t *testing.T) {
root, spanMap, uncollapsed := makeChain(600)
spans, _, _, _ := GetSelectedSpans(uncollapsed, "span300", []*model.Span{root}, spanMap, false)
assert.Equal(t, 500, len(spans), "window should be 500 spans")
// window is [100, 600): span300 lands at position 200 (300 - 100)
assert.Equal(t, "span100", spans[0].SpanID, "window starts 200 before selected")
assert.Equal(t, "span300", spans[200].SpanID, "selected span at position 200 in window")
assert.Equal(t, "span599", spans[499].SpanID, "window ends 300 after selected")
}
// When the selected span is near the start, the window shifts right so no
// negative index is used — the result is still 500 spans.
func TestGetSelectedSpans_WindowShiftsAtStart(t *testing.T) {
root, spanMap, uncollapsed := makeChain(600)
spans, _, _, _ := GetSelectedSpans(uncollapsed, "span10", []*model.Span{root}, spanMap, false)
assert.Equal(t, 500, len(spans))
assert.Equal(t, "span0", spans[0].SpanID, "window clamped to start of trace")
assert.Equal(t, "span10", spans[10].SpanID, "selected span still in window")
}
func mkSpan(id, service string, children ...*model.Span) *model.Span {
return &model.Span{
SpanID: id,
ServiceName: service,
Name: id + "-op",
Children: children,
}
}
// spanIDs returns SpanIDs in order.
func spanIDs(spans []*model.Span) []string {
ids := make([]string, len(spans))
for i, s := range spans {
ids[i] = s.SpanID
}
return ids
}
// buildSpanMap indexes every span in a set of trees by SpanID.
func buildSpanMap(roots ...*model.Span) map[string]*model.Span {
m := map[string]*model.Span{}
var walk func(*model.Span)
walk = func(s *model.Span) {
m[s.SpanID] = s
for _, c := range s.Children {
walk(c)
}
}
for _, r := range roots {
walk(r)
}
return m
}

View File

@@ -698,7 +698,7 @@ func TestBaseRule_FilterNewSeries(t *testing.T) {
slog.Default(),
nil,
telemetryStore,
prometheustest.New(context.Background(), settings, prometheus.Config{}, telemetryStore),
prometheustest.New(context.Background(), settings, prometheus.Config{Timeout: 2 * time.Minute}, telemetryStore),
"",
time.Second,
nil,

View File

@@ -253,7 +253,7 @@ func TestManager_TestNotification_SendUnmatched_PromRule(t *testing.T) {
WillReturnRows(samplesRows)
// Create Prometheus provider for this test
promProvider = prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{}, store)
promProvider = prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{Timeout: 2 * time.Minute}, store)
},
ManagerOptionsHook: func(opts *ManagerOptions) {
// Set Prometheus provider for PromQL queries

View File

@@ -99,7 +99,7 @@ func NewTestManager(t *testing.T, testOpts *TestManagerOptions) *Manager {
options := clickhouseReader.NewOptions("", "", "archiveNamespace")
providerSettings := instrumentationtest.New().ToProviderSettings()
prometheus := prometheustest.New(context.Background(), providerSettings, prometheus.Config{}, telemetryStore)
prometheus := prometheustest.New(context.Background(), providerSettings, prometheus.Config{Timeout: 2 * time.Minute}, telemetryStore)
reader := clickhouseReader.NewReader(
instrumentationtest.New().Logger(),
nil,

View File

@@ -940,7 +940,7 @@ func TestPromRuleUnitCombinations(t *testing.T) {
).
WillReturnRows(samplesRows)
promProvider := prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{}, telemetryStore)
promProvider := prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{Timeout: 2 * time.Minute}, telemetryStore)
postableRule.RuleCondition.CompareOp = ruletypes.CompareOp(c.compareOp)
postableRule.RuleCondition.MatchType = ruletypes.MatchType(c.matchType)
@@ -1061,7 +1061,7 @@ func _Enable_this_after_9146_issue_fix_is_merged_TestPromRuleNoData(t *testing.T
WithArgs("test_metric", "__name__", "test_metric").
WillReturnRows(fingerprintRows)
promProvider := prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{}, telemetryStore)
promProvider := prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{Timeout: 2 * time.Minute}, telemetryStore)
var target float64 = 0
postableRule.RuleCondition.Thresholds = &ruletypes.RuleThresholdData{
@@ -1281,7 +1281,7 @@ func TestMultipleThresholdPromRule(t *testing.T) {
).
WillReturnRows(samplesRows)
promProvider := prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{}, telemetryStore)
promProvider := prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{Timeout: 2 * time.Minute}, telemetryStore)
postableRule.RuleCondition.CompareOp = ruletypes.CompareOp(c.compareOp)
postableRule.RuleCondition.MatchType = ruletypes.MatchType(c.matchType)
@@ -1441,7 +1441,7 @@ func TestPromRule_NoData(t *testing.T) {
promProvider := prometheustest.New(
context.Background(),
instrumentationtest.New().ToProviderSettings(),
prometheus.Config{},
prometheus.Config{Timeout: 2 * time.Minute},
telemetryStore,
)
defer func() {
@@ -1590,7 +1590,7 @@ func TestPromRule_NoData_AbsentFor(t *testing.T) {
promProvider := prometheustest.New(
context.Background(),
instrumentationtest.New().ToProviderSettings(),
prometheus.Config{},
prometheus.Config{Timeout: 2 * time.Minute},
telemetryStore,
)
defer func() {
@@ -1748,7 +1748,7 @@ func TestPromRuleEval_RequireMinPoints(t *testing.T) {
promProvider := prometheustest.New(
context.Background(),
instrumentationtest.New().ToProviderSettings(),
prometheus.Config{LookbackDelta: lookBackDelta},
prometheus.Config{Timeout: 2 * time.Minute, LookbackDelta: lookBackDelta},
telemetryStore,
)
defer func() {

View File

@@ -779,7 +779,7 @@ func TestThresholdRuleUnitCombinations(t *testing.T) {
},
)
require.NoError(t, err)
reader := clickhouseReader.NewReader(slog.Default(), nil, telemetryStore, prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{}, telemetryStore), "", time.Duration(time.Second), nil, readerCache, options)
reader := clickhouseReader.NewReader(slog.Default(), nil, telemetryStore, prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{Timeout: 2 * time.Minute}, telemetryStore), "", time.Duration(time.Second), nil, readerCache, options)
rule, err := NewThresholdRule("69", valuer.GenerateUUID(), &postableRule, reader, nil, logger)
rule.TemporalityMap = map[string]map[v3.Temporality]bool{
"signoz_calls_total": {
@@ -894,7 +894,7 @@ func TestThresholdRuleNoData(t *testing.T) {
)
assert.NoError(t, err)
options := clickhouseReader.NewOptions("", "", "archiveNamespace")
reader := clickhouseReader.NewReader(slog.Default(), nil, telemetryStore, prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{}, telemetryStore), "", time.Duration(time.Second), nil, readerCache, options)
reader := clickhouseReader.NewReader(slog.Default(), nil, telemetryStore, prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{Timeout: 2 * time.Minute}, telemetryStore), "", time.Duration(time.Second), nil, readerCache, options)
rule, err := NewThresholdRule("69", valuer.GenerateUUID(), &postableRule, reader, nil, logger)
rule.TemporalityMap = map[string]map[v3.Temporality]bool{
@@ -1014,7 +1014,7 @@ func TestThresholdRuleTracesLink(t *testing.T) {
}
options := clickhouseReader.NewOptions("", "", "archiveNamespace")
reader := clickhouseReader.NewReader(slog.Default(), nil, telemetryStore, prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{}, telemetryStore), "", time.Duration(time.Second), nil, nil, options)
reader := clickhouseReader.NewReader(slog.Default(), nil, telemetryStore, prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{Timeout: 2 * time.Minute}, telemetryStore), "", time.Duration(time.Second), nil, nil, options)
rule, err := NewThresholdRule("69", valuer.GenerateUUID(), &postableRule, reader, nil, logger)
rule.TemporalityMap = map[string]map[v3.Temporality]bool{
@@ -1151,7 +1151,7 @@ func TestThresholdRuleLogsLink(t *testing.T) {
}
options := clickhouseReader.NewOptions("", "", "archiveNamespace")
reader := clickhouseReader.NewReader(slog.Default(), nil, telemetryStore, prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{}, telemetryStore), "", time.Duration(time.Second), nil, nil, options)
reader := clickhouseReader.NewReader(slog.Default(), nil, telemetryStore, prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{Timeout: 2 * time.Minute}, telemetryStore), "", time.Duration(time.Second), nil, nil, options)
rule, err := NewThresholdRule("69", valuer.GenerateUUID(), &postableRule, reader, nil, logger)
rule.TemporalityMap = map[string]map[v3.Temporality]bool{
@@ -1418,7 +1418,7 @@ func TestMultipleThresholdRule(t *testing.T) {
},
)
require.NoError(t, err)
reader := clickhouseReader.NewReader(slog.Default(), nil, telemetryStore, prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{}, telemetryStore), "", time.Second, nil, readerCache, options)
reader := clickhouseReader.NewReader(slog.Default(), nil, telemetryStore, prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{Timeout: 2 * time.Minute}, telemetryStore), "", time.Second, nil, readerCache, options)
rule, err := NewThresholdRule("69", valuer.GenerateUUID(), &postableRule, reader, nil, logger)
rule.TemporalityMap = map[string]map[v3.Temporality]bool{
"signoz_calls_total": {
@@ -2220,7 +2220,7 @@ func TestThresholdEval_RequireMinPoints(t *testing.T) {
)
require.NoError(t, err)
prometheusProvider := prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{}, telemetryStore)
prometheusProvider := prometheustest.New(context.Background(), instrumentationtest.New().ToProviderSettings(), prometheus.Config{Timeout: 2 * time.Minute}, telemetryStore)
reader := clickhouseReader.NewReader(slog.Default(), nil, telemetryStore, prometheusProvider, "", time.Second, nil, readerCache, options)
rule, err := NewThresholdRule("some-id", valuer.GenerateUUID(), &postableRule, reader, nil, logger)