mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-07 03:50:41 +01:00
Compare commits
17 Commits
proto/tree
...
feat/ai-qu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75e6ceb4bb | ||
|
|
7eb610287e | ||
|
|
74c946fe79 | ||
|
|
23dd98bee7 | ||
|
|
aca35a1309 | ||
|
|
7d3273c423 | ||
|
|
c65f6fa77d | ||
|
|
4d015a927e | ||
|
|
abebea532b | ||
|
|
764fe8ec69 | ||
|
|
a88cc79ef9 | ||
|
|
e424082835 | ||
|
|
2afed07b5b | ||
|
|
1c0dc018e0 | ||
|
|
bb2511ce53 | ||
|
|
33d22c8b59 | ||
|
|
c1b9de0c8a |
1
.github/workflows/integrationci.yaml
vendored
1
.github/workflows/integrationci.yaml
vendored
@@ -58,6 +58,7 @@ jobs:
|
||||
- querierai
|
||||
- rawexportdata
|
||||
- promqlconformance
|
||||
- promapiconformance
|
||||
- querierauthz
|
||||
- role
|
||||
- rootuser
|
||||
|
||||
@@ -46,6 +46,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/version"
|
||||
"github.com/SigNoz/signoz/pkg/zeus"
|
||||
@@ -103,8 +104,8 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
|
||||
|
||||
return openfgaauthz.NewProviderFactory(sqlstore, openfgaschema.NewSchema().Get(ctx), openfgaDataStore, authtypes.NewRegistry()), nil
|
||||
},
|
||||
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, _ querier.Querier, _ licensing.Licensing, tagModule tag.Module) dashboard.Module {
|
||||
return impldashboard.NewModule(impldashboard.NewStore(store), settings, analytics, orgGetter, queryParser, tagModule)
|
||||
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, _ querier.Querier, _ licensing.Licensing, tagModule tag.Module, systemDashboardRegistry dashboardtypes.SystemDashboardRegistry) dashboard.Module {
|
||||
return impldashboard.NewModule(impldashboard.NewStore(store), settings, analytics, orgGetter, queryParser, tagModule, systemDashboardRegistry)
|
||||
},
|
||||
func(_ licensing.Licensing) factory.ProviderFactory[gateway.Gateway, gateway.Config] {
|
||||
return noopgateway.NewProviderFactory()
|
||||
|
||||
@@ -63,6 +63,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/cloudintegrationtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/version"
|
||||
"github.com/SigNoz/signoz/pkg/zeus"
|
||||
@@ -136,8 +137,8 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
|
||||
}
|
||||
return openfgaauthz.NewProviderFactory(sqlstore, openfgaschema.NewSchema().Get(ctx), openfgaDataStore, licensing, onBeforeRoleDelete, authtypes.NewRegistry()), nil
|
||||
},
|
||||
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing, tagModule tag.Module) dashboard.Module {
|
||||
return impldashboard.NewModule(pkgimpldashboard.NewStore(store), settings, analytics, orgGetter, queryParser, querier, licensing, tagModule)
|
||||
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing, tagModule tag.Module, systemDashboardRegistry dashboardtypes.SystemDashboardRegistry) dashboard.Module {
|
||||
return impldashboard.NewModule(pkgimpldashboard.NewStore(store), settings, analytics, orgGetter, queryParser, querier, licensing, tagModule, systemDashboardRegistry)
|
||||
},
|
||||
func(licensing licensing.Licensing) factory.ProviderFactory[gateway.Gateway, gateway.Config] {
|
||||
return httpgateway.NewProviderFactory(licensing)
|
||||
|
||||
@@ -2944,6 +2944,46 @@ components:
|
||||
publicDashboard:
|
||||
$ref: '#/components/schemas/DashboardtypesGettablePublicDasbhboard'
|
||||
type: object
|
||||
DashboardtypesGettableSystemDashboard:
|
||||
properties:
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
createdBy:
|
||||
type: string
|
||||
image:
|
||||
type: string
|
||||
locked:
|
||||
type: boolean
|
||||
name:
|
||||
type: string
|
||||
orgId:
|
||||
type: string
|
||||
schemaVersion:
|
||||
type: string
|
||||
source:
|
||||
$ref: '#/components/schemas/DashboardtypesSource'
|
||||
spec:
|
||||
$ref: '#/components/schemas/DashboardtypesDashboardSpec'
|
||||
tags:
|
||||
items:
|
||||
$ref: '#/components/schemas/TagtypesGettableTag'
|
||||
nullable: true
|
||||
type: array
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
updatedBy:
|
||||
type: string
|
||||
required:
|
||||
- orgId
|
||||
- locked
|
||||
- source
|
||||
- schemaVersion
|
||||
- name
|
||||
- tags
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesHistogramBuckets:
|
||||
properties:
|
||||
bucketCount:
|
||||
@@ -6460,6 +6500,148 @@ components:
|
||||
type: object
|
||||
PreferencetypesValue:
|
||||
type: object
|
||||
PrometheusErrorResponseSchema:
|
||||
properties:
|
||||
error:
|
||||
type: string
|
||||
errorType:
|
||||
enum:
|
||||
- bad_data
|
||||
- execution
|
||||
- canceled
|
||||
- timeout
|
||||
- internal
|
||||
type: string
|
||||
status:
|
||||
enum:
|
||||
- error
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- errorType
|
||||
- error
|
||||
type: object
|
||||
PrometheusMatrixDataSchema:
|
||||
properties:
|
||||
result:
|
||||
items:
|
||||
$ref: '#/components/schemas/PrometheusMatrixSeriesSchema'
|
||||
nullable: true
|
||||
type: array
|
||||
resultType:
|
||||
enum:
|
||||
- matrix
|
||||
type: string
|
||||
required:
|
||||
- resultType
|
||||
- result
|
||||
type: object
|
||||
PrometheusMatrixSeriesSchema:
|
||||
properties:
|
||||
metric:
|
||||
additionalProperties:
|
||||
type: string
|
||||
nullable: true
|
||||
type: object
|
||||
values:
|
||||
items:
|
||||
$ref: '#/components/schemas/PrometheusSamplePairSchema'
|
||||
nullable: true
|
||||
type: array
|
||||
required:
|
||||
- metric
|
||||
- values
|
||||
type: object
|
||||
PrometheusQueryDataSchema:
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/PrometheusMatrixDataSchema'
|
||||
- $ref: '#/components/schemas/PrometheusVectorDataSchema'
|
||||
- $ref: '#/components/schemas/PrometheusScalarDataSchema'
|
||||
- $ref: '#/components/schemas/PrometheusStringDataSchema'
|
||||
type: object
|
||||
PrometheusSamplePairSchema:
|
||||
description: 'A [timestamp, value] pair: float unix seconds, then the string-encoded
|
||||
sample value ("NaN", "+Inf", "-Inf" included).'
|
||||
items:
|
||||
oneOf:
|
||||
- type: number
|
||||
- type: string
|
||||
maxItems: 2
|
||||
minItems: 2
|
||||
nullable: true
|
||||
type: array
|
||||
PrometheusScalarDataSchema:
|
||||
properties:
|
||||
result:
|
||||
$ref: '#/components/schemas/PrometheusSamplePairSchema'
|
||||
resultType:
|
||||
enum:
|
||||
- scalar
|
||||
type: string
|
||||
required:
|
||||
- resultType
|
||||
- result
|
||||
type: object
|
||||
PrometheusStringDataSchema:
|
||||
properties:
|
||||
result:
|
||||
$ref: '#/components/schemas/PrometheusSamplePairSchema'
|
||||
resultType:
|
||||
enum:
|
||||
- string
|
||||
type: string
|
||||
required:
|
||||
- resultType
|
||||
- result
|
||||
type: object
|
||||
PrometheusSuccessResponseSchema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/PrometheusQueryDataSchema'
|
||||
infos:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
status:
|
||||
enum:
|
||||
- success
|
||||
type: string
|
||||
warnings:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
PrometheusVectorDataSchema:
|
||||
properties:
|
||||
result:
|
||||
items:
|
||||
$ref: '#/components/schemas/PrometheusVectorSampleSchema'
|
||||
nullable: true
|
||||
type: array
|
||||
resultType:
|
||||
enum:
|
||||
- vector
|
||||
type: string
|
||||
required:
|
||||
- resultType
|
||||
- result
|
||||
type: object
|
||||
PrometheusVectorSampleSchema:
|
||||
properties:
|
||||
metric:
|
||||
additionalProperties:
|
||||
type: string
|
||||
nullable: true
|
||||
type: object
|
||||
value:
|
||||
$ref: '#/components/schemas/PrometheusSamplePairSchema'
|
||||
required:
|
||||
- metric
|
||||
- value
|
||||
type: object
|
||||
PromotetypesPromotePath:
|
||||
properties:
|
||||
indexes:
|
||||
@@ -15359,6 +15541,73 @@ paths:
|
||||
summary: Migrate dashboard to v2
|
||||
tags:
|
||||
- dashboard
|
||||
/api/v2/dashboards/system/{name}:
|
||||
get:
|
||||
deprecated: false
|
||||
description: Returns a dashboard SigNoz ships and owns, addressed by its stable
|
||||
definition name (e.g. `ai-o11y-overview`) rather than its id. System dashboards
|
||||
are read-only and upgraded through releases. The dashboard's own `name` field
|
||||
carries a reserved prefix that the path segment must not include.
|
||||
operationId: GetSystemDashboard
|
||||
parameters:
|
||||
- in: path
|
||||
name: name
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/DashboardtypesGettableSystemDashboard'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- dashboard:read
|
||||
- tokenizer:
|
||||
- dashboard:read
|
||||
summary: Get system dashboard
|
||||
tags:
|
||||
- dashboard
|
||||
/api/v2/factor_password/forgot:
|
||||
post:
|
||||
deprecated: false
|
||||
@@ -24811,6 +25060,374 @@ paths:
|
||||
summary: Replace variables
|
||||
tags:
|
||||
- querier
|
||||
/prometheus/api/v1/query:
|
||||
get:
|
||||
description: 'Prometheus-compatible endpoint: the request and response contract
|
||||
is the upstream Prometheus HTTP API (https://prometheus.io/docs/prometheus/latest/querying/api/).
|
||||
Parameters are accepted as URL query parameters or a form-encoded body, on
|
||||
GET and POST alike.'
|
||||
operationId: PrometheusQuery
|
||||
parameters:
|
||||
- description: PromQL expression.
|
||||
in: query
|
||||
name: query
|
||||
required: true
|
||||
schema:
|
||||
description: PromQL expression.
|
||||
type: string
|
||||
- description: 'Evaluation timestamp: RFC3339 or float unix seconds. Defaults
|
||||
to the server''s current time.'
|
||||
in: query
|
||||
name: time
|
||||
schema:
|
||||
description: 'Evaluation timestamp: RFC3339 or float unix seconds. Defaults
|
||||
to the server''s current time.'
|
||||
type: string
|
||||
- description: 'Evaluation timeout: duration string or float seconds.'
|
||||
in: query
|
||||
name: timeout
|
||||
schema:
|
||||
description: 'Evaluation timeout: duration string or float seconds.'
|
||||
type: string
|
||||
- description: Any non-empty value includes query statistics in the response.
|
||||
in: query
|
||||
name: stats
|
||||
schema:
|
||||
description: Any non-empty value includes query statistics in the response.
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PrometheusSuccessResponseSchema'
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"422":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
|
||||
description: Unprocessable Entity
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
|
||||
description: Internal Server Error
|
||||
"503":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
|
||||
description: Service Unavailable
|
||||
security:
|
||||
- api_key:
|
||||
- metrics:read
|
||||
- tokenizer:
|
||||
- metrics:read
|
||||
summary: Prometheus instant query
|
||||
tags:
|
||||
- prometheus
|
||||
post:
|
||||
description: 'Prometheus-compatible endpoint: the request and response contract
|
||||
is the upstream Prometheus HTTP API (https://prometheus.io/docs/prometheus/latest/querying/api/).
|
||||
Parameters are accepted as URL query parameters or a form-encoded body, on
|
||||
GET and POST alike.'
|
||||
operationId: PrometheusQueryPost
|
||||
parameters:
|
||||
- description: PromQL expression.
|
||||
in: query
|
||||
name: query
|
||||
required: true
|
||||
schema:
|
||||
description: PromQL expression.
|
||||
type: string
|
||||
- description: 'Evaluation timestamp: RFC3339 or float unix seconds. Defaults
|
||||
to the server''s current time.'
|
||||
in: query
|
||||
name: time
|
||||
schema:
|
||||
description: 'Evaluation timestamp: RFC3339 or float unix seconds. Defaults
|
||||
to the server''s current time.'
|
||||
type: string
|
||||
- description: 'Evaluation timeout: duration string or float seconds.'
|
||||
in: query
|
||||
name: timeout
|
||||
schema:
|
||||
description: 'Evaluation timeout: duration string or float seconds.'
|
||||
type: string
|
||||
- description: Any non-empty value includes query statistics in the response.
|
||||
in: query
|
||||
name: stats
|
||||
schema:
|
||||
description: Any non-empty value includes query statistics in the response.
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PrometheusSuccessResponseSchema'
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"422":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
|
||||
description: Unprocessable Entity
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
|
||||
description: Internal Server Error
|
||||
"503":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
|
||||
description: Service Unavailable
|
||||
security:
|
||||
- api_key:
|
||||
- metrics:read
|
||||
- tokenizer:
|
||||
- metrics:read
|
||||
summary: Prometheus instant query
|
||||
tags:
|
||||
- prometheus
|
||||
/prometheus/api/v1/query_range:
|
||||
get:
|
||||
description: 'Prometheus-compatible endpoint: the request and response contract
|
||||
is the upstream Prometheus HTTP API (https://prometheus.io/docs/prometheus/latest/querying/api/).
|
||||
Parameters are accepted as URL query parameters or a form-encoded body, on
|
||||
GET and POST alike.'
|
||||
operationId: PrometheusQueryRange
|
||||
parameters:
|
||||
- description: PromQL expression.
|
||||
in: query
|
||||
name: query
|
||||
required: true
|
||||
schema:
|
||||
description: PromQL expression.
|
||||
type: string
|
||||
- description: 'Range start: RFC3339 or float unix seconds.'
|
||||
in: query
|
||||
name: start
|
||||
required: true
|
||||
schema:
|
||||
description: 'Range start: RFC3339 or float unix seconds.'
|
||||
type: string
|
||||
- description: 'Range end: RFC3339 or float unix seconds.'
|
||||
in: query
|
||||
name: end
|
||||
required: true
|
||||
schema:
|
||||
description: 'Range end: RFC3339 or float unix seconds.'
|
||||
type: string
|
||||
- description: 'Resolution step: duration string or float seconds.'
|
||||
in: query
|
||||
name: step
|
||||
required: true
|
||||
schema:
|
||||
description: 'Resolution step: duration string or float seconds.'
|
||||
type: string
|
||||
- description: 'Evaluation timeout: duration string or float seconds.'
|
||||
in: query
|
||||
name: timeout
|
||||
schema:
|
||||
description: 'Evaluation timeout: duration string or float seconds.'
|
||||
type: string
|
||||
- description: Any non-empty value includes query statistics in the response.
|
||||
in: query
|
||||
name: stats
|
||||
schema:
|
||||
description: Any non-empty value includes query statistics in the response.
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PrometheusSuccessResponseSchema'
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"422":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
|
||||
description: Unprocessable Entity
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
|
||||
description: Internal Server Error
|
||||
"503":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
|
||||
description: Service Unavailable
|
||||
security:
|
||||
- api_key:
|
||||
- metrics:read
|
||||
- tokenizer:
|
||||
- metrics:read
|
||||
summary: Prometheus range query
|
||||
tags:
|
||||
- prometheus
|
||||
post:
|
||||
description: 'Prometheus-compatible endpoint: the request and response contract
|
||||
is the upstream Prometheus HTTP API (https://prometheus.io/docs/prometheus/latest/querying/api/).
|
||||
Parameters are accepted as URL query parameters or a form-encoded body, on
|
||||
GET and POST alike.'
|
||||
operationId: PrometheusQueryRangePost
|
||||
parameters:
|
||||
- description: PromQL expression.
|
||||
in: query
|
||||
name: query
|
||||
required: true
|
||||
schema:
|
||||
description: PromQL expression.
|
||||
type: string
|
||||
- description: 'Range start: RFC3339 or float unix seconds.'
|
||||
in: query
|
||||
name: start
|
||||
required: true
|
||||
schema:
|
||||
description: 'Range start: RFC3339 or float unix seconds.'
|
||||
type: string
|
||||
- description: 'Range end: RFC3339 or float unix seconds.'
|
||||
in: query
|
||||
name: end
|
||||
required: true
|
||||
schema:
|
||||
description: 'Range end: RFC3339 or float unix seconds.'
|
||||
type: string
|
||||
- description: 'Resolution step: duration string or float seconds.'
|
||||
in: query
|
||||
name: step
|
||||
required: true
|
||||
schema:
|
||||
description: 'Resolution step: duration string or float seconds.'
|
||||
type: string
|
||||
- description: 'Evaluation timeout: duration string or float seconds.'
|
||||
in: query
|
||||
name: timeout
|
||||
schema:
|
||||
description: 'Evaluation timeout: duration string or float seconds.'
|
||||
type: string
|
||||
- description: Any non-empty value includes query statistics in the response.
|
||||
in: query
|
||||
name: stats
|
||||
schema:
|
||||
description: Any non-empty value includes query statistics in the response.
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PrometheusSuccessResponseSchema'
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"422":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
|
||||
description: Unprocessable Entity
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
|
||||
description: Internal Server Error
|
||||
"503":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/PrometheusErrorResponseSchema'
|
||||
description: Service Unavailable
|
||||
security:
|
||||
- api_key:
|
||||
- metrics:read
|
||||
- tokenizer:
|
||||
- metrics:read
|
||||
summary: Prometheus range query
|
||||
tags:
|
||||
- prometheus
|
||||
servers:
|
||||
- description: The fully qualified URL to the SigNoz APIServer.
|
||||
url: https://{host}:{port}{base_path}
|
||||
|
||||
@@ -299,8 +299,11 @@ substituted. One subtlety makes it exact: we write stale markers at absent
|
||||
grid points. Without them, the engine's lookback would resurrect a point
|
||||
from up to `lookback` earlier. The marker encodes "absent here" the way the
|
||||
engine itself encodes it. Units evaluate concurrently. Each unit is one
|
||||
series lookup plus one grid statement. A step of 0 is an instant query: a
|
||||
single evaluation at `end`.
|
||||
grid statement: the group-key join resolves the matchers, and the samples
|
||||
primary key takes the metric name straight from the selector. Only a
|
||||
selector without a static `__name__` runs the series lookup first, to learn
|
||||
the concrete metric names. A step of 0 is an instant query: a single
|
||||
evaluation at `end`.
|
||||
|
||||
A note on the window sliver: when the window is narrower than the step, the
|
||||
grid windows cover only `window/step` of the timeline. A sample in a gap
|
||||
@@ -315,8 +318,9 @@ selectors and `last_over_time` transpile at window < step too.
|
||||
|
||||
## Series lookup
|
||||
|
||||
Both paths resolve matchers the same way, once per selector
|
||||
(`selectSeries`). The series tables hold one row per (fingerprint, bucket)
|
||||
The engine path resolves matchers once per selector (`selectSeries`); the
|
||||
transpiled path builds the same conditions into its group-key join. Both
|
||||
read the same tables. The series tables hold one row per (fingerprint, bucket)
|
||||
at 1h/6h/1d/1w granularities. The shared schema package
|
||||
(`pkg/telemetryschema/metricstelemetryschema`) picks the table whose bucket
|
||||
fits the window. It rounds the window start down to the bucket boundary, so
|
||||
|
||||
@@ -32,9 +32,9 @@ type module struct {
|
||||
tagModule tag.Module
|
||||
}
|
||||
|
||||
func NewModule(store dashboardtypes.Store, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing, tagModule tag.Module) dashboard.Module {
|
||||
func NewModule(store dashboardtypes.Store, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing, tagModule tag.Module, systemDashboardRegistry dashboardtypes.SystemDashboardRegistry) dashboard.Module {
|
||||
scopedProviderSettings := factory.NewScopedProviderSettings(settings, "github.com/SigNoz/signoz/ee/modules/dashboard/impldashboard")
|
||||
pkgDashboardModule := pkgimpldashboard.NewModule(store, settings, analytics, orgGetter, queryParser, tagModule)
|
||||
pkgDashboardModule := pkgimpldashboard.NewModule(store, settings, analytics, orgGetter, queryParser, tagModule, systemDashboardRegistry)
|
||||
|
||||
return &module{
|
||||
pkgDashboardModule: pkgDashboardModule,
|
||||
@@ -361,6 +361,14 @@ func (module *module) LockUnlock(ctx context.Context, orgID valuer.UUID, id valu
|
||||
return module.pkgDashboardModule.LockUnlock(ctx, orgID, id, updatedBy, isAdmin, lock)
|
||||
}
|
||||
|
||||
func (module *module) ReconcileSystemDashboards(ctx context.Context, orgID valuer.UUID) error {
|
||||
return module.pkgDashboardModule.ReconcileSystemDashboards(ctx, orgID)
|
||||
}
|
||||
|
||||
func (module *module) GetSystemDashboard(ctx context.Context, orgID valuer.UUID, name string) (*dashboardtypes.DashboardV2, error) {
|
||||
return module.pkgDashboardModule.GetSystemDashboard(ctx, orgID, name)
|
||||
}
|
||||
|
||||
func (module *module) delete(ctx context.Context, orgID, id valuer.UUID) error {
|
||||
return module.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
if err := module.store.DeletePublic(ctx, id.String()); err != nil && !errors.Ast(err, errors.TypeNotFound) {
|
||||
|
||||
@@ -46,6 +46,8 @@ import type {
|
||||
GetPublicDashboardPathParameters,
|
||||
GetPublicDashboardWidgetQueryRange200,
|
||||
GetPublicDashboardWidgetQueryRangePathParameters,
|
||||
GetSystemDashboard200,
|
||||
GetSystemDashboardPathParameters,
|
||||
ListDashboardViews200,
|
||||
ListDashboardsForUserV2200,
|
||||
ListDashboardsForUserV2Params,
|
||||
@@ -1885,6 +1887,108 @@ export const useMigrateDashboardV2 = <
|
||||
> => {
|
||||
return useMutation(getMigrateDashboardV2MutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns a dashboard SigNoz ships and owns, addressed by its stable definition name (e.g. `ai-o11y-overview`) rather than its id. System dashboards are read-only and upgraded through releases. The dashboard's own `name` field carries a reserved prefix that the path segment must not include.
|
||||
* @summary Get system dashboard
|
||||
*/
|
||||
export const getSystemDashboard = (
|
||||
{ name }: GetSystemDashboardPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetSystemDashboard200>({
|
||||
url: `/api/v2/dashboards/system/${name}`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetSystemDashboardQueryKey = ({
|
||||
name,
|
||||
}: GetSystemDashboardPathParameters) => {
|
||||
return [`/api/v2/dashboards/system/${name}`] as const;
|
||||
};
|
||||
|
||||
export const getGetSystemDashboardQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getSystemDashboard>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ name }: GetSystemDashboardPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getSystemDashboard>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetSystemDashboardQueryKey({ name });
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getSystemDashboard>>
|
||||
> = ({ signal }) => getSystemDashboard({ name }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!name,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getSystemDashboard>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetSystemDashboardQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getSystemDashboard>>
|
||||
>;
|
||||
export type GetSystemDashboardQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get system dashboard
|
||||
*/
|
||||
|
||||
export function useGetSystemDashboard<
|
||||
TData = Awaited<ReturnType<typeof getSystemDashboard>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ name }: GetSystemDashboardPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getSystemDashboard>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetSystemDashboardQueryOptions({ name }, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get system dashboard
|
||||
*/
|
||||
export const invalidateGetSystemDashboard = async (
|
||||
queryClient: QueryClient,
|
||||
{ name }: GetSystemDashboardPathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetSystemDashboardQueryKey({ name }) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint returns the sanitized v2-shape dashboard data for public access. Each panel query is reduced to a safe field subset, so filters and raw query strings are not exposed.
|
||||
* @summary Get public dashboard data (v2)
|
||||
|
||||
396
frontend/src/api/generated/services/prometheus/index.ts
Normal file
396
frontend/src/api/generated/services/prometheus/index.ts
Normal file
@@ -0,0 +1,396 @@
|
||||
/**
|
||||
* ! Do not edit manually
|
||||
* * The file has been auto-generated using Orval for SigNoz
|
||||
* * regenerate with 'pnpm generate:api'
|
||||
* SigNoz
|
||||
*/
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import type {
|
||||
InvalidateOptions,
|
||||
MutationFunction,
|
||||
QueryClient,
|
||||
QueryFunction,
|
||||
QueryKey,
|
||||
UseMutationOptions,
|
||||
UseMutationResult,
|
||||
UseQueryOptions,
|
||||
UseQueryResult,
|
||||
} from 'react-query';
|
||||
|
||||
import type {
|
||||
PrometheusErrorResponseSchemaDTO,
|
||||
PrometheusQueryParams,
|
||||
PrometheusQueryPostParams,
|
||||
PrometheusQueryRangeParams,
|
||||
PrometheusQueryRangePostParams,
|
||||
PrometheusSuccessResponseSchemaDTO,
|
||||
RenderErrorResponseDTO,
|
||||
} from '../sigNoz.schemas';
|
||||
|
||||
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
import type { ErrorType } from '../../../generatedAPIInstance';
|
||||
|
||||
/**
|
||||
* Prometheus-compatible endpoint: the request and response contract is the upstream Prometheus HTTP API (https://prometheus.io/docs/prometheus/latest/querying/api/). Parameters are accepted as URL query parameters or a form-encoded body, on GET and POST alike.
|
||||
* @summary Prometheus instant query
|
||||
*/
|
||||
export const prometheusQuery = (
|
||||
params: PrometheusQueryParams,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<PrometheusSuccessResponseSchemaDTO>({
|
||||
url: `/prometheus/api/v1/query`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getPrometheusQueryQueryKey = (params?: PrometheusQueryParams) => {
|
||||
return [`/prometheus/api/v1/query`, ...(params ? [params] : [])] as const;
|
||||
};
|
||||
|
||||
export const getPrometheusQueryQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof prometheusQuery>>,
|
||||
TError = ErrorType<PrometheusErrorResponseSchemaDTO | RenderErrorResponseDTO>,
|
||||
>(
|
||||
params: PrometheusQueryParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof prometheusQuery>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getPrometheusQueryQueryKey(params);
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof prometheusQuery>>> = ({
|
||||
signal,
|
||||
}) => prometheusQuery(params, signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof prometheusQuery>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type PrometheusQueryQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof prometheusQuery>>
|
||||
>;
|
||||
export type PrometheusQueryQueryError = ErrorType<
|
||||
PrometheusErrorResponseSchemaDTO | RenderErrorResponseDTO
|
||||
>;
|
||||
|
||||
/**
|
||||
* @summary Prometheus instant query
|
||||
*/
|
||||
|
||||
export function usePrometheusQuery<
|
||||
TData = Awaited<ReturnType<typeof prometheusQuery>>,
|
||||
TError = ErrorType<PrometheusErrorResponseSchemaDTO | RenderErrorResponseDTO>,
|
||||
>(
|
||||
params: PrometheusQueryParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof prometheusQuery>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getPrometheusQueryQueryOptions(params, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Prometheus instant query
|
||||
*/
|
||||
export const invalidatePrometheusQuery = async (
|
||||
queryClient: QueryClient,
|
||||
params: PrometheusQueryParams,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getPrometheusQueryQueryKey(params) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Prometheus-compatible endpoint: the request and response contract is the upstream Prometheus HTTP API (https://prometheus.io/docs/prometheus/latest/querying/api/). Parameters are accepted as URL query parameters or a form-encoded body, on GET and POST alike.
|
||||
* @summary Prometheus instant query
|
||||
*/
|
||||
export const prometheusQueryPost = (
|
||||
params: PrometheusQueryPostParams,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<PrometheusSuccessResponseSchemaDTO>({
|
||||
url: `/prometheus/api/v1/query`,
|
||||
method: 'POST',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getPrometheusQueryPostMutationOptions = <
|
||||
TError = ErrorType<PrometheusErrorResponseSchemaDTO | RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof prometheusQueryPost>>,
|
||||
TError,
|
||||
{ params: PrometheusQueryPostParams },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof prometheusQueryPost>>,
|
||||
TError,
|
||||
{ params: PrometheusQueryPostParams },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['prometheusQueryPost'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof prometheusQueryPost>>,
|
||||
{ params: PrometheusQueryPostParams }
|
||||
> = (props) => {
|
||||
const { params } = props ?? {};
|
||||
|
||||
return prometheusQueryPost(params);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type PrometheusQueryPostMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof prometheusQueryPost>>
|
||||
>;
|
||||
|
||||
export type PrometheusQueryPostMutationError = ErrorType<
|
||||
PrometheusErrorResponseSchemaDTO | RenderErrorResponseDTO
|
||||
>;
|
||||
|
||||
/**
|
||||
* @summary Prometheus instant query
|
||||
*/
|
||||
export const usePrometheusQueryPost = <
|
||||
TError = ErrorType<PrometheusErrorResponseSchemaDTO | RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof prometheusQueryPost>>,
|
||||
TError,
|
||||
{ params: PrometheusQueryPostParams },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof prometheusQueryPost>>,
|
||||
TError,
|
||||
{ params: PrometheusQueryPostParams },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getPrometheusQueryPostMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Prometheus-compatible endpoint: the request and response contract is the upstream Prometheus HTTP API (https://prometheus.io/docs/prometheus/latest/querying/api/). Parameters are accepted as URL query parameters or a form-encoded body, on GET and POST alike.
|
||||
* @summary Prometheus range query
|
||||
*/
|
||||
export const prometheusQueryRange = (
|
||||
params: PrometheusQueryRangeParams,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<PrometheusSuccessResponseSchemaDTO>({
|
||||
url: `/prometheus/api/v1/query_range`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getPrometheusQueryRangeQueryKey = (
|
||||
params?: PrometheusQueryRangeParams,
|
||||
) => {
|
||||
return [
|
||||
`/prometheus/api/v1/query_range`,
|
||||
...(params ? [params] : []),
|
||||
] as const;
|
||||
};
|
||||
|
||||
export const getPrometheusQueryRangeQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof prometheusQueryRange>>,
|
||||
TError = ErrorType<PrometheusErrorResponseSchemaDTO | RenderErrorResponseDTO>,
|
||||
>(
|
||||
params: PrometheusQueryRangeParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof prometheusQueryRange>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getPrometheusQueryRangeQueryKey(params);
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof prometheusQueryRange>>
|
||||
> = ({ signal }) => prometheusQueryRange(params, signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof prometheusQueryRange>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type PrometheusQueryRangeQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof prometheusQueryRange>>
|
||||
>;
|
||||
export type PrometheusQueryRangeQueryError = ErrorType<
|
||||
PrometheusErrorResponseSchemaDTO | RenderErrorResponseDTO
|
||||
>;
|
||||
|
||||
/**
|
||||
* @summary Prometheus range query
|
||||
*/
|
||||
|
||||
export function usePrometheusQueryRange<
|
||||
TData = Awaited<ReturnType<typeof prometheusQueryRange>>,
|
||||
TError = ErrorType<PrometheusErrorResponseSchemaDTO | RenderErrorResponseDTO>,
|
||||
>(
|
||||
params: PrometheusQueryRangeParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof prometheusQueryRange>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getPrometheusQueryRangeQueryOptions(params, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Prometheus range query
|
||||
*/
|
||||
export const invalidatePrometheusQueryRange = async (
|
||||
queryClient: QueryClient,
|
||||
params: PrometheusQueryRangeParams,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getPrometheusQueryRangeQueryKey(params) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Prometheus-compatible endpoint: the request and response contract is the upstream Prometheus HTTP API (https://prometheus.io/docs/prometheus/latest/querying/api/). Parameters are accepted as URL query parameters or a form-encoded body, on GET and POST alike.
|
||||
* @summary Prometheus range query
|
||||
*/
|
||||
export const prometheusQueryRangePost = (
|
||||
params: PrometheusQueryRangePostParams,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<PrometheusSuccessResponseSchemaDTO>({
|
||||
url: `/prometheus/api/v1/query_range`,
|
||||
method: 'POST',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getPrometheusQueryRangePostMutationOptions = <
|
||||
TError = ErrorType<PrometheusErrorResponseSchemaDTO | RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof prometheusQueryRangePost>>,
|
||||
TError,
|
||||
{ params: PrometheusQueryRangePostParams },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof prometheusQueryRangePost>>,
|
||||
TError,
|
||||
{ params: PrometheusQueryRangePostParams },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['prometheusQueryRangePost'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof prometheusQueryRangePost>>,
|
||||
{ params: PrometheusQueryRangePostParams }
|
||||
> = (props) => {
|
||||
const { params } = props ?? {};
|
||||
|
||||
return prometheusQueryRangePost(params);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type PrometheusQueryRangePostMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof prometheusQueryRangePost>>
|
||||
>;
|
||||
|
||||
export type PrometheusQueryRangePostMutationError = ErrorType<
|
||||
PrometheusErrorResponseSchemaDTO | RenderErrorResponseDTO
|
||||
>;
|
||||
|
||||
/**
|
||||
* @summary Prometheus range query
|
||||
*/
|
||||
export const usePrometheusQueryRangePost = <
|
||||
TError = ErrorType<PrometheusErrorResponseSchemaDTO | RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof prometheusQueryRangePost>>,
|
||||
TError,
|
||||
{ params: PrometheusQueryRangePostParams },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof prometheusQueryRangePost>>,
|
||||
TError,
|
||||
{ params: PrometheusQueryRangePostParams },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getPrometheusQueryRangePostMutationOptions(options));
|
||||
};
|
||||
@@ -4960,6 +4960,53 @@ export interface DashboardtypesGettablePublicDashboardDataV2DTO {
|
||||
publicDashboard?: DashboardtypesGettablePublicDasbhboardDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesGettableSystemDashboardDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
createdBy?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
image?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
locked: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
orgId: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
schemaVersion: string;
|
||||
source: DashboardtypesSourceDTO;
|
||||
spec: DashboardtypesDashboardSpecDTO;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
tags: TagtypesGettableTagDTO[] | null;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
updatedBy?: string;
|
||||
}
|
||||
|
||||
export enum DashboardtypesPatchOpDTO {
|
||||
add = 'add',
|
||||
remove = 'remove',
|
||||
@@ -7974,6 +8021,164 @@ export interface PreferencetypesUpdatablePreferenceDTO {
|
||||
value?: unknown;
|
||||
}
|
||||
|
||||
export enum PrometheusErrorResponseSchemaDTOErrorType {
|
||||
bad_data = 'bad_data',
|
||||
execution = 'execution',
|
||||
canceled = 'canceled',
|
||||
timeout = 'timeout',
|
||||
internal = 'internal',
|
||||
}
|
||||
export enum PrometheusErrorResponseSchemaDTOStatus {
|
||||
error = 'error',
|
||||
}
|
||||
export interface PrometheusErrorResponseSchemaDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
error: string;
|
||||
/**
|
||||
* @enum bad_data,execution,canceled,timeout,internal
|
||||
* @type string
|
||||
*/
|
||||
errorType: PrometheusErrorResponseSchemaDTOErrorType;
|
||||
/**
|
||||
* @enum error
|
||||
* @type string
|
||||
*/
|
||||
status: PrometheusErrorResponseSchemaDTOStatus;
|
||||
}
|
||||
|
||||
export enum PrometheusMatrixDataSchemaDTOResultType {
|
||||
matrix = 'matrix',
|
||||
}
|
||||
export type PrometheusSamplePairSchemaDTOItem = number | string;
|
||||
|
||||
/**
|
||||
* A [timestamp, value] pair: float unix seconds, then the string-encoded sample value ("NaN", "+Inf", "-Inf" included).
|
||||
* @minItems 2
|
||||
* @maxItems 2
|
||||
* @nullable
|
||||
*/
|
||||
export type PrometheusSamplePairSchemaDTO =
|
||||
| PrometheusSamplePairSchemaDTOItem[]
|
||||
| null;
|
||||
|
||||
export type PrometheusMatrixSeriesSchemaDTOMetricAnyOf = {
|
||||
[key: string]: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type PrometheusMatrixSeriesSchemaDTOMetric =
|
||||
PrometheusMatrixSeriesSchemaDTOMetricAnyOf | null;
|
||||
|
||||
export interface PrometheusMatrixSeriesSchemaDTO {
|
||||
/**
|
||||
* @type object,null
|
||||
*/
|
||||
metric: PrometheusMatrixSeriesSchemaDTOMetric;
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
values: (PrometheusSamplePairSchemaDTO | null)[] | null;
|
||||
}
|
||||
|
||||
export interface PrometheusMatrixDataSchemaDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
result: PrometheusMatrixSeriesSchemaDTO[] | null;
|
||||
/**
|
||||
* @enum matrix
|
||||
* @type string
|
||||
*/
|
||||
resultType: PrometheusMatrixDataSchemaDTOResultType;
|
||||
}
|
||||
|
||||
export type PrometheusVectorSampleSchemaDTOMetricAnyOf = {
|
||||
[key: string]: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* @nullable
|
||||
*/
|
||||
export type PrometheusVectorSampleSchemaDTOMetric =
|
||||
PrometheusVectorSampleSchemaDTOMetricAnyOf | null;
|
||||
|
||||
export interface PrometheusVectorSampleSchemaDTO {
|
||||
/**
|
||||
* @type object,null
|
||||
*/
|
||||
metric: PrometheusVectorSampleSchemaDTOMetric;
|
||||
value: PrometheusSamplePairSchemaDTO | null;
|
||||
}
|
||||
|
||||
export enum PrometheusVectorDataSchemaDTOResultType {
|
||||
vector = 'vector',
|
||||
}
|
||||
export interface PrometheusVectorDataSchemaDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
result: PrometheusVectorSampleSchemaDTO[] | null;
|
||||
/**
|
||||
* @enum vector
|
||||
* @type string
|
||||
*/
|
||||
resultType: PrometheusVectorDataSchemaDTOResultType;
|
||||
}
|
||||
|
||||
export enum PrometheusScalarDataSchemaDTOResultType {
|
||||
scalar = 'scalar',
|
||||
}
|
||||
export interface PrometheusScalarDataSchemaDTO {
|
||||
result: PrometheusSamplePairSchemaDTO | null;
|
||||
/**
|
||||
* @enum scalar
|
||||
* @type string
|
||||
*/
|
||||
resultType: PrometheusScalarDataSchemaDTOResultType;
|
||||
}
|
||||
|
||||
export enum PrometheusStringDataSchemaDTOResultType {
|
||||
string = 'string',
|
||||
}
|
||||
export interface PrometheusStringDataSchemaDTO {
|
||||
result: PrometheusSamplePairSchemaDTO | null;
|
||||
/**
|
||||
* @enum string
|
||||
* @type string
|
||||
*/
|
||||
resultType: PrometheusStringDataSchemaDTOResultType;
|
||||
}
|
||||
|
||||
export type PrometheusQueryDataSchemaDTO =
|
||||
| PrometheusMatrixDataSchemaDTO
|
||||
| PrometheusVectorDataSchemaDTO
|
||||
| PrometheusScalarDataSchemaDTO
|
||||
| PrometheusStringDataSchemaDTO;
|
||||
|
||||
export enum PrometheusSuccessResponseSchemaDTOStatus {
|
||||
success = 'success',
|
||||
}
|
||||
export interface PrometheusSuccessResponseSchemaDTO {
|
||||
data: PrometheusQueryDataSchemaDTO;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
infos?: string[];
|
||||
/**
|
||||
* @enum success
|
||||
* @type string
|
||||
*/
|
||||
status: PrometheusSuccessResponseSchemaDTOStatus;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
warnings?: string[];
|
||||
}
|
||||
|
||||
export interface PromotetypesWrappedIndexDTO {
|
||||
fieldDataType?: TelemetrytypesFieldDataTypeDTO;
|
||||
/**
|
||||
@@ -11313,6 +11518,17 @@ export type MigrateDashboardV2200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetSystemDashboardPathParameters = {
|
||||
name: string;
|
||||
};
|
||||
export type GetSystemDashboard200 = {
|
||||
data: DashboardtypesGettableSystemDashboardDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetFeatures200 = {
|
||||
/**
|
||||
* @type array
|
||||
@@ -12471,3 +12687,115 @@ export type ReplaceVariables200 = {
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type PrometheusQueryParams = {
|
||||
/**
|
||||
* @type string
|
||||
* @description PromQL expression.
|
||||
*/
|
||||
query: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description Evaluation timestamp: RFC3339 or float unix seconds. Defaults to the server's current time.
|
||||
*/
|
||||
time?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description Evaluation timeout: duration string or float seconds.
|
||||
*/
|
||||
timeout?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description Any non-empty value includes query statistics in the response.
|
||||
*/
|
||||
stats?: string;
|
||||
};
|
||||
|
||||
export type PrometheusQueryPostParams = {
|
||||
/**
|
||||
* @type string
|
||||
* @description PromQL expression.
|
||||
*/
|
||||
query: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description Evaluation timestamp: RFC3339 or float unix seconds. Defaults to the server's current time.
|
||||
*/
|
||||
time?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description Evaluation timeout: duration string or float seconds.
|
||||
*/
|
||||
timeout?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description Any non-empty value includes query statistics in the response.
|
||||
*/
|
||||
stats?: string;
|
||||
};
|
||||
|
||||
export type PrometheusQueryRangeParams = {
|
||||
/**
|
||||
* @type string
|
||||
* @description PromQL expression.
|
||||
*/
|
||||
query: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description Range start: RFC3339 or float unix seconds.
|
||||
*/
|
||||
start: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description Range end: RFC3339 or float unix seconds.
|
||||
*/
|
||||
end: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description Resolution step: duration string or float seconds.
|
||||
*/
|
||||
step: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description Evaluation timeout: duration string or float seconds.
|
||||
*/
|
||||
timeout?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description Any non-empty value includes query statistics in the response.
|
||||
*/
|
||||
stats?: string;
|
||||
};
|
||||
|
||||
export type PrometheusQueryRangePostParams = {
|
||||
/**
|
||||
* @type string
|
||||
* @description PromQL expression.
|
||||
*/
|
||||
query: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description Range start: RFC3339 or float unix seconds.
|
||||
*/
|
||||
start: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description Range end: RFC3339 or float unix seconds.
|
||||
*/
|
||||
end: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description Resolution step: duration string or float seconds.
|
||||
*/
|
||||
step: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description Evaluation timeout: duration string or float seconds.
|
||||
*/
|
||||
timeout?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @description Any non-empty value includes query statistics in the response.
|
||||
*/
|
||||
stats?: string;
|
||||
};
|
||||
|
||||
@@ -2,8 +2,10 @@ import { cloneDeep, isEmpty } from 'lodash-es';
|
||||
import { SuccessResponse, Warning } from 'types/api';
|
||||
import { MetricRangePayloadV3 } from 'types/api/metrics/getQueryRange';
|
||||
import {
|
||||
BuilderQuery,
|
||||
DistributionData,
|
||||
MetricRangePayloadV5,
|
||||
QueryEnvelope,
|
||||
QueryRangeRequestV5,
|
||||
RawData,
|
||||
ScalarData,
|
||||
@@ -11,6 +13,11 @@ import {
|
||||
} from 'types/api/v5/queryRange';
|
||||
import { QueryDataV3 } from 'types/api/widgets/getQuery';
|
||||
|
||||
const isBuilderQueryEnvelope = (
|
||||
envelope: QueryEnvelope,
|
||||
): envelope is QueryEnvelope & { spec: BuilderQuery } =>
|
||||
envelope.type === 'builder_query' || envelope.type === 'builder_ai_query';
|
||||
|
||||
function getColName(
|
||||
col: ScalarData['columns'][number],
|
||||
legendMap: Record<string, string>,
|
||||
@@ -409,21 +416,15 @@ export function convertV5ResponseToLegacy(
|
||||
const v5Data = payload?.data;
|
||||
|
||||
const aggregationPerQuery =
|
||||
params?.compositeQuery?.queries
|
||||
?.filter((query) => query.type === 'builder_query')
|
||||
.reduce(
|
||||
(acc, query) => {
|
||||
if (
|
||||
query.type === 'builder_query' &&
|
||||
'aggregations' in query.spec &&
|
||||
query.spec.name
|
||||
) {
|
||||
acc[query.spec.name] = query.spec.aggregations;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, any>,
|
||||
) || {};
|
||||
params?.compositeQuery?.queries?.filter(isBuilderQueryEnvelope).reduce(
|
||||
(acc, query) => {
|
||||
if ('aggregations' in query.spec && query.spec.name) {
|
||||
acc[query.spec.name] = query.spec.aggregations;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, any>,
|
||||
) || {};
|
||||
|
||||
// clickhouse_sql queries have no aggregation metadata; their value columns
|
||||
// are named/keyed by the real SQL alias the response carries (see getColId).
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
QueryBuilderFormula as V5QueryBuilderFormula,
|
||||
QueryEnvelope,
|
||||
QueryRangePayloadV5,
|
||||
RequestType,
|
||||
} from 'types/api/v5/queryRange';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
|
||||
@@ -935,3 +936,41 @@ describe('convertBuilderQueriesToV5 having normalization', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertBuilderQueriesToV5 builder query type', () => {
|
||||
const buildEnvelope = (
|
||||
builderQueryType: IBuilderQuery['builderQueryType'],
|
||||
requestType: RequestType,
|
||||
): QueryEnvelope => {
|
||||
const [envelope] = convertBuilderQueriesToV5(
|
||||
{
|
||||
A: {
|
||||
dataSource: DataSource.TRACES,
|
||||
queryName: 'A',
|
||||
builderQueryType,
|
||||
} as unknown as IBuilderQuery,
|
||||
},
|
||||
requestType,
|
||||
);
|
||||
return envelope;
|
||||
};
|
||||
|
||||
it.each<[RequestType]>([
|
||||
['trace'],
|
||||
['raw'],
|
||||
['time_series'],
|
||||
['scalar'],
|
||||
['distribution'],
|
||||
])('sends builder_ai_query for the %s request type', (requestType) => {
|
||||
expect(buildEnvelope('builder_ai_query', requestType).type).toBe(
|
||||
'builder_ai_query',
|
||||
);
|
||||
});
|
||||
|
||||
it.each<[string, IBuilderQuery['builderQueryType']]>([
|
||||
['an unmarked query', undefined],
|
||||
['an explicitly generic query', 'builder_query'],
|
||||
])('sends builder_query for %s', (_label, builderQueryType) => {
|
||||
expect(buildEnvelope(builderQueryType, 'trace').type).toBe('builder_query');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -364,7 +364,7 @@ export function convertBuilderQueriesToV5(
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'builder_query' as QueryType,
|
||||
type: queryData.builderQueryType ?? 'builder_query',
|
||||
spec,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -16,8 +16,6 @@ import { githubLight } from '@uiw/codemirror-theme-github';
|
||||
import CodeMirror, { EditorView, keymap, Prec } from '@uiw/react-codemirror';
|
||||
import { Button, Card, Collapse, Popover, Tooltip } from 'antd';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
|
||||
import { getValueSuggestions } from 'api/querySuggestions/getValueSuggestion';
|
||||
import cx from 'classnames';
|
||||
import {
|
||||
negationQueryOperatorSuggestions,
|
||||
@@ -54,6 +52,12 @@ import {
|
||||
SUGGESTION_FETCH_DEBOUNCE_MS,
|
||||
SUGGESTIONS_SECTION,
|
||||
} from './constants';
|
||||
import {
|
||||
fetchFieldKeysForQuery,
|
||||
fetchFieldValuesForQuery,
|
||||
SuggestedFieldKey,
|
||||
SuggestedFieldKeysByName,
|
||||
} from './fieldSuggestions';
|
||||
import {
|
||||
combineInitialAndUserExpression,
|
||||
dedupeOptionsByLabel,
|
||||
@@ -264,10 +268,8 @@ function QuerySearch({
|
||||
);
|
||||
|
||||
// Add back the generateOptions function and useEffect
|
||||
const generateOptions = (keys: {
|
||||
[key: string]: QueryKeyDataSuggestionsProps[];
|
||||
}): any[] =>
|
||||
Object.values(keys).flatMap((items: QueryKeyDataSuggestionsProps[]) =>
|
||||
const generateOptions = (keys: SuggestedFieldKeysByName): any[] =>
|
||||
Object.values(keys).flatMap((items: SuggestedFieldKey[]) =>
|
||||
items.map(({ name, fieldDataType, fieldContext }) => ({
|
||||
label: name,
|
||||
type: fieldDataType === 'string' ? 'keyword' : fieldDataType,
|
||||
@@ -320,8 +322,9 @@ function QuerySearch({
|
||||
|
||||
lastFetchedKeyRef.current = searchText || '';
|
||||
|
||||
const response = await getKeySuggestions({
|
||||
signal: dataSource,
|
||||
const response = await fetchFieldKeysForQuery({
|
||||
builderQueryType: queryData.builderQueryType,
|
||||
dataSource,
|
||||
searchText: searchText || '',
|
||||
metricName: debouncedMetricName ?? undefined,
|
||||
signalSource: signalSource as 'meter' | '',
|
||||
@@ -363,6 +366,7 @@ function QuerySearch({
|
||||
hardcodedAttributeKeys,
|
||||
showFilterSuggestionsWithoutMetric,
|
||||
metricNamespace,
|
||||
queryData.builderQueryType,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -496,10 +500,11 @@ function QuerySearch({
|
||||
try {
|
||||
const values = valueSuggestionsOverride
|
||||
? await valueSuggestionsOverride(key, sanitizedSearchText)
|
||||
: await getValueSuggestions({
|
||||
: await fetchFieldValuesForQuery({
|
||||
builderQueryType: queryData.builderQueryType,
|
||||
dataSource,
|
||||
key,
|
||||
searchText: sanitizedSearchText,
|
||||
signal: dataSource,
|
||||
signalSource: signalSource as 'meter' | '',
|
||||
metricName: debouncedMetricName ?? undefined,
|
||||
}).then((response) => {
|
||||
@@ -604,6 +609,7 @@ function QuerySearch({
|
||||
signalSource,
|
||||
toggleSuggestions,
|
||||
valueSuggestionsOverride,
|
||||
queryData.builderQueryType,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import {
|
||||
getAIObservabilityFieldsKeys,
|
||||
getAIObservabilityFieldsValues,
|
||||
} from 'api/generated/services/ai-observability';
|
||||
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
|
||||
import { getValueSuggestions } from 'api/querySuggestions/getValueSuggestion';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import {
|
||||
fetchFieldKeysForQuery,
|
||||
fetchFieldValuesForQuery,
|
||||
} from '../fieldSuggestions';
|
||||
|
||||
jest.mock('api/generated/services/ai-observability', () => ({
|
||||
getAIObservabilityFieldsKeys: jest.fn(),
|
||||
getAIObservabilityFieldsValues: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('api/querySuggestions/getKeySuggestions', () => ({
|
||||
getKeySuggestions: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('api/querySuggestions/getValueSuggestion', () => ({
|
||||
getValueSuggestions: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockedAIKeys = getAIObservabilityFieldsKeys as jest.MockedFunction<
|
||||
typeof getAIObservabilityFieldsKeys
|
||||
>;
|
||||
const mockedGenericKeys = getKeySuggestions as jest.MockedFunction<
|
||||
typeof getKeySuggestions
|
||||
>;
|
||||
const mockedAIValues = getAIObservabilityFieldsValues as jest.MockedFunction<
|
||||
typeof getAIObservabilityFieldsValues
|
||||
>;
|
||||
const mockedGenericValues = getValueSuggestions as jest.MockedFunction<
|
||||
typeof getValueSuggestions
|
||||
>;
|
||||
|
||||
const aiValuesResponse = (
|
||||
values: { stringValues?: string[]; numberValues?: number[] } | null,
|
||||
complete = true,
|
||||
): Awaited<ReturnType<typeof getAIObservabilityFieldsValues>> =>
|
||||
({
|
||||
status: 'success',
|
||||
data: { complete, values },
|
||||
}) as Awaited<ReturnType<typeof getAIObservabilityFieldsValues>>;
|
||||
|
||||
describe('fetchFieldKeysForQuery', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('reads the ai_observability endpoint for a builder_ai_query', async () => {
|
||||
mockedAIKeys.mockResolvedValue({
|
||||
status: 'success',
|
||||
data: {
|
||||
complete: true,
|
||||
keys: { llm_call_count: [{ name: 'llm_call_count' }] },
|
||||
},
|
||||
} as Awaited<ReturnType<typeof getAIObservabilityFieldsKeys>>);
|
||||
|
||||
const keys = await fetchFieldKeysForQuery({
|
||||
builderQueryType: 'builder_ai_query',
|
||||
dataSource: DataSource.TRACES,
|
||||
searchText: 'llm',
|
||||
});
|
||||
|
||||
expect(mockedAIKeys).toHaveBeenCalledWith({ searchText: 'llm' });
|
||||
expect(mockedGenericKeys).not.toHaveBeenCalled();
|
||||
expect(keys.data.data).toStrictEqual({
|
||||
complete: true,
|
||||
keys: { llm_call_count: [{ name: 'llm_call_count' }] },
|
||||
});
|
||||
});
|
||||
|
||||
it.each<[string, 'builder_query' | undefined]>([
|
||||
['an unmarked query', undefined],
|
||||
['an explicitly generic query', 'builder_query'],
|
||||
])('reads the generic endpoint for %s', async (_label, builderQueryType) => {
|
||||
mockedGenericKeys.mockResolvedValue({
|
||||
data: { status: 'success', data: { complete: true, keys: {} } },
|
||||
} as Awaited<ReturnType<typeof getKeySuggestions>>);
|
||||
|
||||
await fetchFieldKeysForQuery({
|
||||
builderQueryType,
|
||||
dataSource: DataSource.TRACES,
|
||||
searchText: 'svc',
|
||||
});
|
||||
|
||||
expect(mockedAIKeys).not.toHaveBeenCalled();
|
||||
expect(mockedGenericKeys).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ signal: DataSource.TRACES, searchText: 'svc' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes a null ai_observability keys payload to an empty map', async () => {
|
||||
mockedAIKeys.mockResolvedValue({
|
||||
status: 'success',
|
||||
data: { complete: false, keys: null },
|
||||
} as Awaited<ReturnType<typeof getAIObservabilityFieldsKeys>>);
|
||||
|
||||
const response = await fetchFieldKeysForQuery({
|
||||
builderQueryType: 'builder_ai_query',
|
||||
dataSource: DataSource.TRACES,
|
||||
searchText: '',
|
||||
});
|
||||
|
||||
expect(response.data.data).toStrictEqual({ complete: false, keys: {} });
|
||||
});
|
||||
|
||||
it('passes the generic response through untouched', async () => {
|
||||
const genericResponse = {
|
||||
data: { status: 'success', data: { complete: true, keys: {} } },
|
||||
} as unknown as Awaited<ReturnType<typeof getKeySuggestions>>;
|
||||
mockedGenericKeys.mockResolvedValue(genericResponse);
|
||||
|
||||
await expect(
|
||||
fetchFieldKeysForQuery({
|
||||
builderQueryType: 'builder_query',
|
||||
dataSource: DataSource.TRACES,
|
||||
searchText: '',
|
||||
}),
|
||||
).resolves.toBe(genericResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchFieldValuesForQuery', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('reads the ai_observability endpoint for a builder_ai_query', async () => {
|
||||
mockedAIValues.mockResolvedValue(
|
||||
aiValuesResponse({ stringValues: ['gpt-4o'], numberValues: [] }),
|
||||
);
|
||||
|
||||
const response = await fetchFieldValuesForQuery({
|
||||
builderQueryType: 'builder_ai_query',
|
||||
dataSource: DataSource.TRACES,
|
||||
key: 'gen_ai.request.model',
|
||||
searchText: 'gpt',
|
||||
});
|
||||
|
||||
expect(mockedGenericValues).not.toHaveBeenCalled();
|
||||
expect(response).toStrictEqual({
|
||||
data: {
|
||||
data: {
|
||||
complete: true,
|
||||
values: { stringValues: ['gpt-4o'], numberValues: [] },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards the key as the name the endpoint expects', async () => {
|
||||
mockedAIValues.mockResolvedValue(aiValuesResponse({}));
|
||||
|
||||
await fetchFieldValuesForQuery({
|
||||
builderQueryType: 'builder_ai_query',
|
||||
dataSource: DataSource.TRACES,
|
||||
key: 'total_tokens',
|
||||
searchText: '',
|
||||
});
|
||||
|
||||
expect(mockedAIValues).toHaveBeenCalledWith({
|
||||
name: 'total_tokens',
|
||||
searchText: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('wraps the ai_observability payload in the envelope the call site unwraps', async () => {
|
||||
mockedAIValues.mockResolvedValue(aiValuesResponse(null, false));
|
||||
|
||||
await expect(
|
||||
fetchFieldValuesForQuery({
|
||||
builderQueryType: 'builder_ai_query',
|
||||
dataSource: DataSource.TRACES,
|
||||
key: 'llm_call_count',
|
||||
searchText: '',
|
||||
}),
|
||||
).resolves.toStrictEqual({
|
||||
data: { data: { complete: false, values: null } },
|
||||
});
|
||||
});
|
||||
|
||||
it.each<[string, 'builder_query' | undefined]>([
|
||||
['an unmarked query', undefined],
|
||||
['an explicitly generic query', 'builder_query'],
|
||||
])('reads the generic endpoint for %s', async (_label, builderQueryType) => {
|
||||
const genericResponse = {
|
||||
data: {
|
||||
data: { complete: false, values: { stringValues: ['frontend'] } },
|
||||
},
|
||||
} as unknown as Awaited<ReturnType<typeof getValueSuggestions>>;
|
||||
mockedGenericValues.mockResolvedValue(genericResponse);
|
||||
|
||||
const response = await fetchFieldValuesForQuery({
|
||||
builderQueryType,
|
||||
dataSource: DataSource.TRACES,
|
||||
key: 'service.name',
|
||||
searchText: 'front',
|
||||
});
|
||||
|
||||
expect(mockedAIValues).not.toHaveBeenCalled();
|
||||
expect(mockedGenericValues).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
signal: DataSource.TRACES,
|
||||
key: 'service.name',
|
||||
searchText: 'front',
|
||||
}),
|
||||
);
|
||||
expect(response).toBe(genericResponse);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import {
|
||||
getAIObservabilityFieldsKeys,
|
||||
getAIObservabilityFieldsValues,
|
||||
} from 'api/generated/services/ai-observability';
|
||||
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
|
||||
import { getValueSuggestions } from 'api/querySuggestions/getValueSuggestion';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
export interface SuggestedFieldKey {
|
||||
name: string;
|
||||
fieldContext?: string;
|
||||
fieldDataType?: string;
|
||||
}
|
||||
|
||||
export type SuggestedFieldKeysByName = Record<string, SuggestedFieldKey[]>;
|
||||
|
||||
export interface SuggestedFieldKeysPayload {
|
||||
complete: boolean;
|
||||
keys: SuggestedFieldKeysByName;
|
||||
}
|
||||
|
||||
export interface SuggestedFieldKeysResponse {
|
||||
data: { data?: SuggestedFieldKeysPayload };
|
||||
}
|
||||
|
||||
export interface SuggestedFieldValuesPayload {
|
||||
complete?: boolean;
|
||||
values?: {
|
||||
stringValues?: string[] | null;
|
||||
numberValues?: number[] | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface SuggestedFieldValuesResponse {
|
||||
data: { data?: SuggestedFieldValuesPayload };
|
||||
}
|
||||
|
||||
interface FetchFieldKeysParams {
|
||||
builderQueryType: IBuilderQuery['builderQueryType'];
|
||||
dataSource: DataSource;
|
||||
searchText: string;
|
||||
metricName?: string;
|
||||
signalSource?: 'meter' | '';
|
||||
metricNamespace?: string;
|
||||
}
|
||||
|
||||
interface FetchFieldValuesParams {
|
||||
builderQueryType: IBuilderQuery['builderQueryType'];
|
||||
dataSource: DataSource;
|
||||
key: string;
|
||||
searchText: string;
|
||||
metricName?: string;
|
||||
signalSource?: 'meter' | '';
|
||||
}
|
||||
|
||||
export const fetchFieldKeysForQuery = async ({
|
||||
builderQueryType,
|
||||
dataSource,
|
||||
searchText,
|
||||
metricName,
|
||||
signalSource,
|
||||
metricNamespace,
|
||||
}: FetchFieldKeysParams): Promise<SuggestedFieldKeysResponse> => {
|
||||
if (builderQueryType === 'builder_ai_query') {
|
||||
const response = await getAIObservabilityFieldsKeys({ searchText });
|
||||
|
||||
return {
|
||||
data: {
|
||||
data: response.data
|
||||
? { complete: response.data.complete, keys: response.data.keys ?? {} }
|
||||
: undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return getKeySuggestions({
|
||||
signal: dataSource,
|
||||
searchText,
|
||||
metricName,
|
||||
signalSource,
|
||||
metricNamespace,
|
||||
});
|
||||
};
|
||||
|
||||
export const fetchFieldValuesForQuery = async ({
|
||||
builderQueryType,
|
||||
dataSource,
|
||||
key,
|
||||
searchText,
|
||||
metricName,
|
||||
signalSource,
|
||||
}: FetchFieldValuesParams): Promise<SuggestedFieldValuesResponse> => {
|
||||
if (builderQueryType === 'builder_ai_query') {
|
||||
const response = await getAIObservabilityFieldsValues({
|
||||
name: key,
|
||||
searchText,
|
||||
});
|
||||
|
||||
return { data: { data: response.data } };
|
||||
}
|
||||
|
||||
// getValueSuggestions' declared response type does not match what the endpoint returns.
|
||||
return getValueSuggestions({
|
||||
signal: dataSource,
|
||||
key,
|
||||
searchText,
|
||||
signalSource,
|
||||
metricName,
|
||||
}) as unknown as Promise<SuggestedFieldValuesResponse>;
|
||||
};
|
||||
@@ -54,7 +54,7 @@ export const QueryV2 = forwardRef(function QueryV2(
|
||||
const { cloneQuery, panelType } = useQueryBuilder();
|
||||
|
||||
const showFunctions = query?.functions?.length > 0;
|
||||
const { dataSource } = query;
|
||||
const { dataSource, builderQueryType } = query;
|
||||
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
|
||||
@@ -94,8 +94,9 @@ export const QueryV2 = forwardRef(function QueryV2(
|
||||
);
|
||||
|
||||
const showSpanScopeSelector = useMemo(
|
||||
() => dataSource === DataSource.TRACES,
|
||||
[dataSource],
|
||||
() =>
|
||||
dataSource === DataSource.TRACES && builderQueryType !== 'builder_ai_query',
|
||||
[dataSource, builderQueryType],
|
||||
);
|
||||
|
||||
const showInlineQuerySearch = useMemo(() => {
|
||||
|
||||
@@ -348,6 +348,19 @@ export const initialQueryMeterWithType: Query = {
|
||||
},
|
||||
};
|
||||
|
||||
export const initialQueryAIWithType: Query = {
|
||||
...initialQueryWithType,
|
||||
builder: {
|
||||
...initialQueryWithType.builder,
|
||||
queryData: [
|
||||
{
|
||||
...initialQueryBuilderFormValuesMap.traces,
|
||||
builderQueryType: 'builder_ai_query',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const operatorsByTypes: Record<LocalDataType, string[]> = {
|
||||
string: Object.values(StringOperators),
|
||||
number: Object.values(NumberOperators),
|
||||
|
||||
@@ -38,19 +38,20 @@ import {
|
||||
TEST_ENDPOINT,
|
||||
} from '../../__tests__/fixtures';
|
||||
|
||||
const SAMPLE_SPAN = JSON.parse(SAMPLE_SPAN_JSON) as {
|
||||
attributes: Record<string, unknown>;
|
||||
resource: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const MAPPED_ATTRIBUTE_KEY = 'gen_ai.content.prompt';
|
||||
|
||||
// Deriving from the sample keeps exactly one key added, so the single `populated` badge assertion below stays exact.
|
||||
const RESULT_SPAN = {
|
||||
attributes: {
|
||||
'my_company.llm.input': 'What is quantum computing?',
|
||||
'llm.input_messages': 'What is quantum computing?',
|
||||
'gen_ai.request.model': 'gpt-4',
|
||||
'gen_ai.usage.total_tokens': 1250,
|
||||
'gen_ai.content.completion': 'Quantum computing leverages...',
|
||||
'gen_ai.content.prompt': 'What is quantum computing?',
|
||||
},
|
||||
resource: {
|
||||
'service.name': 'llm-gateway',
|
||||
'deployment.environment': 'production',
|
||||
...SAMPLE_SPAN.attributes,
|
||||
[MAPPED_ATTRIBUTE_KEY]: SAMPLE_SPAN.attributes['input.value'],
|
||||
},
|
||||
resource: SAMPLE_SPAN.resource,
|
||||
};
|
||||
|
||||
const EDITED_SPAN_JSON = `{
|
||||
@@ -97,7 +98,7 @@ describe('TestTab — sample-span flow', () => {
|
||||
).resolves.toBeInTheDocument();
|
||||
expect(screen.getByTestId('test-result-0')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('test-result-0-attributes')).toHaveTextContent(
|
||||
'gen_ai.content.prompt',
|
||||
MAPPED_ATTRIBUTE_KEY,
|
||||
);
|
||||
expect(screen.getByText('populated')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('test-error')).not.toBeInTheDocument();
|
||||
|
||||
@@ -7,11 +7,14 @@ import { parseSpanInput } from './testPayload';
|
||||
|
||||
export const SAMPLE_SPAN_JSON = `{
|
||||
"attributes": {
|
||||
"my_company.llm.input": "What is quantum computing?",
|
||||
"llm.input_messages": "What is quantum computing?",
|
||||
"gen_ai.request.model": "gpt-4",
|
||||
"gen_ai.usage.total_tokens": 1250,
|
||||
"gen_ai.content.completion": "Quantum computing leverages..."
|
||||
"llm.model_name": "gpt-4o",
|
||||
"llm.provider": "openai",
|
||||
"llm.token_count.prompt": 1024,
|
||||
"llm.token_count.completion": 226,
|
||||
"llm.token_count.prompt_details.cache_read": 512,
|
||||
"input.value": "What is quantum computing?",
|
||||
"output.value": "Quantum computing leverages superposition and entanglement...",
|
||||
"session.id": "chat-8f2e41"
|
||||
},
|
||||
"resource": {
|
||||
"service.name": "llm-gateway",
|
||||
|
||||
@@ -11,7 +11,7 @@ import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
import { QuickFiltersSource, SignalType } from 'components/QuickFilters/types';
|
||||
import WarningPopover from 'components/WarningPopover/WarningPopover';
|
||||
import { AVAILABLE_EXPORT_PANEL_TYPES } from 'constants/panelTypes';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { usePageActions } from 'container/AIAssistant/pageActions/usePageActions';
|
||||
import ExplorerOptionWrapper from 'container/ExplorerOptions/ExplorerOptionWrapper';
|
||||
import { useOptionsMenu } from 'container/OptionsMenu';
|
||||
@@ -51,7 +51,7 @@ import {
|
||||
} from 'utils/explorerUtils';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import { TOOLBAR_VIEWS } from './constants';
|
||||
import { DEFAULT_PANEL_TYPE, TOOLBAR_VIEWS } from './constants';
|
||||
import ListView from './ListView/ListView';
|
||||
import { defaultSelectedColumns } from './ListView/configs';
|
||||
import QuerySection from './QuerySection/QuerySection';
|
||||
@@ -88,7 +88,7 @@ function Explorer(): JSX.Element {
|
||||
const listQueryKeyRef = useRef<any>();
|
||||
|
||||
// Get panel type from URL
|
||||
const panelTypesFromUrl = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
|
||||
const panelTypesFromUrl = useGetPanelTypesQueryParam(DEFAULT_PANEL_TYPE);
|
||||
const [isLoadingQueries, setIsLoadingQueries] = useState<boolean>(false);
|
||||
const [isCancelled, setIsCancelled] = useState(false);
|
||||
|
||||
@@ -118,8 +118,8 @@ function Explorer(): JSX.Element {
|
||||
const defaultQuery = useMemo(
|
||||
(): Query =>
|
||||
updateAllQueriesOperators(
|
||||
initialQueriesMap.traces,
|
||||
PANEL_TYPES.LIST,
|
||||
initialQueryAIWithType,
|
||||
DEFAULT_PANEL_TYPE,
|
||||
DataSource.TRACES,
|
||||
),
|
||||
[updateAllQueriesOperators],
|
||||
@@ -185,8 +185,8 @@ function Explorer(): JSX.Element {
|
||||
const exportDefaultQuery = useMemo(
|
||||
() =>
|
||||
getQueryByPanelType(
|
||||
stagedQuery || initialQueriesMap.traces,
|
||||
panelType || PANEL_TYPES.LIST,
|
||||
stagedQuery || initialQueryAIWithType,
|
||||
panelType || DEFAULT_PANEL_TYPE,
|
||||
),
|
||||
[stagedQuery, panelType],
|
||||
);
|
||||
|
||||
@@ -17,7 +17,7 @@ import ListViewOrderBy from 'components/OrderBy/ListViewOrderBy';
|
||||
import type { TableColumnDef } from 'components/TanStackTableView/types';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { useOptionsMenu } from 'container/OptionsMenu';
|
||||
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/types';
|
||||
@@ -94,7 +94,7 @@ function ListView({
|
||||
paginationQueryData ?? getDefaultPaginationConfig(PER_PAGE_OPTIONS);
|
||||
|
||||
const requestQuery = useMemo(
|
||||
() => getListViewQuery(stagedQuery || initialQueriesMap.traces, orderBy),
|
||||
() => getListViewQuery(stagedQuery || initialQueryAIWithType, orderBy),
|
||||
[stagedQuery, orderBy],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,42 +1,25 @@
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import { memo, useMemo } from 'react';
|
||||
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ExplorerOrderBy from 'container/ExplorerOrderBy';
|
||||
import { OrderByFilterProps } from 'container/QueryBuilder/filters/OrderByFilter/OrderByFilter.interfaces';
|
||||
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
|
||||
import { useGetPanelTypesQueryParam } from 'hooks/queryBuilder/useGetPanelTypesQueryParam';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { DEFAULT_PANEL_TYPE } from '../constants';
|
||||
|
||||
function QuerySection(): JSX.Element {
|
||||
const panelTypes = useGetPanelTypesQueryParam(PANEL_TYPES.LIST);
|
||||
const panelTypes = useGetPanelTypesQueryParam(DEFAULT_PANEL_TYPE);
|
||||
|
||||
const filterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(() => {
|
||||
const isList = panelTypes === PANEL_TYPES.LIST;
|
||||
const config: QueryBuilderProps['filterConfigs'] = {
|
||||
// Only reaches the builder for timeseries/table; list/trace panels use QueryBuilderV2's listViewTracesFilterConfigs.
|
||||
const filterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(
|
||||
() => ({
|
||||
stepInterval: { isHidden: false, isDisabled: false },
|
||||
limit: { isHidden: isList, isDisabled: true },
|
||||
having: { isHidden: isList, isDisabled: true },
|
||||
};
|
||||
|
||||
return config;
|
||||
}, [panelTypes]);
|
||||
|
||||
const renderOrderBy = useCallback(
|
||||
({ query, onChange }: OrderByFilterProps) => (
|
||||
<ExplorerOrderBy query={query} onChange={onChange} />
|
||||
),
|
||||
limit: { isHidden: false, isDisabled: true },
|
||||
having: { isHidden: false, isDisabled: true },
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
const queryComponents = useMemo((): QueryBuilderProps['queryComponents'] => {
|
||||
const shouldRenderCustomOrderBy =
|
||||
panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE;
|
||||
|
||||
return {
|
||||
...(shouldRenderCustomOrderBy ? { renderOrderBy } : {}),
|
||||
};
|
||||
}, [panelTypes, renderOrderBy]);
|
||||
|
||||
const isListViewPanel = useMemo(
|
||||
() => panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE,
|
||||
[panelTypes],
|
||||
@@ -45,14 +28,10 @@ function QuerySection(): JSX.Element {
|
||||
return (
|
||||
<QueryBuilderV2
|
||||
isListViewPanel={isListViewPanel}
|
||||
showTraceOperator
|
||||
config={{ initialDataSource: DataSource.TRACES, queryVariant: 'static' }}
|
||||
queryComponents={queryComponents}
|
||||
panelType={panelTypes}
|
||||
filterConfigs={filterConfigs}
|
||||
showOnlyWhereClause={
|
||||
panelTypes === PANEL_TYPES.LIST || panelTypes === PANEL_TYPES.TRACE
|
||||
}
|
||||
showOnlyWhereClause={isListViewPanel}
|
||||
version="v3" // setting this to v3 as we this is rendered in logs explorer
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -14,7 +14,7 @@ import logEvent from 'api/common/logEvent';
|
||||
import DownloadOptionsMenu from 'components/DownloadOptionsMenu/DownloadOptionsMenu';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { initialQueryAIWithType, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import TraceExplorerControls from 'container/TracesExplorer/Controls';
|
||||
import { getListViewQuery } from 'container/TracesExplorer/explorerUtils';
|
||||
@@ -60,7 +60,7 @@ function TracesView({
|
||||
);
|
||||
|
||||
const transformedQuery = useMemo(
|
||||
() => getListViewQuery(stagedQuery || initialQueriesMap.traces),
|
||||
() => getListViewQuery(stagedQuery || initialQueryAIWithType),
|
||||
[stagedQuery],
|
||||
);
|
||||
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
|
||||
export const DEFAULT_PANEL_TYPE = PANEL_TYPES.TRACE;
|
||||
|
||||
export const TOOLBAR_VIEWS = {
|
||||
list: {
|
||||
name: 'list',
|
||||
|
||||
@@ -37,11 +37,13 @@ const mapQueryFromV5 = (compositeQuery: ICompositeMetricQuery): Query => {
|
||||
|
||||
compositeQuery.queries?.forEach((q) => {
|
||||
const spec = q.spec as BuilderQuery | PromQuery | ClickHouseQuery;
|
||||
if (q.type === 'builder_query') {
|
||||
if (q.type === 'builder_query' || q.type === 'builder_ai_query') {
|
||||
if (spec.name) {
|
||||
builderQueries[spec.name] = convertBuilderQueryToIBuilderQuery(
|
||||
spec as BuilderQuery,
|
||||
);
|
||||
builderQueries[spec.name] = {
|
||||
...convertBuilderQueryToIBuilderQuery(spec as BuilderQuery),
|
||||
builderQueryType: q.type,
|
||||
};
|
||||
// Both share the builder bucket; the AI variant rides on the query itself.
|
||||
builderQueryTypes[spec.name] = 'builder_query';
|
||||
}
|
||||
} else if (q.type === 'builder_formula') {
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Atom, Terminal } from '@signozhq/icons';
|
||||
import { Atom, Sparkles, Terminal } from '@signozhq/icons';
|
||||
import { Tabs } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
@@ -21,15 +21,24 @@ import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interface
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import {
|
||||
getHiddenQueryBuilderFields,
|
||||
getSupportedQueryTypes,
|
||||
supportsAIQuery,
|
||||
} from '../../Panels/capabilities';
|
||||
import {
|
||||
PANEL_KIND_TO_PANEL_TYPE,
|
||||
type PanelKind,
|
||||
} from '../../Panels/types/panelKind';
|
||||
import {
|
||||
AI_QUERY_TAB,
|
||||
type QueryTabKey,
|
||||
resolveActiveQueryTab,
|
||||
toAIQuery,
|
||||
withAIQueryType,
|
||||
} from './utils';
|
||||
|
||||
import styles from './PanelEditorQueryBuilder.module.scss';
|
||||
|
||||
@@ -69,11 +78,20 @@ function PanelEditorQueryBuilder({
|
||||
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
// The AI tab is not a query type — it stamps `builderQueryType` onto the builder
|
||||
// queries (and pins them to traces, the only signal AI queries support).
|
||||
const handleQueryCategoryChange = useCallback(
|
||||
(queryType: string): void => {
|
||||
(nextTab: string): void => {
|
||||
if (nextTab === AI_QUERY_TAB) {
|
||||
redirectWithQueryBuilderData({
|
||||
...toAIQuery(currentQuery),
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
});
|
||||
return;
|
||||
}
|
||||
redirectWithQueryBuilderData({
|
||||
...currentQuery,
|
||||
queryType: queryType as EQueryType,
|
||||
...withAIQueryType(currentQuery, false),
|
||||
queryType: nextTab as EQueryType,
|
||||
});
|
||||
},
|
||||
[currentQuery, redirectWithQueryBuilderData],
|
||||
@@ -101,9 +119,32 @@ function PanelEditorQueryBuilder({
|
||||
);
|
||||
|
||||
const items = useMemo(() => {
|
||||
const supportedQueryTypes = getSupportedQueryTypes(panelKind);
|
||||
const supportedQueryTypes: QueryTabKey[] = getSupportedQueryTypes(panelKind);
|
||||
const supportedTabs = supportsAIQuery(panelKind)
|
||||
? [...supportedQueryTypes, AI_QUERY_TAB]
|
||||
: supportedQueryTypes;
|
||||
|
||||
const queryTypeComponents = {
|
||||
[AI_QUERY_TAB]: {
|
||||
icon: <Sparkles size={14} />,
|
||||
label: 'AI Query Builder',
|
||||
component: (
|
||||
<div className="query-builder-v2-container">
|
||||
<QueryBuilderV2
|
||||
panelType={panelType}
|
||||
filterConfigs={filterConfigs}
|
||||
config={{
|
||||
initialDataSource: DataSource.TRACES,
|
||||
queryVariant: 'static',
|
||||
}}
|
||||
version="v3"
|
||||
isListViewPanel={panelType === PANEL_TYPES.LIST}
|
||||
queryComponents={{}}
|
||||
savePreviousQuery
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
[EQueryType.QUERY_BUILDER]: {
|
||||
icon: <Atom size={14} />,
|
||||
label: 'Query Builder',
|
||||
@@ -138,15 +179,15 @@ function PanelEditorQueryBuilder({
|
||||
},
|
||||
};
|
||||
|
||||
return supportedQueryTypes.map((queryType) => ({
|
||||
key: queryType,
|
||||
return supportedTabs.map((tabKey) => ({
|
||||
key: tabKey,
|
||||
label: (
|
||||
<div className={styles.queryTypeTab}>
|
||||
{queryTypeComponents[queryType].icon}
|
||||
<Typography>{queryTypeComponents[queryType].label}</Typography>
|
||||
{queryTypeComponents[tabKey].icon}
|
||||
<Typography>{queryTypeComponents[tabKey].label}</Typography>
|
||||
</div>
|
||||
),
|
||||
children: queryTypeComponents[queryType].component,
|
||||
children: queryTypeComponents[tabKey].component,
|
||||
}));
|
||||
}, [panelKind, panelType, filterConfigs, isDarkMode]);
|
||||
|
||||
@@ -163,7 +204,7 @@ function PanelEditorQueryBuilder({
|
||||
className={cx(styles.tabsContainer, {
|
||||
[styles.stickyNav]: stickyHeader,
|
||||
})}
|
||||
activeKey={currentQuery.queryType}
|
||||
activeKey={resolveActiveQueryTab(currentQuery)}
|
||||
onChange={handleQueryCategoryChange}
|
||||
tabBarExtraContent={
|
||||
<span className={styles.runQueryBtnContainer}>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { OPERATORS } from 'constants/queryBuilder';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import PanelEditorQueryBuilder from '../PanelEditorQueryBuilder';
|
||||
|
||||
@@ -61,6 +62,7 @@ function lastQueryBuilderProps(): {
|
||||
panelType: string;
|
||||
isListViewPanel: boolean;
|
||||
filterConfigs: unknown;
|
||||
config?: unknown;
|
||||
} {
|
||||
const calls = mockQueryBuilderV2.mock.calls;
|
||||
return calls[calls.length - 1][0];
|
||||
@@ -70,15 +72,20 @@ describe('PanelEditorQueryBuilder query-type tabs (driven by the capabilities gu
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUseQueryBuilder.mockReturnValue({
|
||||
currentQuery: { queryType: EQueryType.QUERY_BUILDER },
|
||||
currentQuery: {
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
builder: { queryData: [] },
|
||||
},
|
||||
redirectWithQueryBuilderData: jest.fn(),
|
||||
updateAllQueriesOperators: jest.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
it('shows only the Query Builder tab for the List kind', () => {
|
||||
it('shows only the Query Builder tabs for the List kind', () => {
|
||||
renderBuilder('signoz/ListPanel', TelemetrytypesSignalDTO.logs);
|
||||
|
||||
expect(screen.getByText('Query Builder')).toBeInTheDocument();
|
||||
expect(screen.getByText('AI Query Builder')).toBeInTheDocument();
|
||||
expect(screen.queryByText('ClickHouse Query')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('PromQL')).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -91,21 +98,62 @@ describe('PanelEditorQueryBuilder query-type tabs (driven by the capabilities gu
|
||||
expect(screen.queryByText('PromQL')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows all three tabs for the Time Series kind', () => {
|
||||
it('shows all four tabs for the Time Series kind', () => {
|
||||
renderBuilder('signoz/TimeSeriesPanel');
|
||||
|
||||
expect(screen.getByText('Query Builder')).toBeInTheDocument();
|
||||
expect(screen.getByText('AI Query Builder')).toBeInTheDocument();
|
||||
expect(screen.getByText('ClickHouse Query')).toBeInTheDocument();
|
||||
expect(screen.getByText('PromQL')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The AI tab is derived from `builderQueryType`, not from a stored tab key.
|
||||
it('activates the AI tab when the builder query carries the AI envelope tag', () => {
|
||||
mockUseQueryBuilder.mockReturnValue({
|
||||
currentQuery: {
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
builder: { queryData: [{ builderQueryType: 'builder_ai_query' }] },
|
||||
},
|
||||
redirectWithQueryBuilderData: jest.fn(),
|
||||
updateAllQueriesOperators: jest.fn(),
|
||||
});
|
||||
|
||||
renderBuilder('signoz/TimeSeriesPanel');
|
||||
|
||||
expect(
|
||||
screen.getByRole('tab', { name: 'AI Query Builder', selected: true }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('pins the AI tab builder to traces so the signal cannot be changed', () => {
|
||||
mockUseQueryBuilder.mockReturnValue({
|
||||
currentQuery: {
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
builder: { queryData: [{ builderQueryType: 'builder_ai_query' }] },
|
||||
},
|
||||
redirectWithQueryBuilderData: jest.fn(),
|
||||
updateAllQueriesOperators: jest.fn(),
|
||||
});
|
||||
|
||||
renderBuilder('signoz/TimeSeriesPanel');
|
||||
|
||||
expect(lastQueryBuilderProps().config).toStrictEqual({
|
||||
initialDataSource: DataSource.TRACES,
|
||||
queryVariant: 'static',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('PanelEditorQueryBuilder field visibility (driven by the capabilities guard)', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUseQueryBuilder.mockReturnValue({
|
||||
currentQuery: { queryType: EQueryType.QUERY_BUILDER },
|
||||
currentQuery: {
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
builder: { queryData: [] },
|
||||
},
|
||||
redirectWithQueryBuilderData: jest.fn(),
|
||||
updateAllQueriesOperators: jest.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import {
|
||||
AI_QUERY_TAB,
|
||||
isAIQuery,
|
||||
resolveActiveQueryTab,
|
||||
toAIQuery,
|
||||
withAIQueryType,
|
||||
} from '../utils';
|
||||
|
||||
function makeQuery(
|
||||
queryData: Record<string, unknown>[],
|
||||
queryType: EQueryType = EQueryType.QUERY_BUILDER,
|
||||
): Query {
|
||||
return {
|
||||
queryType,
|
||||
builder: { queryData, queryFormulas: [], queryTraceOperator: [] },
|
||||
promql: [],
|
||||
clickhouse_sql: [],
|
||||
id: 'test',
|
||||
} as unknown as Query;
|
||||
}
|
||||
|
||||
describe('isAIQuery', () => {
|
||||
it('is true when any builder query carries the AI envelope tag', () => {
|
||||
expect(
|
||||
isAIQuery(
|
||||
makeQuery([{ queryName: 'A' }, { builderQueryType: 'builder_ai_query' }]),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('is false for plain builder queries and for an empty builder', () => {
|
||||
expect(isAIQuery(makeQuery([{ queryName: 'A' }]))).toBe(false);
|
||||
expect(isAIQuery(makeQuery([]))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveActiveQueryTab', () => {
|
||||
it('selects the AI tab for a tagged builder query', () => {
|
||||
expect(
|
||||
resolveActiveQueryTab(makeQuery([{ builderQueryType: 'builder_ai_query' }])),
|
||||
).toBe(AI_QUERY_TAB);
|
||||
});
|
||||
|
||||
it('selects the query type for an untagged query', () => {
|
||||
expect(resolveActiveQueryTab(makeQuery([{ queryName: 'A' }]))).toBe(
|
||||
EQueryType.QUERY_BUILDER,
|
||||
);
|
||||
});
|
||||
|
||||
// A PromQL panel reads its queries from a different bucket, so a stale tag on the
|
||||
// builder bucket must not steal the active tab.
|
||||
it('keeps PromQL selected even if the builder bucket carries a tag', () => {
|
||||
expect(
|
||||
resolveActiveQueryTab(
|
||||
makeQuery([{ builderQueryType: 'builder_ai_query' }], EQueryType.PROM),
|
||||
),
|
||||
).toBe(EQueryType.PROM);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toAIQuery', () => {
|
||||
// The backend decodes a builder_ai_query spec as QueryBuilderQuery[TraceAggregation],
|
||||
// which has no `metricName` — a carried-over metrics aggregation fails the request.
|
||||
it('re-seeds a metrics query onto traces, dropping the metric aggregation', () => {
|
||||
const result = toAIQuery(
|
||||
makeQuery([
|
||||
{
|
||||
queryName: 'A',
|
||||
dataSource: DataSource.METRICS,
|
||||
aggregations: [{ metricName: 'signoz_latency_bucket' }],
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const [queryData] = result.builder.queryData;
|
||||
expect(queryData.dataSource).toBe(DataSource.TRACES);
|
||||
expect(queryData.aggregations).toStrictEqual([{ expression: 'count() ' }]);
|
||||
expect(queryData.builderQueryType).toBe('builder_ai_query');
|
||||
});
|
||||
|
||||
it('keeps the filter on a query already using traces', () => {
|
||||
const result = toAIQuery(
|
||||
makeQuery([
|
||||
{
|
||||
queryName: 'A',
|
||||
dataSource: DataSource.TRACES,
|
||||
filter: { expression: "service.name = 'checkout'" },
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
expect(result.builder.queryData[0].filter).toStrictEqual({
|
||||
expression: "service.name = 'checkout'",
|
||||
});
|
||||
expect(result.builder.queryData[0].builderQueryType).toBe('builder_ai_query');
|
||||
});
|
||||
|
||||
it('preserves the query name when re-seeding', () => {
|
||||
const result = toAIQuery(
|
||||
makeQuery([{ queryName: 'B', dataSource: DataSource.LOGS }]),
|
||||
);
|
||||
|
||||
expect(result.builder.queryData[0].queryName).toBe('B');
|
||||
});
|
||||
});
|
||||
|
||||
describe('withAIQueryType', () => {
|
||||
it('stamps the tag onto every builder query', () => {
|
||||
const result = withAIQueryType(
|
||||
makeQuery([{ queryName: 'A' }, { queryName: 'B' }]),
|
||||
true,
|
||||
);
|
||||
|
||||
expect(
|
||||
result.builder.queryData.map((item) => item.builderQueryType),
|
||||
).toStrictEqual(['builder_ai_query', 'builder_ai_query']);
|
||||
});
|
||||
|
||||
it('deletes the key when clearing, rather than setting undefined', () => {
|
||||
const result = withAIQueryType(
|
||||
makeQuery([{ queryName: 'A', builderQueryType: 'builder_ai_query' }]),
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result.builder.queryData[0]).not.toHaveProperty('builderQueryType');
|
||||
expect(result.builder.queryData[0]).toStrictEqual({ queryName: 'A' });
|
||||
});
|
||||
|
||||
it('returns the query untouched when it already matches', () => {
|
||||
const tagged = makeQuery([{ builderQueryType: 'builder_ai_query' }]);
|
||||
const plain = makeQuery([{ queryName: 'A' }]);
|
||||
|
||||
expect(withAIQueryType(tagged, true)).toBe(tagged);
|
||||
expect(withAIQueryType(plain, false)).toBe(plain);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { initialQueryBuilderFormValuesMap } from 'constants/queryBuilder';
|
||||
import type {
|
||||
IBuilderQuery,
|
||||
Query,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
/**
|
||||
* Tab key for the AI query builder. Deliberately not an `EQueryType`: an AI query is
|
||||
* a builder query carrying `builderQueryType: 'builder_ai_query'`, so the query type
|
||||
* on the wire stays `builder` and only the per-query envelope tag differs. Keeping the
|
||||
* tab out of the enum leaves that tag the single source of truth.
|
||||
*/
|
||||
export const AI_QUERY_TAB = 'ai_builder' as const;
|
||||
|
||||
export type QueryTabKey = EQueryType | typeof AI_QUERY_TAB;
|
||||
|
||||
export function isAIQuery(query: Query): boolean {
|
||||
return query.builder.queryData.some(
|
||||
(item) => item.builderQueryType === 'builder_ai_query',
|
||||
);
|
||||
}
|
||||
|
||||
/** The tab to highlight — derived from the queries, never stored separately. */
|
||||
export function resolveActiveQueryTab(query: Query): QueryTabKey {
|
||||
return query.queryType === EQueryType.QUERY_BUILDER && isAIQuery(query)
|
||||
? AI_QUERY_TAB
|
||||
: query.queryType;
|
||||
}
|
||||
|
||||
/** Carried across a signal switch, mirroring the builder's own datasource selector. */
|
||||
const PRESERVED_ON_SIGNAL_SWITCH = ['queryName', 'expression'];
|
||||
|
||||
/**
|
||||
* Re-seed a non-traces query with the traces defaults, the way `handleChangeDataSource`
|
||||
* does. AI queries are traces-only, and a leftover metrics aggregation carries
|
||||
* `metricName` — a field the backend rejects on a trace spec. A query already on traces
|
||||
* keeps its filters, so switching tabs on a trace query is non-destructive.
|
||||
*/
|
||||
function toTracesQueryData(item: IBuilderQuery): IBuilderQuery {
|
||||
if (item.dataSource === DataSource.TRACES) {
|
||||
return item;
|
||||
}
|
||||
|
||||
const tracesDefaults = Object.fromEntries(
|
||||
Object.entries(initialQueryBuilderFormValuesMap[DataSource.TRACES]).filter(
|
||||
([key]) => !PRESERVED_ON_SIGNAL_SWITCH.includes(key),
|
||||
),
|
||||
);
|
||||
return { ...item, ...tracesDefaults, dataSource: DataSource.TRACES };
|
||||
}
|
||||
|
||||
/** Move a query onto the AI builder: pin every query to traces and tag it. */
|
||||
export function toAIQuery(query: Query): Query {
|
||||
return {
|
||||
...query,
|
||||
builder: {
|
||||
...query.builder,
|
||||
queryData: query.builder.queryData.map((item) => ({
|
||||
...toTracesQueryData(item),
|
||||
builderQueryType: 'builder_ai_query' as const,
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamp or clear `builderQueryType` across every builder query. Returns the query
|
||||
* untouched when it already matches, and deletes the key rather than setting it to
|
||||
* `undefined` — the dirty checks compare by value, so a stray key reads as an edit.
|
||||
*/
|
||||
export function withAIQueryType(query: Query, enabled: boolean): Query {
|
||||
const needsUpdate = query.builder.queryData.some(
|
||||
(item) => (item.builderQueryType === 'builder_ai_query') !== enabled,
|
||||
);
|
||||
if (!needsUpdate) {
|
||||
return query;
|
||||
}
|
||||
|
||||
return {
|
||||
...query,
|
||||
builder: {
|
||||
...query.builder,
|
||||
queryData: query.builder.queryData.map((item): IBuilderQuery => {
|
||||
const { builderQueryType: _dropped, ...rest } = item;
|
||||
return enabled ? { ...rest, builderQueryType: 'builder_ai_query' } : rest;
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -5,7 +5,10 @@ import { handleQueryChange } from 'container/NewWidget/utils';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { resolveQueryType } from '../../../Panels/capabilities';
|
||||
import {
|
||||
resolveQueryType,
|
||||
supportsAIQuery,
|
||||
} from '../../../Panels/capabilities';
|
||||
import { getBuilderQueries } from '../../../Panels/utils/getBuilderQueries';
|
||||
import { toPerses } from '../../../queryV5/persesQueryAdapters';
|
||||
import { getSwitchedPluginSpec } from '../../getSwitchedPluginSpec';
|
||||
@@ -19,6 +22,7 @@ jest.mock('container/NewWidget/utils', () => ({
|
||||
}));
|
||||
jest.mock('../../../Panels/capabilities', () => ({
|
||||
resolveQueryType: jest.fn(),
|
||||
supportsAIQuery: jest.fn(),
|
||||
}));
|
||||
jest.mock('../../../queryV5/persesQueryAdapters', () => ({
|
||||
toPerses: jest.fn(),
|
||||
@@ -33,6 +37,7 @@ jest.mock('../../../Panels/utils/getBuilderQueries', () => ({
|
||||
const mockUseQueryBuilder = useQueryBuilder as unknown as jest.Mock;
|
||||
const mockHandleQueryChange = handleQueryChange as unknown as jest.Mock;
|
||||
const mockResolveQueryType = resolveQueryType as unknown as jest.Mock;
|
||||
const mockSupportsAIQuery = supportsAIQuery as unknown as jest.Mock;
|
||||
const mockToPerses = toPerses as unknown as jest.Mock;
|
||||
const mockGetSwitchedPluginSpec = getSwitchedPluginSpec as unknown as jest.Mock;
|
||||
const mockGetBuilderQueries = getBuilderQueries as unknown as jest.Mock;
|
||||
@@ -96,7 +101,11 @@ describe('usePanelTypeSwitch', () => {
|
||||
|
||||
it('does nothing when switching to the current kind', () => {
|
||||
const setSpec = jest.fn();
|
||||
const state = builderState({ id: 'q', queryType: 'builder' } as Query);
|
||||
const state = builderState({
|
||||
id: 'q',
|
||||
queryType: 'builder',
|
||||
builder: { queryData: [] },
|
||||
} as unknown as Query);
|
||||
mockUseQueryBuilder.mockReturnValue(state);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
@@ -114,7 +123,11 @@ describe('usePanelTypeSwitch', () => {
|
||||
|
||||
it('on first visit: transforms the query and resets the spec to the new kind', () => {
|
||||
const setSpec = jest.fn();
|
||||
const tableQuery = { id: 'table-current', queryType: 'builder' } as Query;
|
||||
const tableQuery = {
|
||||
id: 'table-current',
|
||||
queryType: 'builder',
|
||||
builder: { queryData: [] },
|
||||
} as unknown as Query;
|
||||
const state = builderState(tableQuery);
|
||||
mockUseQueryBuilder.mockReturnValue(state);
|
||||
|
||||
@@ -142,7 +155,11 @@ describe('usePanelTypeSwitch', () => {
|
||||
it('seeds timestamp-desc Order By on every query when switching to a List panel', () => {
|
||||
const setSpec = jest.fn();
|
||||
mockUseQueryBuilder.mockReturnValue(
|
||||
builderState({ id: 'ts-current', queryType: 'builder' } as Query),
|
||||
builderState({
|
||||
id: 'ts-current',
|
||||
queryType: 'builder',
|
||||
builder: { queryData: [] },
|
||||
} as unknown as Query),
|
||||
);
|
||||
mockHandleQueryChange.mockReturnValue({
|
||||
id: 'transformed',
|
||||
@@ -169,7 +186,11 @@ describe('usePanelTypeSwitch', () => {
|
||||
|
||||
it('coerces the query type when the new kind disallows it (promql → List)', () => {
|
||||
const setSpec = jest.fn();
|
||||
const promQuery = { id: 'prom', queryType: 'promql' } as Query;
|
||||
const promQuery = {
|
||||
id: 'prom',
|
||||
queryType: 'promql',
|
||||
builder: { queryData: [] },
|
||||
} as unknown as Query;
|
||||
mockUseQueryBuilder.mockReturnValue(builderState(promQuery));
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
@@ -191,10 +212,88 @@ describe('usePanelTypeSwitch', () => {
|
||||
expect((queryArg as Query).queryType).toBe('builder');
|
||||
});
|
||||
|
||||
// `handleQueryChange` rebuilds from a field allow-list that omits `builderQueryType`,
|
||||
// so the tag has to be re-applied after the rebuild or the AI tab silently reverts.
|
||||
it('re-applies the AI envelope tag when the new kind supports AI queries', () => {
|
||||
const setSpec = jest.fn();
|
||||
mockSupportsAIQuery.mockReturnValue(true);
|
||||
mockHandleQueryChange.mockReturnValue({
|
||||
id: 'transformed',
|
||||
queryType: 'builder',
|
||||
builder: { queryData: [{ orderBy: [] }] },
|
||||
} as unknown as Query);
|
||||
const aiQuery = {
|
||||
id: 'ai-current',
|
||||
queryType: 'builder',
|
||||
builder: { queryData: [{ builderQueryType: 'builder_ai_query' }] },
|
||||
} as unknown as Query;
|
||||
const state = builderState(aiQuery);
|
||||
mockUseQueryBuilder.mockReturnValue(state);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePanelTypeSwitch({
|
||||
spec: makeSpec('signoz/TimeSeriesPanel', {}, TABLE_QUERIES),
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
setSpec,
|
||||
}),
|
||||
);
|
||||
act(() => result.current.onChangePanelKind('signoz/TablePanel'));
|
||||
|
||||
const redirected = state.redirectWithQueryBuilderData.mock
|
||||
.calls[0][0] as Query;
|
||||
expect(redirected.builder.queryData[0].builderQueryType).toBe(
|
||||
'builder_ai_query',
|
||||
);
|
||||
});
|
||||
|
||||
it('drops the AI envelope tag when the new kind has no AI tab', () => {
|
||||
const setSpec = jest.fn();
|
||||
mockSupportsAIQuery.mockReturnValue(false);
|
||||
mockHandleQueryChange.mockReturnValue({
|
||||
id: 'transformed',
|
||||
queryType: 'builder',
|
||||
builder: { queryData: [{ orderBy: [] }] },
|
||||
} as unknown as Query);
|
||||
const aiQuery = {
|
||||
id: 'ai-current',
|
||||
queryType: 'builder',
|
||||
builder: { queryData: [{ builderQueryType: 'builder_ai_query' }] },
|
||||
} as unknown as Query;
|
||||
const state = builderState(aiQuery);
|
||||
mockUseQueryBuilder.mockReturnValue(state);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
usePanelTypeSwitch({
|
||||
spec: makeSpec('signoz/TimeSeriesPanel', {}, TABLE_QUERIES),
|
||||
panelType: PANEL_TYPES.TIME_SERIES,
|
||||
setSpec,
|
||||
}),
|
||||
);
|
||||
act(() => result.current.onChangePanelKind('signoz/ListPanel'));
|
||||
|
||||
// The rebuild receives an untagged query…
|
||||
const [, queryArg] = mockHandleQueryChange.mock.calls[0];
|
||||
expect((queryArg as Query).builder.queryData[0]).not.toHaveProperty(
|
||||
'builderQueryType',
|
||||
);
|
||||
// …and nothing re-applies it afterwards.
|
||||
const redirected = state.redirectWithQueryBuilderData.mock
|
||||
.calls[0][0] as Query;
|
||||
expect(redirected.builder.queryData[0].builderQueryType).toBeUndefined();
|
||||
});
|
||||
|
||||
it('restores the original kind verbatim on switch-back (reversibility)', () => {
|
||||
const setSpec = jest.fn();
|
||||
const tableQuery = { id: 'table-current', queryType: 'builder' } as Query;
|
||||
const listQuery = { id: 'list-current', queryType: 'builder' } as Query;
|
||||
const tableQuery = {
|
||||
id: 'table-current',
|
||||
queryType: 'builder',
|
||||
builder: { queryData: [] },
|
||||
} as unknown as Query;
|
||||
const listQuery = {
|
||||
id: 'list-current',
|
||||
queryType: 'builder',
|
||||
builder: { queryData: [] },
|
||||
} as unknown as Query;
|
||||
let state = builderState(tableQuery);
|
||||
mockUseQueryBuilder.mockImplementation(() => state);
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import type {
|
||||
Query,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { resolveQueryType } from '../../Panels/capabilities';
|
||||
import { resolveQueryType, supportsAIQuery } from '../../Panels/capabilities';
|
||||
import {
|
||||
PANEL_KIND_TO_PANEL_TYPE,
|
||||
type PanelKind,
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
getSwitchedPluginSpec,
|
||||
type SwitchedPluginSpec,
|
||||
} from '../getSwitchedPluginSpec';
|
||||
import { isAIQuery, withAIQueryType } from '../PanelEditorQueryBuilder/utils';
|
||||
|
||||
// V1's handleQueryChange clears orderBy for lists; re-seed the fresh-list default (timestamp desc).
|
||||
const DEFAULT_LIST_ORDER_BY: OrderByPayload[] = [
|
||||
@@ -139,16 +140,24 @@ export function usePanelTypeSwitch({
|
||||
// First visit → coerce the query type if the new kind disallows it, then
|
||||
// rebuild the builder query for the new type.
|
||||
const queryType = resolveQueryType(newKind, query.queryType);
|
||||
// AI-ness rides on the query, not on `queryType`, so `resolveQueryType` can't
|
||||
// see it: carry it across only when the new kind has an AI tab to surface it.
|
||||
const keepAIQueryType = supportsAIQuery(newKind) && isAIQuery(query);
|
||||
const transformed = handleQueryChange(
|
||||
newPanelType as keyof PartialPanelTypes,
|
||||
{ ...query, queryType },
|
||||
{ ...withAIQueryType(query, false), queryType },
|
||||
panelTypeRef.current,
|
||||
);
|
||||
// Match a fresh list panel's default order so the builder's Order By isn't empty.
|
||||
const nextQuery =
|
||||
const reordered =
|
||||
newPanelType === PANEL_TYPES.LIST
|
||||
? withDefaultListOrder(transformed)
|
||||
: transformed;
|
||||
// `handleQueryChange` rebuilds each query from an allow-list of fields that
|
||||
// doesn't include `builderQueryType`, so re-stamp it after the rebuild.
|
||||
const nextQuery = keepAIQueryType
|
||||
? withAIQueryType(reordered, true)
|
||||
: reordered;
|
||||
const signal = getBuilderQueries(currentSpec.queries)[0]
|
||||
?.signal as TelemetrytypesSignalDTO;
|
||||
|
||||
|
||||
@@ -39,6 +39,15 @@ export function isQueryTypeSupportedByPanelKind(
|
||||
return getSupportedQueryTypes(kind).includes(queryType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a kind offers the AI query builder. Separate from `supportedQueryTypes`
|
||||
* because an AI query is a builder query carrying `builderQueryType`, not its own
|
||||
* `EQueryType` — the tab is UI state, the wire type stays `builder`.
|
||||
*/
|
||||
export function supportsAIQuery(kind: PanelKind): boolean {
|
||||
return getPanelDefinition(kind).supportsAIQuery === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Master guard: is this panel kind renderable with this query type (and, in builder
|
||||
* mode, this signal)? ClickHouse/PromQL queries carry no signal, so the signal is
|
||||
|
||||
@@ -19,6 +19,7 @@ export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
|
||||
EQueryType.CLICKHOUSE,
|
||||
EQueryType.PROM,
|
||||
],
|
||||
supportsAIQuery: true,
|
||||
queryBuilderFields: {},
|
||||
actions: {
|
||||
view: true,
|
||||
|
||||
@@ -19,6 +19,7 @@ export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
|
||||
EQueryType.CLICKHOUSE,
|
||||
EQueryType.PROM,
|
||||
],
|
||||
supportsAIQuery: true,
|
||||
queryBuilderFields: {},
|
||||
actions: {
|
||||
view: true,
|
||||
|
||||
@@ -19,6 +19,7 @@ export const definition: PanelDefinition<'signoz/ListPanel'> = {
|
||||
// hide `limit` (the server paginates raw spans). Mirrors QueryBuilderV2's internal
|
||||
// list configs — the capabilities guard is the single source for both.
|
||||
supportedQueryTypes: [EQueryType.QUERY_BUILDER],
|
||||
supportsAIQuery: true,
|
||||
queryBuilderFields: {
|
||||
default: {
|
||||
stepInterval: { isHidden: true, isDisabled: true },
|
||||
|
||||
@@ -19,6 +19,7 @@ export const definition: PanelDefinition<'signoz/NumberPanel'> = {
|
||||
EQueryType.CLICKHOUSE,
|
||||
EQueryType.PROM,
|
||||
],
|
||||
supportsAIQuery: true,
|
||||
queryBuilderFields: {},
|
||||
actions: {
|
||||
view: true,
|
||||
|
||||
@@ -15,6 +15,7 @@ export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
],
|
||||
supportedQueryTypes: [EQueryType.QUERY_BUILDER, EQueryType.CLICKHOUSE],
|
||||
supportsAIQuery: true,
|
||||
queryBuilderFields: {},
|
||||
actions: {
|
||||
view: true,
|
||||
|
||||
@@ -15,6 +15,7 @@ export const definition: PanelDefinition<'signoz/TablePanel'> = {
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
],
|
||||
supportedQueryTypes: [EQueryType.QUERY_BUILDER, EQueryType.CLICKHOUSE],
|
||||
supportsAIQuery: true,
|
||||
queryBuilderFields: {},
|
||||
// Tables carry tabular data worth exporting (V1 parity: download is table-only).
|
||||
actions: {
|
||||
|
||||
@@ -19,6 +19,7 @@ export const definition: PanelDefinition<'signoz/TimeSeriesPanel'> = {
|
||||
EQueryType.CLICKHOUSE,
|
||||
EQueryType.PROM,
|
||||
],
|
||||
supportsAIQuery: true,
|
||||
queryBuilderFields: {},
|
||||
actions: {
|
||||
view: true,
|
||||
|
||||
@@ -48,6 +48,8 @@ export interface PanelDefinition<K extends PanelKind = PanelKind> {
|
||||
supportedSignals: TelemetrytypesSignalDTO[];
|
||||
/** Query languages this kind supports (Query Builder / ClickHouse / PromQL). */
|
||||
supportedQueryTypes: EQueryType[];
|
||||
/** Kind offers the AI query builder — a traces-only builder variant, not its own query language. */
|
||||
supportsAIQuery?: boolean;
|
||||
/** Query-builder fields this kind hides/disables, optionally per signal (`{}` hides none). */
|
||||
queryBuilderFields: QueryBuilderFieldRule;
|
||||
actions: PanelActionCapabilities;
|
||||
|
||||
@@ -5,8 +5,8 @@ import type {
|
||||
import type { BuilderQuery } from 'types/api/v5/queryRange';
|
||||
|
||||
/**
|
||||
* Flattens a panel's queries into its builder queries, unwrapping
|
||||
* `CompositeQuery` envelopes. Non-builder kinds (PromQL, ClickHouseSQL, Formula,
|
||||
* Flattens a panel's queries into its builder queries (`builder_query` and its AI
|
||||
* variant), unwrapping `CompositeQuery` envelopes. Non-builder kinds (PromQL, ClickHouseSQL, Formula,
|
||||
* TraceOperator) are dropped — they lack the legend/groupBy/aggregation context
|
||||
* downstream code needs. Returns the generated v5 `BuilderQuery` shape directly.
|
||||
*/
|
||||
@@ -22,7 +22,7 @@ export function getBuilderQueries(
|
||||
}
|
||||
if (plugin.kind === 'signoz/CompositeQuery') {
|
||||
(plugin.spec.queries || []).forEach((sub) => {
|
||||
if (sub.type === 'builder_query') {
|
||||
if (sub.type === 'builder_query' || sub.type === 'builder_ai_query') {
|
||||
flattened.push(sub.spec as BuilderQuery);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,7 +2,11 @@ import type {
|
||||
DashboardtypesQueryDTO,
|
||||
Querybuildertypesv5QueryEnvelopeDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import {
|
||||
initialQueriesMap,
|
||||
initialQueryAIWithType,
|
||||
PANEL_TYPES,
|
||||
} from 'constants/queryBuilder';
|
||||
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
@@ -171,6 +175,23 @@ describe('persesQueryAdapters', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves an AI builder query through toPerses → fromPerses', () => {
|
||||
const original: Query = initialQueryAIWithType;
|
||||
|
||||
const perses = toPerses(original, PANEL_TYPES.TIME_SERIES);
|
||||
const { queries } = perses[0].spec.plugin.spec as {
|
||||
queries: Querybuildertypesv5QueryEnvelopeDTO[];
|
||||
};
|
||||
expect(queries[0].type).toBe('builder_ai_query');
|
||||
|
||||
const restored = fromPerses(perses, PANEL_TYPES.TIME_SERIES);
|
||||
|
||||
expect(restored.queryType).toBe(EQueryType.QUERY_BUILDER);
|
||||
expect(restored.builder.queryData[0].builderQueryType).toBe(
|
||||
'builder_ai_query',
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves a List builder query through toPerses → fromPerses', () => {
|
||||
const original: Query = initialQueriesMap[DataSource.LOGS];
|
||||
|
||||
|
||||
@@ -475,6 +475,7 @@ export function QueryBuilderProvider({
|
||||
const newQuery: IBuilderQuery = {
|
||||
...initialBuilderQuery,
|
||||
source: queries?.[0]?.source || '',
|
||||
builderQueryType: queries?.[0]?.builderQueryType,
|
||||
queryName: createNewBuilderItemName({ existNames, sourceNames: alphabet }),
|
||||
expression: createNewBuilderItemName({
|
||||
existNames,
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
initialQueriesMap,
|
||||
initialQueryAIWithType,
|
||||
} from 'constants/queryBuilder';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { act, AllTheProviders, renderHook } from 'tests/test-utils';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
const renderQueryBuilder = (
|
||||
initialQuery: Query,
|
||||
): ReturnType<
|
||||
typeof renderHook<ReturnType<typeof useQueryBuilder>, unknown>
|
||||
> => {
|
||||
const hook = renderHook(() => useQueryBuilder(), {
|
||||
wrapper: AllTheProviders,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
hook.result.current.initQueryBuilderData(initialQuery);
|
||||
});
|
||||
|
||||
return hook;
|
||||
};
|
||||
|
||||
describe('createNewBuilderQuery builderQueryType propagation', () => {
|
||||
it('carries builderQueryType from the first query onto an added query', () => {
|
||||
const { result } = renderQueryBuilder(initialQueryAIWithType);
|
||||
|
||||
expect(
|
||||
result.current.currentQuery.builder.queryData[0].builderQueryType,
|
||||
).toBe('builder_ai_query');
|
||||
|
||||
act(() => {
|
||||
result.current.addNewBuilderQuery();
|
||||
});
|
||||
|
||||
expect(result.current.currentQuery.builder.queryData).toHaveLength(2);
|
||||
expect(
|
||||
result.current.currentQuery.builder.queryData[1].builderQueryType,
|
||||
).toBe('builder_ai_query');
|
||||
});
|
||||
|
||||
it('leaves builderQueryType unset when the first query has none', () => {
|
||||
const { result } = renderQueryBuilder(initialQueriesMap.traces);
|
||||
|
||||
act(() => {
|
||||
result.current.addNewBuilderQuery();
|
||||
});
|
||||
|
||||
expect(result.current.currentQuery.builder.queryData).toHaveLength(2);
|
||||
expect(
|
||||
result.current.currentQuery.builder.queryData[1].builderQueryType,
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from 'types/common/queryBuilder';
|
||||
|
||||
import {
|
||||
BuilderQueryType,
|
||||
Filter,
|
||||
Having as HavingV5,
|
||||
LogAggregation,
|
||||
@@ -90,6 +91,7 @@ export type IBuilderQuery = {
|
||||
offset?: number;
|
||||
selectColumns?: BaseAutocompleteData[] | TelemetryFieldKey[];
|
||||
source?: 'meter' | '';
|
||||
builderQueryType?: BuilderQueryType;
|
||||
};
|
||||
|
||||
export interface IClickHouseQuery {
|
||||
|
||||
@@ -16,6 +16,7 @@ export type RequestType =
|
||||
|
||||
export type QueryType =
|
||||
| 'builder_query'
|
||||
| 'builder_ai_query'
|
||||
| 'builder_trace_operator'
|
||||
| 'builder_formula'
|
||||
| 'builder_sub_query'
|
||||
@@ -23,6 +24,11 @@ export type QueryType =
|
||||
| 'clickhouse_sql'
|
||||
| 'promql';
|
||||
|
||||
export type BuilderQueryType = Extract<
|
||||
QueryType,
|
||||
'builder_query' | 'builder_ai_query'
|
||||
>;
|
||||
|
||||
export type OrderDirection = 'asc' | 'desc';
|
||||
|
||||
export type JoinType = 'inner' | 'left' | 'right' | 'full' | 'cross';
|
||||
|
||||
@@ -332,6 +332,33 @@ func (provider *provider) addDashboardRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/dashboards/system/{name}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.dashboardHandler.GetSystemDashboard, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetSystemDashboard",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Get system dashboard",
|
||||
Description: "Returns a dashboard SigNoz ships and owns, addressed by its stable definition name (e.g. `ai-o11y-overview`) rather than its id. System dashboards are read-only and upgraded through releases. The dashboard's own `name` field carries a reserved prefix that the path segment must not include.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(dashboardtypes.GettableSystemDashboard),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbRead)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceDashboard,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
ID: provider.systemDashboardID(),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Pinning mutates the calling user's pin list, not the dashboard, so it rides
|
||||
// on the collection-level list permission rather than a per-dashboard check.
|
||||
// The id is still extracted, for audit.
|
||||
@@ -718,3 +745,23 @@ func (provider *provider) addDashboardRoutes(router *mux.Router) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// systemDashboardID resolves the {name} path param to the dashboard's id. Authz
|
||||
// tuples and audit records are written against ids, so the name has to be
|
||||
// resolved before either runs.
|
||||
func (provider *provider) systemDashboardID() coretypes.ResourceIDExtractor {
|
||||
return coretypes.NewResourceIDExtractor(coretypes.PhaseRequest, func(ec coretypes.ExtractorContext) (string, error) {
|
||||
ctx := ec.Request.Context()
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
systemDashboard, err := provider.dashboardModule.GetSystemDashboard(ctx, valuer.MustNewUUID(claims.OrgID), mux.Vars(ec.Request)["name"])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return systemDashboard.ID.StringValue(), nil
|
||||
})
|
||||
}
|
||||
|
||||
102
pkg/apiserver/signozapiserver/prometheus.go
Normal file
102
pkg/apiserver/signozapiserver/prometheus.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package signozapiserver
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/prometheus"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/gorilla/mux"
|
||||
openapi "github.com/swaggest/openapi-go"
|
||||
)
|
||||
|
||||
// prometheusOpenAPIHandler skips the default handler wrapper: that wraps
|
||||
// every response in the house envelope, and these endpoints follow
|
||||
// Prometheus' wire contract, described by the prometheus package's *Schema
|
||||
// types.
|
||||
type prometheusOpenAPIHandler struct {
|
||||
handlerFunc http.HandlerFunc
|
||||
id string
|
||||
summary string
|
||||
params any
|
||||
}
|
||||
|
||||
func (h *prometheusOpenAPIHandler) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
||||
h.handlerFunc.ServeHTTP(rw, req)
|
||||
}
|
||||
|
||||
func (h *prometheusOpenAPIHandler) ServeOpenAPI(opCtx openapi.OperationContext) {
|
||||
// One route serves GET and POST; operation IDs must stay unique.
|
||||
id := h.id
|
||||
if strings.EqualFold(opCtx.Method(), http.MethodPost) {
|
||||
id += "Post"
|
||||
}
|
||||
opCtx.SetID(id)
|
||||
opCtx.SetTags("prometheus")
|
||||
opCtx.SetSummary(h.summary)
|
||||
opCtx.SetDescription("Prometheus-compatible endpoint: the request and response contract is the upstream Prometheus HTTP API (https://prometheus.io/docs/prometheus/latest/querying/api/). Parameters are accepted as URL query parameters or a form-encoded body, on GET and POST alike.")
|
||||
|
||||
for _, scheme := range newScopedSecuritySchemes([]string{coretypes.ResourceTelemetryResourceMetrics.Scope(coretypes.VerbRead)}) {
|
||||
opCtx.AddSecurity(scheme.Name, scheme.Scopes...)
|
||||
}
|
||||
|
||||
opCtx.AddReqStructure(h.params)
|
||||
|
||||
opCtx.AddRespStructure(
|
||||
prometheus.SuccessResponseSchema{},
|
||||
openapi.WithContentType("application/json"),
|
||||
openapi.WithHTTPStatus(http.StatusOK),
|
||||
)
|
||||
for _, statusCode := range []int{http.StatusBadRequest, http.StatusUnprocessableEntity, http.StatusServiceUnavailable, http.StatusInternalServerError} {
|
||||
opCtx.AddRespStructure(
|
||||
prometheus.ErrorResponseSchema{},
|
||||
openapi.WithContentType("application/json"),
|
||||
openapi.WithHTTPStatus(statusCode),
|
||||
)
|
||||
}
|
||||
// The auth middleware answers before the handler and uses the house
|
||||
// envelope, not Prometheus'.
|
||||
for _, statusCode := range []int{http.StatusUnauthorized, http.StatusForbidden} {
|
||||
opCtx.AddRespStructure(
|
||||
render.ErrorResponse{Status: render.StatusError.String(), Error: &errors.JSON{}},
|
||||
openapi.WithContentType("application/json"),
|
||||
openapi.WithHTTPStatus(statusCode),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *prometheusOpenAPIHandler) ResourceDefs() []handler.ResourceDef {
|
||||
return []handler.ResourceDef{handler.TelemetryResourceDef{
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
Selector: querybuilder.TelemetrySelector,
|
||||
Resources: querybuilder.PromQLResources,
|
||||
}}
|
||||
}
|
||||
|
||||
func (provider *provider) addPrometheusRoutes(router *mux.Router) error {
|
||||
if err := router.Handle("/prometheus/api/v1/query", &prometheusOpenAPIHandler{
|
||||
handlerFunc: provider.authzMiddleware.CheckResources(provider.prometheusHandler.Query, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
id: "PrometheusQuery",
|
||||
summary: "Prometheus instant query",
|
||||
params: new(prometheus.QueryParamsSchema),
|
||||
}).Methods(http.MethodGet, http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/prometheus/api/v1/query_range", &prometheusOpenAPIHandler{
|
||||
handlerFunc: provider.authzMiddleware.CheckResources(provider.prometheusHandler.QueryRange, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
id: "PrometheusQueryRange",
|
||||
summary: "Prometheus range query",
|
||||
params: new(prometheus.QueryRangeParamsSchema),
|
||||
}).Methods(http.MethodGet, http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanmapper"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tracedetail"
|
||||
"github.com/SigNoz/signoz/pkg/modules/user"
|
||||
"github.com/SigNoz/signoz/pkg/prometheus"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
"github.com/SigNoz/signoz/pkg/ruler"
|
||||
"github.com/SigNoz/signoz/pkg/statsreporter"
|
||||
@@ -75,6 +76,7 @@ type provider struct {
|
||||
ruleStateHistoryHandler rulestatehistory.Handler
|
||||
spanMapperHandler spanmapper.Handler
|
||||
alertmanagerHandler alertmanager.Handler
|
||||
prometheusHandler prometheus.Handler
|
||||
traceDetailHandler tracedetail.Handler
|
||||
rulerHandler ruler.Handler
|
||||
llmPricingRuleHandler llmpricingrule.Handler
|
||||
@@ -113,6 +115,7 @@ func NewFactory(
|
||||
ruleStateHistoryHandler rulestatehistory.Handler,
|
||||
spanMapperHandler spanmapper.Handler,
|
||||
alertmanagerHandler alertmanager.Handler,
|
||||
prometheusHandler prometheus.Handler,
|
||||
llmPricingRuleHandler llmpricingrule.Handler,
|
||||
traceDetailHandler tracedetail.Handler,
|
||||
rulerHandler ruler.Handler,
|
||||
@@ -154,6 +157,7 @@ func NewFactory(
|
||||
ruleStateHistoryHandler,
|
||||
spanMapperHandler,
|
||||
alertmanagerHandler,
|
||||
prometheusHandler,
|
||||
llmPricingRuleHandler,
|
||||
traceDetailHandler,
|
||||
rulerHandler,
|
||||
@@ -197,6 +201,7 @@ func newProvider(
|
||||
ruleStateHistoryHandler rulestatehistory.Handler,
|
||||
spanMapperHandler spanmapper.Handler,
|
||||
alertmanagerHandler alertmanager.Handler,
|
||||
prometheusHandler prometheus.Handler,
|
||||
llmPricingRuleHandler llmpricingrule.Handler,
|
||||
traceDetailHandler tracedetail.Handler,
|
||||
rulerHandler ruler.Handler,
|
||||
@@ -239,6 +244,7 @@ func newProvider(
|
||||
ruleStateHistoryHandler: ruleStateHistoryHandler,
|
||||
spanMapperHandler: spanMapperHandler,
|
||||
alertmanagerHandler: alertmanagerHandler,
|
||||
prometheusHandler: prometheusHandler,
|
||||
traceDetailHandler: traceDetailHandler,
|
||||
rulerHandler: rulerHandler,
|
||||
llmPricingRuleHandler: llmPricingRuleHandler,
|
||||
@@ -340,6 +346,10 @@ func (provider *provider) AddToRouter(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := provider.addPrometheusRoutes(router); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := provider.addServiceAccountRoutes(router); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/instrumentation/tracehandler"
|
||||
"github.com/SigNoz/signoz/pkg/version"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/collectors"
|
||||
@@ -108,7 +109,7 @@ func New(ctx context.Context, cfg Config, build version.Build, serviceName strin
|
||||
}
|
||||
|
||||
// Set the global tracer provider to the sdk tracer provider so that external packages can use this
|
||||
otel.SetTracerProvider(sdk.TracerProvider())
|
||||
otel.SetTracerProvider(tracehandler.New(sdk.TracerProvider(), tracehandler.NewPromQL()))
|
||||
|
||||
return &SDK{
|
||||
sdk: sdk,
|
||||
|
||||
27
pkg/instrumentation/tracehandler/promql.go
Normal file
27
pkg/instrumentation/tracehandler/promql.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package tracehandler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
tracenoop "go.opentelemetry.io/otel/trace/noop"
|
||||
)
|
||||
|
||||
// TODO(srikanthccv): replace with the tracer scope filter (per-scope
|
||||
// "enabled") when the otel-go trace SDK ships it
|
||||
// (https://github.com/open-telemetry/opentelemetry-go/issues/8411).
|
||||
func NewPromQL() Wrapper {
|
||||
noop := tracenoop.NewTracerProvider().Tracer("")
|
||||
return WrapperFunc(func(scope string, next StartFunc) StartFunc {
|
||||
if scope != "" {
|
||||
return next
|
||||
}
|
||||
return func(ctx context.Context, spanName string, opts ...trace.SpanStartOption) (context.Context, trace.Span) {
|
||||
if strings.HasPrefix(spanName, "promql") {
|
||||
return noop.Start(ctx, spanName)
|
||||
}
|
||||
return next(ctx, spanName, opts...)
|
||||
}
|
||||
})
|
||||
}
|
||||
53
pkg/instrumentation/tracehandler/tracehandler.go
Normal file
53
pkg/instrumentation/tracehandler/tracehandler.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package tracehandler
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
"go.opentelemetry.io/otel/trace/embedded"
|
||||
)
|
||||
|
||||
// StartFunc is to trace.Tracer.Start as loghandler.LogHandlerFunc is to
|
||||
// loghandler.LogHandler.
|
||||
type StartFunc func(ctx context.Context, spanName string, opts ...trace.SpanStartOption) (context.Context, trace.Span)
|
||||
|
||||
// Wrapper is an interface implemented by all trace handlers. scope is the
|
||||
// instrumentation scope name of the tracer being wrapped; a wrapper that
|
||||
// does not apply to a scope returns next unchanged.
|
||||
type Wrapper interface {
|
||||
Wrap(scope string, next StartFunc) StartFunc
|
||||
}
|
||||
|
||||
type WrapperFunc func(scope string, next StartFunc) StartFunc
|
||||
|
||||
func (m WrapperFunc) Wrap(scope string, next StartFunc) StartFunc {
|
||||
return m(scope, next)
|
||||
}
|
||||
|
||||
type provider struct {
|
||||
embedded.TracerProvider
|
||||
base trace.TracerProvider
|
||||
wrappers []Wrapper
|
||||
}
|
||||
|
||||
func New(base trace.TracerProvider, wrappers ...Wrapper) trace.TracerProvider {
|
||||
return &provider{base: base, wrappers: wrappers}
|
||||
}
|
||||
|
||||
func (p *provider) Tracer(name string, opts ...trace.TracerOption) trace.Tracer {
|
||||
base := p.base.Tracer(name, opts...)
|
||||
start := StartFunc(base.Start)
|
||||
for i := len(p.wrappers) - 1; i >= 0; i-- {
|
||||
start = p.wrappers[i].Wrap(name, start)
|
||||
}
|
||||
return &tracer{start: start}
|
||||
}
|
||||
|
||||
type tracer struct {
|
||||
embedded.Tracer
|
||||
start StartFunc
|
||||
}
|
||||
|
||||
func (t *tracer) Start(ctx context.Context, spanName string, opts ...trace.SpanStartOption) (context.Context, trace.Span) {
|
||||
return t.start(ctx, spanName, opts...)
|
||||
}
|
||||
87
pkg/instrumentation/tracehandler/tracehandler_test.go
Normal file
87
pkg/instrumentation/tracehandler/tracehandler_test.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package tracehandler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||
"go.opentelemetry.io/otel/sdk/trace/tracetest"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
tracenoop "go.opentelemetry.io/otel/trace/noop"
|
||||
)
|
||||
|
||||
func TestWrappersChainInOrder(t *testing.T) {
|
||||
recorder := tracetest.NewSpanRecorder()
|
||||
var order []string
|
||||
observer := func(name string) Wrapper {
|
||||
return WrapperFunc(func(_ string, next StartFunc) StartFunc {
|
||||
return func(ctx context.Context, spanName string, opts ...trace.SpanStartOption) (context.Context, trace.Span) {
|
||||
order = append(order, name)
|
||||
return next(ctx, spanName, opts...)
|
||||
}
|
||||
})
|
||||
}
|
||||
provider := New(sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(recorder)), observer("first"), observer("second"))
|
||||
|
||||
_, span := provider.Tracer("test").Start(context.Background(), "op")
|
||||
span.End()
|
||||
|
||||
assert.Equal(t, []string{"first", "second"}, order)
|
||||
require.Len(t, recorder.Ended(), 1)
|
||||
assert.Equal(t, "op", recorder.Ended()[0].Name())
|
||||
}
|
||||
|
||||
func TestScopedWrapperSkipsOtherScopes(t *testing.T) {
|
||||
recorder := tracetest.NewSpanRecorder()
|
||||
noop := tracenoop.NewTracerProvider().Tracer("")
|
||||
dropAnonymous := WrapperFunc(func(scope string, next StartFunc) StartFunc {
|
||||
if scope != "" {
|
||||
return next
|
||||
}
|
||||
return func(ctx context.Context, spanName string, opts ...trace.SpanStartOption) (context.Context, trace.Span) {
|
||||
return noop.Start(ctx, spanName)
|
||||
}
|
||||
})
|
||||
provider := New(sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(recorder)), dropAnonymous)
|
||||
|
||||
_, dropped := provider.Tracer("").Start(context.Background(), "anon")
|
||||
dropped.End()
|
||||
_, kept := provider.Tracer("named").Start(context.Background(), "op")
|
||||
kept.End()
|
||||
|
||||
require.Len(t, recorder.Ended(), 1)
|
||||
assert.Equal(t, "op", recorder.Ended()[0].Name())
|
||||
}
|
||||
|
||||
func TestPromQL(t *testing.T) {
|
||||
recorder := tracetest.NewSpanRecorder()
|
||||
provider := New(sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(recorder)), NewPromQL())
|
||||
|
||||
ctx, root := provider.Tracer("http").Start(context.Background(), "GET /api")
|
||||
|
||||
engineCtx, engineSpan := provider.Tracer("").Start(ctx, "promqlInnerEval eval *promql.BinaryExpr")
|
||||
assert.False(t, engineSpan.IsRecording(), "promql engine spans must not record")
|
||||
assert.Equal(t, root.SpanContext().SpanID(), engineSpan.SpanContext().SpanID(), "the filtered span must keep the parent's span context")
|
||||
|
||||
_, child := provider.Tracer("clickhouse").Start(engineCtx, "clickhouse.query")
|
||||
child.End()
|
||||
|
||||
_, other := provider.Tracer("").Start(ctx, "http.request")
|
||||
other.End()
|
||||
root.End()
|
||||
|
||||
var names []string
|
||||
var childParent string
|
||||
for _, span := range recorder.Ended() {
|
||||
names = append(names, span.Name())
|
||||
if span.Name() == "clickhouse.query" {
|
||||
childParent = span.Parent().SpanID().String()
|
||||
}
|
||||
}
|
||||
require.ElementsMatch(t, []string{"clickhouse.query", "http.request", "GET /api"}, names)
|
||||
assert.Equal(t, root.SpanContext().SpanID().String(), childParent, "descendants of a filtered span must attach to the surrounding span")
|
||||
assert.False(t, strings.HasPrefix(recorder.Ended()[0].Name(), "promql"))
|
||||
}
|
||||
@@ -22,7 +22,7 @@ func newConfig() factory.Config {
|
||||
Agent: AgentConfig{
|
||||
// we will maintain the latest version of cloud integration agent from here,
|
||||
// till we automate it externally or figure out a way to validate it.
|
||||
Version: "v0.0.13",
|
||||
Version: "v0.0.14",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,6 +99,14 @@ type Module interface {
|
||||
DeleteView(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error
|
||||
|
||||
GetByMetricNamesV2(ctx context.Context, orgID valuer.UUID, metricNames []string) (map[string][]dashboardtypes.DashboardPanelRef, error)
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// System dashboard methods
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
ReconcileSystemDashboards(ctx context.Context, orgID valuer.UUID) error
|
||||
|
||||
GetSystemDashboard(ctx context.Context, orgID valuer.UUID, name string) (*dashboardtypes.DashboardV2, error)
|
||||
}
|
||||
|
||||
type Handler interface {
|
||||
@@ -162,4 +170,6 @@ type Handler interface {
|
||||
UpdateView(http.ResponseWriter, *http.Request)
|
||||
|
||||
DeleteView(http.ResponseWriter, *http.Request)
|
||||
|
||||
GetSystemDashboard(http.ResponseWriter, *http.Request)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"version": 1,
|
||||
"definition": {
|
||||
"schemaVersion": "v6",
|
||||
"name": "signoz---ai-o11y-overview",
|
||||
"tags": [],
|
||||
"spec": {
|
||||
"display": {
|
||||
"name": "AI Observability Overview",
|
||||
"description": "Overview of LLM traffic. Panels ship in an upcoming release."
|
||||
},
|
||||
"variables": [],
|
||||
"panels": {},
|
||||
"layouts": []
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,23 +21,25 @@ import (
|
||||
)
|
||||
|
||||
type module struct {
|
||||
store dashboardtypes.Store
|
||||
settings factory.ScopedProviderSettings
|
||||
analytics analytics.Analytics
|
||||
orgGetter organization.Getter
|
||||
queryParser queryparser.QueryParser
|
||||
tagModule tag.Module
|
||||
store dashboardtypes.Store
|
||||
settings factory.ScopedProviderSettings
|
||||
analytics analytics.Analytics
|
||||
orgGetter organization.Getter
|
||||
queryParser queryparser.QueryParser
|
||||
tagModule tag.Module
|
||||
systemDashboardRegistry dashboardtypes.SystemDashboardRegistry
|
||||
}
|
||||
|
||||
func NewModule(store dashboardtypes.Store, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, tagModule tag.Module) dashboard.Module {
|
||||
func NewModule(store dashboardtypes.Store, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, tagModule tag.Module, systemDashboardRegistry dashboardtypes.SystemDashboardRegistry) dashboard.Module {
|
||||
scopedProviderSettings := factory.NewScopedProviderSettings(settings, "github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard")
|
||||
return &module{
|
||||
store: store,
|
||||
settings: scopedProviderSettings,
|
||||
analytics: analytics,
|
||||
orgGetter: orgGetter,
|
||||
queryParser: queryParser,
|
||||
tagModule: tagModule,
|
||||
store: store,
|
||||
settings: scopedProviderSettings,
|
||||
analytics: analytics,
|
||||
orgGetter: orgGetter,
|
||||
queryParser: queryParser,
|
||||
tagModule: tagModule,
|
||||
systemDashboardRegistry: systemDashboardRegistry,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package impldashboard
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
@@ -64,6 +65,23 @@ func (store *store) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID)
|
||||
return storableDashboard, nil
|
||||
}
|
||||
|
||||
func (store *store) GetByName(ctx context.Context, orgID valuer.UUID, name string) (*dashboardtypes.StorableDashboard, error) {
|
||||
storableDashboard := new(dashboardtypes.StorableDashboard)
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDB().
|
||||
NewSelect().
|
||||
Model(storableDashboard).
|
||||
Where("name = ?", name).
|
||||
Where("org_id = ?", orgID).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, store.sqlstore.WrapNotFoundErrf(err, errors.CodeNotFound, "dashboard with name %s doesn't exist", name)
|
||||
}
|
||||
|
||||
return storableDashboard, nil
|
||||
}
|
||||
|
||||
// ListForUser emits the joined dashboard ⨝ user_dashboard_preference query the
|
||||
// spec calls for. Aliases:
|
||||
//
|
||||
@@ -613,3 +631,60 @@ func (store *store) DeleteDashboardView(ctx context.Context, orgID valuer.UUID,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) CreateSystemDashboard(ctx context.Context, storable *dashboardtypes.StorableSystemDashboard) error {
|
||||
_, err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewInsert().
|
||||
Model(storable).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return store.sqlstore.WrapAlreadyExistsErrf(err, dashboardtypes.ErrCodeSystemDashboardAlreadyProvisioned, "system dashboard %s is already provisioned", storable.Name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) GetSystemDashboard(ctx context.Context, orgID valuer.UUID, name string) (*dashboardtypes.StorableSystemDashboard, error) {
|
||||
storable := new(dashboardtypes.StorableSystemDashboard)
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(storable).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("name = ?", name).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, store.sqlstore.WrapNotFoundErrf(err, dashboardtypes.ErrCodeSystemDashboardNotFound, "system dashboard %s is not provisioned", name)
|
||||
}
|
||||
|
||||
return storable, nil
|
||||
}
|
||||
|
||||
func (store *store) UpdateSystemDashboardVersion(ctx context.Context, orgID valuer.UUID, name string, version int) error {
|
||||
result, err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewUpdate().
|
||||
Model(new(dashboardtypes.StorableSystemDashboard)).
|
||||
Set("version = ?", version).
|
||||
Set("updated_at = ?", time.Now()).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("name = ?", name).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rows == 0 {
|
||||
return errors.Newf(errors.TypeNotFound, dashboardtypes.ErrCodeSystemDashboardNotFound, "system dashboard %s is not provisioned", name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package impldashboard
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"path"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
|
||||
)
|
||||
|
||||
const definitionsRoot = "fs/definitions"
|
||||
|
||||
//go:embed fs/definitions/*.json
|
||||
var definitionFiles embed.FS
|
||||
|
||||
// NewSystemDashboardRegistry parses every embedded definition. Definitions are
|
||||
// build-time assets validated by a test, so a failure here means the binary
|
||||
// shipped broken JSON.
|
||||
func NewSystemDashboardRegistry() (dashboardtypes.SystemDashboardRegistry, error) {
|
||||
entries, err := fs.ReadDir(definitionFiles, definitionsRoot)
|
||||
if err != nil {
|
||||
return dashboardtypes.SystemDashboardRegistry{}, errors.WrapInternalf(err, errors.CodeInternal, "couldn't read system dashboard definitions")
|
||||
}
|
||||
|
||||
definitions := make([]dashboardtypes.SystemDashboardDefinition, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
file := path.Join(definitionsRoot, entry.Name())
|
||||
raw, err := definitionFiles.ReadFile(file)
|
||||
if err != nil {
|
||||
return dashboardtypes.SystemDashboardRegistry{}, errors.WrapInternalf(err, errors.CodeInternal, "couldn't read %s", file)
|
||||
}
|
||||
|
||||
definition, err := dashboardtypes.NewSystemDashboardDefinition(raw)
|
||||
if err != nil {
|
||||
return dashboardtypes.SystemDashboardRegistry{}, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "couldn't parse %s", file)
|
||||
}
|
||||
definitions = append(definitions, definition)
|
||||
}
|
||||
|
||||
return dashboardtypes.NewSystemDashboardRegistry(definitions)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package impldashboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
)
|
||||
|
||||
const reconcileRetryInterval = 30 * time.Second
|
||||
|
||||
type service struct {
|
||||
settings factory.ScopedProviderSettings
|
||||
module dashboard.Module
|
||||
orgGetter organization.Getter
|
||||
stopC chan struct{}
|
||||
healthyC chan struct{}
|
||||
}
|
||||
|
||||
// NewService reconciles every org's system dashboards once at startup. Orgs
|
||||
// created later are reconciled by the organization setter instead.
|
||||
func NewService(providerSettings factory.ProviderSettings, module dashboard.Module, orgGetter organization.Getter) factory.Service {
|
||||
return &service{
|
||||
settings: factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"),
|
||||
module: module,
|
||||
orgGetter: orgGetter,
|
||||
stopC: make(chan struct{}),
|
||||
healthyC: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (service *service) Start(ctx context.Context) error {
|
||||
ticker := time.NewTicker(reconcileRetryInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
err := service.reconcile(ctx)
|
||||
if err == nil {
|
||||
close(service.healthyC)
|
||||
<-service.stopC
|
||||
return nil
|
||||
}
|
||||
|
||||
service.settings.Logger().WarnContext(ctx, "system dashboard reconciliation failed, retrying", errors.Attr(err))
|
||||
|
||||
select {
|
||||
case <-service.stopC:
|
||||
return nil
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (service *service) Healthy() <-chan struct{} {
|
||||
return service.healthyC
|
||||
}
|
||||
|
||||
func (service *service) Stop(_ context.Context) error {
|
||||
close(service.stopC)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (service *service) reconcile(ctx context.Context) error {
|
||||
orgs, err := service.orgGetter.ListByOwnedKeyRange(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, org := range orgs {
|
||||
if err := service.module.ReconcileSystemDashboards(ctx, org.ID); err != nil {
|
||||
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "couldn't reconcile system dashboards for org %s", org.ID.StringValue())
|
||||
}
|
||||
}
|
||||
|
||||
service.settings.Logger().InfoContext(ctx, "system dashboard reconciliation completed", slog.Int("orgs", len(orgs)))
|
||||
return nil
|
||||
}
|
||||
@@ -502,3 +502,28 @@ func (handler *handler) GetPublicWidgetQueryRangeV2(rw http.ResponseWriter, r *h
|
||||
|
||||
render.Success(rw, http.StatusOK, queryRangeResults)
|
||||
}
|
||||
|
||||
func (handler *handler) GetSystemDashboard(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
name := mux.Vars(r)["name"]
|
||||
if name == "" {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "name is missing in the path"))
|
||||
return
|
||||
}
|
||||
|
||||
systemDashboard, err := handler.module.GetSystemDashboard(ctx, valuer.MustNewUUID(claims.OrgID), name)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, systemDashboard.ToGettableSystemDashboard())
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ package impldashboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/transition"
|
||||
@@ -19,9 +21,12 @@ func (m *module) CreateV2(ctx context.Context, orgID valuer.UUID, createdBy stri
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dashboard := postable.NewDashboardV2(orgID, createdBy, source)
|
||||
dashboard, err := postable.NewDashboardV2(orgID, createdBy, source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err := m.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
err = m.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
resolvedTags, err := m.tagModule.SyncTags(ctx, orgID, coretypes.KindDashboard, dashboard.ID, postable.Tags)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -120,6 +125,20 @@ func (module *module) GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UU
|
||||
return storable.ToDashboardV2(tags)
|
||||
}
|
||||
|
||||
func (module *module) getByNameV2(ctx context.Context, orgID valuer.UUID, name string) (*dashboardtypes.DashboardV2, error) {
|
||||
storable, err := module.store.GetByName(ctx, orgID, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tags, err := module.tagModule.ListForResource(ctx, orgID, coretypes.KindDashboard, storable.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return storable.ToDashboardV2(tags)
|
||||
}
|
||||
|
||||
// MigrateV2 retries the v1→v2 migration on a dashboard still stored as v1 (one the
|
||||
// bulk 103 migration skipped or failed). Idempotent: an already-v2 one is unchanged.
|
||||
func (module *module) MigrateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error) {
|
||||
@@ -179,13 +198,33 @@ func (module *module) UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = module.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
resolvedTags, err := module.tagModule.SyncTags(ctx, orgID, coretypes.KindDashboard, id, updatable.Tags)
|
||||
return module.updateV2(ctx, orgID, existing, updatedBy, updatable, existing.Update)
|
||||
}
|
||||
|
||||
// updateUnsafeV2 updates a dashboard bypassing the guards. Intended for internal system callers.
|
||||
func (module *module) updateUnsafeV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, updatable dashboardtypes.UpdatableDashboardV2) (*dashboardtypes.DashboardV2, error) {
|
||||
if err := updatable.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
existing, err := module.GetV2(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return module.updateV2(ctx, orgID, existing, updatedBy, updatable, existing.UpdateUnsafe)
|
||||
}
|
||||
|
||||
// apply is existing.Update or existing.UpdateUnsafe, so the gated path keeps its
|
||||
// in-transaction checks and only updateUnsafeV2 skips them.
|
||||
func (module *module) updateV2(ctx context.Context, orgID valuer.UUID, existing *dashboardtypes.DashboardV2, updatedBy string, updatable dashboardtypes.UpdatableDashboardV2, apply func(dashboardtypes.UpdatableDashboardV2, string, []*tagtypes.Tag) error) (*dashboardtypes.DashboardV2, error) {
|
||||
err := module.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
resolvedTags, err := module.tagModule.SyncTags(ctx, orgID, coretypes.KindDashboard, existing.ID, updatable.Tags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = existing.Update(updatable, updatedBy, resolvedTags)
|
||||
err = apply(updatable, updatedBy, resolvedTags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -296,3 +335,98 @@ func (module *module) UnpinV2(ctx context.Context, orgID valuer.UUID, userID val
|
||||
func (module *module) DeletePreferencesForUser(ctx context.Context, orgID valuer.UUID, userID valuer.UUID) error {
|
||||
return module.store.DeletePreferencesForUser(ctx, orgID, userID)
|
||||
}
|
||||
|
||||
func (m *module) ReconcileSystemDashboards(ctx context.Context, orgID valuer.UUID) error {
|
||||
for _, definition := range m.systemDashboardRegistry.List() {
|
||||
if err := m.reconcileSystemDashboard(ctx, orgID, definition); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *module) reconcileSystemDashboard(ctx context.Context, orgID valuer.UUID, definition dashboardtypes.SystemDashboardDefinition) error {
|
||||
existing, err := m.getByNameV2(ctx, orgID, definition.Name())
|
||||
if err != nil {
|
||||
if !errors.Ast(err, errors.TypeNotFound) {
|
||||
return err
|
||||
}
|
||||
return m.provisionSystemDashboard(ctx, orgID, definition)
|
||||
}
|
||||
|
||||
state, err := m.store.GetSystemDashboard(ctx, orgID, definition.Name())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Only ever move forward: a downgrade must not rewrite the newer content.
|
||||
if state.Version >= definition.Version {
|
||||
return nil
|
||||
}
|
||||
|
||||
return m.upgradeSystemDashboard(ctx, orgID, existing.ID, definition)
|
||||
}
|
||||
|
||||
// provisionSystemDashboard creates the dashboard and its state row in one transaction,
|
||||
// so a system dashboard can never exist without the version it was provisioned at.
|
||||
// A concurrent provisioner (another replica, or the org-creation hook racing the
|
||||
// startup sweep) loses on the state row's unique (org_id, name) index and rolls back.
|
||||
func (m *module) provisionSystemDashboard(ctx context.Context, orgID valuer.UUID, definition dashboardtypes.SystemDashboardDefinition) error {
|
||||
err := m.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
created, err := m.CreateV2(
|
||||
ctx,
|
||||
orgID,
|
||||
dashboardtypes.ProvisionerIdentity,
|
||||
valuer.UUID{},
|
||||
dashboardtypes.SourceSystem,
|
||||
definition.Dashboard,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return m.store.CreateSystemDashboard(ctx, dashboardtypes.NewStorableSystemDashboard(orgID, created.ID, definition.Name(), definition.Version))
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Ast(err, errors.TypeAlreadyExists) {
|
||||
m.settings.Logger().DebugContext(ctx, "system dashboard already provisioned concurrently", slog.String("name", definition.Name()), slog.String("org_id", orgID.StringValue()))
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
m.settings.Logger().InfoContext(ctx, "provisioned system dashboard", slog.String("name", definition.Name()), slog.Int("version", definition.Version), slog.String("org_id", orgID.StringValue()))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *module) upgradeSystemDashboard(ctx context.Context, orgID valuer.UUID, id valuer.UUID, definition dashboardtypes.SystemDashboardDefinition) error {
|
||||
err := m.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
if _, err := m.updateUnsafeV2(ctx, orgID, id, dashboardtypes.ProvisionerIdentity, definition.ToUpdatable()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return m.store.UpdateSystemDashboardVersion(ctx, orgID, definition.Name(), definition.Version)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
m.settings.Logger().InfoContext(ctx, "upgraded system dashboard", slog.String("name", definition.Name()), slog.Int("version", definition.Version), slog.String("org_id", orgID.StringValue()))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *module) GetSystemDashboard(ctx context.Context, orgID valuer.UUID, name string) (*dashboardtypes.DashboardV2, error) {
|
||||
if strings.HasPrefix(name, dashboardtypes.SystemDashboardNamePrefix) {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "name must not carry the %q prefix", dashboardtypes.SystemDashboardNamePrefix)
|
||||
}
|
||||
|
||||
existing, err := m.getByNameV2(ctx, orgID, dashboardtypes.SystemDashboardNamePrefix+name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := existing.ErrIfNotSystem(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
191
pkg/modules/dashboard/impldashboard/v2_module_test.go
Normal file
191
pkg/modules/dashboard/impldashboard/v2_module_test.go
Normal file
@@ -0,0 +1,191 @@
|
||||
package impldashboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/analytics/analyticstest"
|
||||
"github.com/SigNoz/signoz/pkg/factory/factorytest"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tag/impltag"
|
||||
"github.com/SigNoz/signoz/pkg/queryparser"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore/sqlitesqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/tagtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const testDashboardName = "test-overview"
|
||||
|
||||
func newTestSQLStore(t *testing.T) sqlstore.SQLStore {
|
||||
t.Helper()
|
||||
|
||||
store, err := sqlitesqlstore.New(context.Background(), factorytest.NewSettings(), sqlstore.Config{
|
||||
Provider: "sqlite",
|
||||
Connection: sqlstore.ConnectionConfig{MaxOpenConns: 10},
|
||||
Sqlite: sqlstore.SqliteConfig{
|
||||
Path: filepath.Join(t.TempDir(), "test.db"),
|
||||
Mode: "wal",
|
||||
BusyTimeout: 5 * time.Second,
|
||||
TransactionMode: "deferred",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, model := range []any{
|
||||
(*dashboardtypes.StorableDashboard)(nil),
|
||||
(*tagtypes.Tag)(nil),
|
||||
(*tagtypes.TagRelation)(nil),
|
||||
(*dashboardtypes.StorableSystemDashboard)(nil),
|
||||
} {
|
||||
_, err := store.BunDB().NewCreateTable().Model(model).IfNotExists().Exec(context.Background())
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
_, err = store.BunDB().Exec(`CREATE UNIQUE INDEX IF NOT EXISTS uq_system_dashboard_org_name ON system_dashboard (org_id, name)`)
|
||||
require.NoError(t, err)
|
||||
|
||||
return store
|
||||
}
|
||||
|
||||
func newTestModule(t *testing.T, sqlStore sqlstore.SQLStore, definitions ...dashboardtypes.SystemDashboardDefinition) *module {
|
||||
t.Helper()
|
||||
|
||||
registry, err := dashboardtypes.NewSystemDashboardRegistry(definitions)
|
||||
require.NoError(t, err)
|
||||
|
||||
providerSettings := factorytest.NewSettings()
|
||||
return NewModule(
|
||||
NewStore(sqlStore),
|
||||
providerSettings,
|
||||
analyticstest.New(),
|
||||
nil,
|
||||
queryparser.New(providerSettings),
|
||||
impltag.NewModule(impltag.NewStore(sqlStore)),
|
||||
registry,
|
||||
).(*module)
|
||||
}
|
||||
|
||||
func newTestDefinition(t *testing.T, version int, displayName string) dashboardtypes.SystemDashboardDefinition {
|
||||
t.Helper()
|
||||
|
||||
raw := `{
|
||||
"version": ` + strconv.Itoa(version) + `,
|
||||
"definition": {
|
||||
"schemaVersion": "` + dashboardtypes.SchemaVersion + `",
|
||||
"name": "` + dashboardtypes.SystemDashboardNamePrefix + testDashboardName + `",
|
||||
"tags": [],
|
||||
"spec": {"display": {"name": "` + displayName + `"}, "variables": [], "panels": {}, "layouts": []}
|
||||
}
|
||||
}`
|
||||
|
||||
definition, err := dashboardtypes.NewSystemDashboardDefinition([]byte(raw))
|
||||
require.NoError(t, err)
|
||||
|
||||
return definition
|
||||
}
|
||||
|
||||
func TestReconcileProvisionsThenUpgrades(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
sqlStore := newTestSQLStore(t)
|
||||
orgID := valuer.GenerateUUID()
|
||||
|
||||
dashboardModule := newTestModule(t, sqlStore, newTestDefinition(t, 1, "v1"))
|
||||
require.NoError(t, dashboardModule.ReconcileSystemDashboards(ctx, orgID))
|
||||
|
||||
provisioned, err := dashboardModule.GetSystemDashboard(ctx, orgID, testDashboardName)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, dashboardtypes.SourceSystem, provisioned.Source)
|
||||
assert.Equal(t, dashboardtypes.ProvisionerIdentity, provisioned.CreatedBy)
|
||||
assert.Equal(t, "v1", provisioned.Spec.Display.Name)
|
||||
assert.Equal(t, 1, stateVersion(t, dashboardModule, ctx, orgID))
|
||||
|
||||
// Reconciling the same version again is a no-op.
|
||||
require.NoError(t, dashboardModule.ReconcileSystemDashboards(ctx, orgID))
|
||||
unchanged, err := dashboardModule.GetSystemDashboard(ctx, orgID, testDashboardName)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, provisioned.UpdatedAt, unchanged.UpdatedAt)
|
||||
|
||||
// An unmodified copy is upgraded in place, keeping its id.
|
||||
upgradingModule := newTestModule(t, sqlStore, newTestDefinition(t, 2, "v2"))
|
||||
require.NoError(t, upgradingModule.ReconcileSystemDashboards(ctx, orgID))
|
||||
|
||||
upgraded, err := upgradingModule.GetSystemDashboard(ctx, orgID, testDashboardName)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, provisioned.ID, upgraded.ID)
|
||||
assert.Equal(t, "v2", upgraded.Spec.Display.Name)
|
||||
assert.Equal(t, 2, stateVersion(t, upgradingModule, ctx, orgID))
|
||||
}
|
||||
|
||||
func stateVersion(t *testing.T, module *module, ctx context.Context, orgID valuer.UUID) int {
|
||||
t.Helper()
|
||||
|
||||
state, err := module.store.GetSystemDashboard(ctx, orgID, dashboardtypes.SystemDashboardNamePrefix+testDashboardName)
|
||||
require.NoError(t, err)
|
||||
|
||||
return state.Version
|
||||
}
|
||||
|
||||
func TestSystemDashboardsAreImmutableToUsers(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
sqlStore := newTestSQLStore(t)
|
||||
orgID := valuer.GenerateUUID()
|
||||
|
||||
dashboardModule := newTestModule(t, sqlStore, newTestDefinition(t, 1, "v1"))
|
||||
require.NoError(t, dashboardModule.ReconcileSystemDashboards(ctx, orgID))
|
||||
|
||||
provisioned, err := dashboardModule.GetSystemDashboard(ctx, orgID, testDashboardName)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = dashboardModule.UpdateV2(ctx, orgID, provisioned.ID, "user@signoz.io", newTestDefinition(t, 1, "edited").ToUpdatable())
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "cannot be modified")
|
||||
}
|
||||
|
||||
func TestReconcileDoesNotDowngrade(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
sqlStore := newTestSQLStore(t)
|
||||
orgID := valuer.GenerateUUID()
|
||||
|
||||
newerModule := newTestModule(t, sqlStore, newTestDefinition(t, 3, "v3"))
|
||||
require.NoError(t, newerModule.ReconcileSystemDashboards(ctx, orgID))
|
||||
|
||||
olderModule := newTestModule(t, sqlStore, newTestDefinition(t, 2, "v2"))
|
||||
require.NoError(t, olderModule.ReconcileSystemDashboards(ctx, orgID))
|
||||
|
||||
got, err := newerModule.GetSystemDashboard(ctx, orgID, testDashboardName)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "v3", got.Spec.Display.Name)
|
||||
assert.Equal(t, 3, stateVersion(t, newerModule, ctx, orgID))
|
||||
}
|
||||
|
||||
func TestGetRejectsANonSystemDashboard(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
sqlStore := newTestSQLStore(t)
|
||||
orgID := valuer.GenerateUUID()
|
||||
|
||||
dashboardModule := newTestModule(t, sqlStore)
|
||||
|
||||
var postable dashboardtypes.PostableDashboardV2
|
||||
require.NoError(t, postable.UnmarshalJSON([]byte(`{
|
||||
"schemaVersion": "`+dashboardtypes.SchemaVersion+`",
|
||||
"name": "a-user-dashboard",
|
||||
"tags": [],
|
||||
"spec": {"display": {"name": "user"}, "variables": [], "panels": {}, "layouts": []}
|
||||
}`)))
|
||||
_, err := dashboardModule.CreateV2(ctx, orgID, "user@signoz.io", valuer.GenerateUUID(), dashboardtypes.SourceUser, postable)
|
||||
require.NoError(t, err)
|
||||
|
||||
// The server-side prefix makes user names structurally unreachable here.
|
||||
_, err = dashboardModule.GetSystemDashboard(ctx, orgID, "a-user-dashboard")
|
||||
require.Error(t, err)
|
||||
|
||||
_, err = dashboardModule.GetSystemDashboard(ctx, orgID, dashboardtypes.SystemDashboardNamePrefix+testDashboardName)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "must not carry")
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager"
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/quickfilter"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
@@ -14,10 +15,11 @@ type setter struct {
|
||||
store types.OrganizationStore
|
||||
alertmanager alertmanager.Alertmanager
|
||||
quickfilter quickfilter.Module
|
||||
dashboard dashboard.Module
|
||||
}
|
||||
|
||||
func NewSetter(store types.OrganizationStore, alertmanager alertmanager.Alertmanager, quickfilter quickfilter.Module) organization.Setter {
|
||||
return &setter{store: store, alertmanager: alertmanager, quickfilter: quickfilter}
|
||||
func NewSetter(store types.OrganizationStore, alertmanager alertmanager.Alertmanager, quickfilter quickfilter.Module, dashboard dashboard.Module) organization.Setter {
|
||||
return &setter{store: store, alertmanager: alertmanager, quickfilter: quickfilter, dashboard: dashboard}
|
||||
}
|
||||
|
||||
func (module *setter) Create(ctx context.Context, organization *types.Organization, createManagedRoles func(context.Context, valuer.UUID) error) error {
|
||||
@@ -37,6 +39,10 @@ func (module *setter) Create(ctx context.Context, organization *types.Organizati
|
||||
return err
|
||||
}
|
||||
|
||||
if err := module.dashboard.ReconcileSystemDashboards(ctx, organization.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -73,8 +73,8 @@ func (c *captureQuerier) LabelNames(context.Context, *storage.LabelHints, ...*la
|
||||
}
|
||||
|
||||
// metricNamesFromMatchers extracts the statically known metric name, if any.
|
||||
// The live path derives names from the matched series; the capture path has
|
||||
// no execution results, so only a __name__ equality contributes.
|
||||
// Only a __name__ equality contributes; a regex selector needs a series
|
||||
// lookup to learn the concrete names.
|
||||
func metricNamesFromMatchers(matchers []*labels.Matcher) []string {
|
||||
for _, m := range matchers {
|
||||
if m.Name == metricNameLabel && m.Type == labels.MatchEqual && m.Value != "" {
|
||||
|
||||
@@ -88,7 +88,8 @@ func (e *executor) TryExecuteRange(ctx context.Context, qs string, start, end ti
|
||||
}
|
||||
|
||||
// Evaluate every unit concurrently on its own grid (the query grid, or a
|
||||
// subquery grid); each is one series lookup plus one grid query.
|
||||
// subquery grid); each is one grid query (see executeUnit for when a
|
||||
// series lookup precedes it).
|
||||
results := make([][]transpiledSeries, len(plan.units))
|
||||
eg, egCtx := errgroup.WithContext(ctx)
|
||||
for i, unit := range plan.units {
|
||||
@@ -142,19 +143,27 @@ func (e *executor) executeUnit(ctx context.Context, unit *coreUnit, grid gridCon
|
||||
dataStart := startMs - unit.offsetMs - windowMs
|
||||
dataEnd := endMs - unit.offsetMs
|
||||
|
||||
seriesQuery, seriesArgs, err := buildSeriesQuery(dataStart, dataEnd, unit.matchers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lookup, err := e.client.selectSeries(ctx, seriesQuery, seriesArgs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(lookup.fingerprints) == 0 {
|
||||
return nil, nil
|
||||
// The group-key join resolves the matchers on its own, so the unit
|
||||
// statement only needs concrete metric names for the samples
|
||||
// primary-key prefix. A selector without a static __name__ learns them
|
||||
// through the series lookup; every other selector skips the roundtrip.
|
||||
metricNames := metricNamesFromMatchers(unit.matchers)
|
||||
if metricNames == nil {
|
||||
seriesQuery, seriesArgs, err := buildSeriesQuery(dataStart, dataEnd, unit.matchers)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lookup, err := e.client.selectSeries(ctx, seriesQuery, seriesArgs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(lookup.fingerprints) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
metricNames = lookup.metricNames
|
||||
}
|
||||
|
||||
query, args, err := buildUnitSQL(unit, lookup.metricNames, dataStart, dataEnd, startMs, endMs, stepMs, e.client.lookbackMs)
|
||||
query, args, err := buildUnitSQL(unit, metricNames, dataStart, dataEnd, startMs, endMs, stepMs, e.client.lookbackMs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -27,9 +27,15 @@ func newTestClient(t *testing.T) (*client, *telemetrystoretest.Provider) {
|
||||
return newClient(settings, store, prometheus.Config{}), store
|
||||
}
|
||||
|
||||
var seriesCols = []cmock.ColumnType{
|
||||
{Name: "fingerprint", Type: "UInt64"},
|
||||
{Name: "labels", Type: "String"},
|
||||
var unitCols = []cmock.ColumnType{
|
||||
{Name: "gkey", Type: "String"},
|
||||
{Name: "grid", Type: "Array(Nullable(Float64))"},
|
||||
}
|
||||
|
||||
// anyArgs matches a bound-argument list by count alone: the mock treats a
|
||||
// nil expected argument as a wildcard.
|
||||
func anyArgs(n int) []any {
|
||||
return make([]any, n)
|
||||
}
|
||||
|
||||
func parse(t *testing.T, q string) parser.Expr {
|
||||
@@ -553,7 +559,7 @@ func TestTryExecuteRange_WindowedGateFallsBack(t *testing.T) {
|
||||
|
||||
// 1m range at 5m step: the windows are disjoint slivers — no
|
||||
// divisibility or width requirement, so this transpiles.
|
||||
store.Mock().ExpectQuery("SELECT fingerprint, any\\(labels\\)").WithArgs("up", int64(1_699_999_200_000), int64(1_700_003_600_000)).WillReturnRows(cmock.NewRows(seriesCols, [][]any{}))
|
||||
store.Mock().ExpectQuery("FROM signoz_metrics\\.distributed_samples_v4").WithArgs(anyArgs(9)...).WillReturnRows(cmock.NewRows(unitCols, [][]any{}))
|
||||
_, ok, err = e.TryExecuteRange(context.Background(), `avg_over_time(up[1m])`, start, end, 5*time.Minute)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, ok, "range below step is the disjoint form and must transpile")
|
||||
@@ -637,12 +643,12 @@ func TestTryExecuteRange_LastStyleWindowBelowStepTranspiles(t *testing.T) {
|
||||
start := time.UnixMilli(1_700_000_000_000)
|
||||
end := time.UnixMilli(1_700_003_600_000)
|
||||
|
||||
store.Mock().ExpectQuery("SELECT fingerprint, any\\(labels\\)").WithArgs("up", int64(1_699_999_200_000), int64(1_700_003_600_000)).WillReturnRows(cmock.NewRows(seriesCols, [][]any{}))
|
||||
store.Mock().ExpectQuery("timeSeriesLastToGrid").WithArgs(anyArgs(10)...).WillReturnRows(cmock.NewRows(unitCols, [][]any{}))
|
||||
_, ok, err := e.TryExecuteRange(context.Background(), `sum by (pod) (up)`, start, end, time.Hour)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, ok, "instant selection at step > lookback must transpile")
|
||||
|
||||
store.Mock().ExpectQuery("SELECT fingerprint, any\\(labels\\)").WithArgs("up", int64(1_699_999_200_000), int64(1_700_003_600_000)).WillReturnRows(cmock.NewRows(seriesCols, [][]any{}))
|
||||
store.Mock().ExpectQuery("timeSeriesLastToGrid").WithArgs(anyArgs(9)...).WillReturnRows(cmock.NewRows(unitCols, [][]any{}))
|
||||
_, ok, err = e.TryExecuteRange(context.Background(), `last_over_time(up[10m])`, start, end, time.Hour)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, ok, "last_over_time at range < step must transpile")
|
||||
|
||||
211
pkg/prometheus/handler.go
Normal file
211
pkg/prometheus/handler.go
Normal file
@@ -0,0 +1,211 @@
|
||||
package prometheus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
promModel "github.com/prometheus/common/model"
|
||||
"github.com/prometheus/prometheus/promql"
|
||||
"github.com/prometheus/prometheus/util/stats"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
)
|
||||
|
||||
// Handler serves the Prometheus HTTP query API over a Prometheus provider:
|
||||
// /query and /query_range in the shape of Prometheus' /api/v1 endpoints
|
||||
// (https://prometheus.io/docs/prometheus/latest/querying/api/), intended to
|
||||
// be mounted under a distinguishing prefix (/prometheus/api/v1) so
|
||||
// PromQL-only endpoints are separate from the SigNoz query APIs. The request
|
||||
// and response contracts follow Prometheus: form-encoded GET/POST params,
|
||||
// {"status":"success","data":{resultType,result}} on success and
|
||||
// {"status":"error","errorType","error"} with Prometheus' status codes on
|
||||
// failure — so Prometheus-compatible clients can point at the prefix. The
|
||||
// wire shapes are documented as OpenAPI schemas in render.go.
|
||||
type Handler interface {
|
||||
Query(http.ResponseWriter, *http.Request)
|
||||
|
||||
QueryRange(http.ResponseWriter, *http.Request)
|
||||
}
|
||||
|
||||
type handler struct {
|
||||
logger *slog.Logger
|
||||
prom Prometheus
|
||||
}
|
||||
|
||||
func NewHandler(logger *slog.Logger, prom Prometheus) Handler {
|
||||
return &handler{logger: logger, prom: prom}
|
||||
}
|
||||
|
||||
// QueryRange evaluates an expression over a grid: query, start, end, step,
|
||||
// and optional timeout/stats params, all in Prometheus' formats.
|
||||
func (h *handler) QueryRange(w http.ResponseWriter, r *http.Request) {
|
||||
start, err := parseTime(r.FormValue("start"))
|
||||
if err != nil {
|
||||
h.respondError(r.Context(), w, errBadData, err)
|
||||
return
|
||||
}
|
||||
end, err := parseTime(r.FormValue("end"))
|
||||
if err != nil {
|
||||
h.respondError(r.Context(), w, errBadData, err)
|
||||
return
|
||||
}
|
||||
if end.Before(start) {
|
||||
h.respondError(r.Context(), w, errBadData, errors.NewInvalidInputf(errors.CodeInvalidInput, "end timestamp must not be before start time"))
|
||||
return
|
||||
}
|
||||
step, err := parseDuration(r.FormValue("step"))
|
||||
if err != nil {
|
||||
h.respondError(r.Context(), w, errBadData, err)
|
||||
return
|
||||
}
|
||||
if step <= 0 {
|
||||
h.respondError(r.Context(), w, errBadData, errors.NewInvalidInputf(errors.CodeInvalidInput, "zero or negative query resolution step widths are not accepted. Try a positive integer"))
|
||||
return
|
||||
}
|
||||
// The engine materializes every point of every series; an unbounded
|
||||
// grid is an unbounded allocation. 11,000 points covers 60s resolution
|
||||
// for a week or 1h resolution for a year.
|
||||
if end.Sub(start)/step > 11000 {
|
||||
h.respondError(r.Context(), w, errBadData, errors.NewInvalidInputf(errors.CodeInvalidInput, "exceeded maximum resolution of 11,000 points per timeseries. Try decreasing the query resolution (?step=XX)"))
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel, err := h.contextWithTimeout(r)
|
||||
if err != nil {
|
||||
h.respondError(r.Context(), w, errBadData, err)
|
||||
return
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
if h.tryRangeExecutor(ctx, w, r, start, end, step) {
|
||||
return
|
||||
}
|
||||
|
||||
qry, err := h.prom.Engine().NewRangeQuery(ctx, h.prom.Storage(), nil, r.FormValue("query"), start, end, step)
|
||||
if err != nil {
|
||||
h.respondError(r.Context(), w, errBadData, err)
|
||||
return
|
||||
}
|
||||
h.exec(ctx, w, r, qry)
|
||||
}
|
||||
|
||||
// tryRangeExecutor serves the query the way a RangeExecutor provider is
|
||||
// designed to serve: evaluated inside the datastore when the shape allows.
|
||||
// It reports whether the response was written.
|
||||
func (h *handler) tryRangeExecutor(ctx context.Context, w http.ResponseWriter, r *http.Request, start, end time.Time, step time.Duration) bool {
|
||||
re, ok := h.prom.(RangeExecutor)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
matrix, served, err := re.TryExecuteRange(ctx, r.FormValue("query"), start, end, step)
|
||||
if err != nil {
|
||||
h.respondError(ctx, w, errExec, err)
|
||||
return true
|
||||
}
|
||||
if !served {
|
||||
return false
|
||||
}
|
||||
h.respond(ctx, w, &queryData{ResultType: matrix.Type(), Result: matrix}, nil, nil)
|
||||
return true
|
||||
}
|
||||
|
||||
// Query evaluates an expression at a single instant: query and optional
|
||||
// time/timeout/stats params. A missing time evaluates at the server's now,
|
||||
// as in Prometheus.
|
||||
func (h *handler) Query(w http.ResponseWriter, r *http.Request) {
|
||||
ts := time.Now()
|
||||
if t := r.FormValue("time"); t != "" {
|
||||
var err error
|
||||
ts, err = parseTime(t)
|
||||
if err != nil {
|
||||
h.respondError(r.Context(), w, errBadData, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel, err := h.contextWithTimeout(r)
|
||||
if err != nil {
|
||||
h.respondError(r.Context(), w, errBadData, err)
|
||||
return
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
qry, err := h.prom.Engine().NewInstantQuery(ctx, h.prom.Storage(), nil, r.FormValue("query"), ts)
|
||||
if err != nil {
|
||||
h.respondError(r.Context(), w, errBadData, err)
|
||||
return
|
||||
}
|
||||
h.exec(ctx, w, r, qry)
|
||||
}
|
||||
|
||||
func (h *handler) exec(ctx context.Context, w http.ResponseWriter, r *http.Request, qry promql.Query) {
|
||||
defer qry.Close()
|
||||
res := qry.Exec(ctx)
|
||||
if res.Err != nil {
|
||||
h.logger.ErrorContext(ctx, "error evaluating promql query", errors.Attr(res.Err))
|
||||
switch res.Err.(type) {
|
||||
case promql.ErrQueryCanceled:
|
||||
h.respondError(ctx, w, errCanceled, res.Err)
|
||||
case promql.ErrQueryTimeout:
|
||||
h.respondError(ctx, w, errTimeout, res.Err)
|
||||
case promql.ErrStorage:
|
||||
h.respondError(ctx, w, errInternal, res.Err)
|
||||
default:
|
||||
h.respondError(ctx, w, errExec, res.Err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
data := &queryData{ResultType: res.Value.Type(), Result: res.Value}
|
||||
if r.FormValue("stats") != "" {
|
||||
data.Stats = stats.NewQueryStats(qry.Stats())
|
||||
}
|
||||
warnings, infos := res.Warnings.AsStrings(r.FormValue("query"), 10, 10)
|
||||
h.respond(ctx, w, data, warnings, infos)
|
||||
}
|
||||
|
||||
func (h *handler) contextWithTimeout(r *http.Request) (context.Context, context.CancelFunc, error) {
|
||||
ctx := r.Context()
|
||||
if to := r.FormValue("timeout"); to != "" {
|
||||
timeout, err := parseDuration(to)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
return ctx, cancel, nil
|
||||
}
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
return ctx, cancel, nil
|
||||
}
|
||||
|
||||
// parseTime accepts Prometheus' time formats: float unix seconds or RFC3339.
|
||||
func parseTime(s string) (time.Time, error) {
|
||||
if t, err := strconv.ParseFloat(s, 64); err == nil {
|
||||
sec, ns := math.Modf(t)
|
||||
return time.Unix(int64(sec), int64(ns*float64(time.Second))), nil
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339Nano, s); err == nil {
|
||||
return t, nil
|
||||
}
|
||||
return time.Time{}, errors.NewInvalidInputf(errors.CodeInvalidInput, "cannot parse %q to a valid timestamp", s)
|
||||
}
|
||||
|
||||
// parseDuration accepts Prometheus' duration formats: float seconds or a
|
||||
// duration string like 5m.
|
||||
func parseDuration(s string) (time.Duration, error) {
|
||||
if d, err := strconv.ParseFloat(s, 64); err == nil {
|
||||
ts := d * float64(time.Second)
|
||||
if ts > float64(math.MaxInt64) || ts < float64(math.MinInt64) {
|
||||
return 0, errors.NewInvalidInputf(errors.CodeInvalidInput, "cannot parse %q to a valid duration. It overflows int64", s)
|
||||
}
|
||||
return time.Duration(ts), nil
|
||||
}
|
||||
if d, err := promModel.ParseDuration(s); err == nil {
|
||||
return time.Duration(d), nil
|
||||
}
|
||||
return 0, errors.NewInvalidInputf(errors.CodeInvalidInput, "cannot parse %q to a valid duration", s)
|
||||
}
|
||||
164
pkg/prometheus/render.go
Normal file
164
pkg/prometheus/render.go
Normal file
@@ -0,0 +1,164 @@
|
||||
package prometheus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/prometheus/prometheus/promql/parser"
|
||||
"github.com/prometheus/prometheus/util/stats"
|
||||
"github.com/swaggest/jsonschema-go"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
)
|
||||
|
||||
// This file is the single description of the Prometheus API wire shapes:
|
||||
// the runtime envelope the handler encodes, and the *Schema types that
|
||||
// document the same shapes in the generated OpenAPI spec. The contract is
|
||||
// upstream's (https://prometheus.io/docs/prometheus/latest/querying/api/);
|
||||
// the schemas describe it, they do not define it.
|
||||
|
||||
type errorType string
|
||||
|
||||
const (
|
||||
errBadData errorType = "bad_data"
|
||||
errExec errorType = "execution"
|
||||
errCanceled errorType = "canceled"
|
||||
errTimeout errorType = "timeout"
|
||||
errInternal errorType = "internal"
|
||||
)
|
||||
|
||||
type queryData struct {
|
||||
ResultType parser.ValueType `json:"resultType"`
|
||||
Result parser.Value `json:"result"`
|
||||
Stats stats.QueryStats `json:"stats,omitempty"`
|
||||
}
|
||||
|
||||
type response struct {
|
||||
Status string `json:"status"`
|
||||
Data *queryData `json:"data,omitempty"`
|
||||
ErrorType errorType `json:"errorType,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
Infos []string `json:"infos,omitempty"`
|
||||
}
|
||||
|
||||
func (h *handler) respond(ctx context.Context, w http.ResponseWriter, data *queryData, warnings, infos []string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if err := json.NewEncoder(w).Encode(&response{Status: "success", Data: data, Warnings: warnings, Infos: infos}); err != nil {
|
||||
h.logger.ErrorContext(ctx, "error writing prometheus api response", errors.Attr(err))
|
||||
}
|
||||
}
|
||||
|
||||
// respondError follows Prometheus' status-code mapping: bad_data 400,
|
||||
// execution 422, canceled/timeout 503, internal 500.
|
||||
func (h *handler) respondError(ctx context.Context, w http.ResponseWriter, typ errorType, err error) {
|
||||
code := http.StatusInternalServerError
|
||||
switch typ {
|
||||
case errBadData:
|
||||
code = http.StatusBadRequest
|
||||
case errExec:
|
||||
code = http.StatusUnprocessableEntity
|
||||
case errCanceled, errTimeout:
|
||||
code = http.StatusServiceUnavailable
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
if encErr := json.NewEncoder(w).Encode(&response{Status: "error", ErrorType: typ, Error: err.Error()}); encErr != nil {
|
||||
h.logger.ErrorContext(ctx, "error writing prometheus api error response", errors.Attr(encErr))
|
||||
}
|
||||
}
|
||||
|
||||
// The endpoints accept parameters as URL query params or a form-encoded
|
||||
// body, on GET and POST alike.
|
||||
type QueryParamsSchema struct {
|
||||
Query string `query:"query" required:"true" description:"PromQL expression."`
|
||||
Time string `query:"time" description:"Evaluation timestamp: RFC3339 or float unix seconds. Defaults to the server's current time."`
|
||||
Timeout string `query:"timeout" description:"Evaluation timeout: duration string or float seconds."`
|
||||
Stats string `query:"stats" description:"Any non-empty value includes query statistics in the response."`
|
||||
}
|
||||
|
||||
type QueryRangeParamsSchema struct {
|
||||
Query string `query:"query" required:"true" description:"PromQL expression."`
|
||||
Start string `query:"start" required:"true" description:"Range start: RFC3339 or float unix seconds."`
|
||||
End string `query:"end" required:"true" description:"Range end: RFC3339 or float unix seconds."`
|
||||
Step string `query:"step" required:"true" description:"Resolution step: duration string or float seconds."`
|
||||
Timeout string `query:"timeout" description:"Evaluation timeout: duration string or float seconds."`
|
||||
Stats string `query:"stats" description:"Any non-empty value includes query statistics in the response."`
|
||||
}
|
||||
|
||||
type SuccessResponseSchema struct {
|
||||
Status string `json:"status" enum:"success" required:"true"`
|
||||
Data QueryDataSchema `json:"data" required:"true"`
|
||||
Warnings []string `json:"warnings,omitempty"`
|
||||
Infos []string `json:"infos,omitempty"`
|
||||
}
|
||||
|
||||
// QueryDataSchema is the result union, discriminated by resultType.
|
||||
type QueryDataSchema struct{}
|
||||
|
||||
var _ jsonschema.OneOfExposer = QueryDataSchema{}
|
||||
|
||||
func (QueryDataSchema) JSONSchemaOneOf() []interface{} {
|
||||
return []interface{}{MatrixDataSchema{}, VectorDataSchema{}, ScalarDataSchema{}, StringDataSchema{}}
|
||||
}
|
||||
|
||||
type MatrixDataSchema struct {
|
||||
ResultType string `json:"resultType" enum:"matrix" required:"true"`
|
||||
Result []MatrixSeriesSchema `json:"result" required:"true"`
|
||||
}
|
||||
|
||||
type MatrixSeriesSchema struct {
|
||||
Metric map[string]string `json:"metric" required:"true"`
|
||||
Values []SamplePairSchema `json:"values" required:"true"`
|
||||
}
|
||||
|
||||
type VectorDataSchema struct {
|
||||
ResultType string `json:"resultType" enum:"vector" required:"true"`
|
||||
Result []VectorSampleSchema `json:"result" required:"true"`
|
||||
}
|
||||
|
||||
type VectorSampleSchema struct {
|
||||
Metric map[string]string `json:"metric" required:"true"`
|
||||
Value SamplePairSchema `json:"value" required:"true"`
|
||||
}
|
||||
|
||||
type ScalarDataSchema struct {
|
||||
ResultType string `json:"resultType" enum:"scalar" required:"true"`
|
||||
Result SamplePairSchema `json:"result" required:"true"`
|
||||
}
|
||||
|
||||
type StringDataSchema struct {
|
||||
ResultType string `json:"resultType" enum:"string" required:"true"`
|
||||
Result SamplePairSchema `json:"result" required:"true"`
|
||||
}
|
||||
|
||||
// SamplePairSchema is the positional [timestamp, value] pair: a float of
|
||||
// unix seconds, then the value as a string ("NaN", "+Inf" and "-Inf"
|
||||
// included). Struct reflection cannot express a positional array, so the
|
||||
// schema is authored by hand.
|
||||
type SamplePairSchema struct{}
|
||||
|
||||
var _ jsonschema.Exposer = SamplePairSchema{}
|
||||
|
||||
func (SamplePairSchema) JSONSchema() (jsonschema.Schema, error) {
|
||||
item := jsonschema.Schema{}
|
||||
item.WithOneOf(
|
||||
(&jsonschema.Schema{}).WithType(jsonschema.Number.Type()).ToSchemaOrBool(),
|
||||
(&jsonschema.Schema{}).WithType(jsonschema.String.Type()).ToSchemaOrBool(),
|
||||
)
|
||||
s := jsonschema.Schema{}
|
||||
s.WithType(jsonschema.Array.Type())
|
||||
s.WithMinItems(2)
|
||||
s.WithMaxItems(2)
|
||||
s.WithItems(*(&jsonschema.Items{}).WithSchemaOrBool(item.ToSchemaOrBool()))
|
||||
s.WithDescription(`A [timestamp, value] pair: float unix seconds, then the string-encoded sample value ("NaN", "+Inf", "-Inf" included).`)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
type ErrorResponseSchema struct {
|
||||
Status string `json:"status" enum:"error" required:"true"`
|
||||
ErrorType string `json:"errorType" enum:"bad_data,execution,canceled,timeout,internal" required:"true"`
|
||||
Error string `json:"error" required:"true"`
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package querier
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"reflect"
|
||||
@@ -11,12 +12,12 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ClickHouse/clickhouse-go/v2/lib/chcol"
|
||||
"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/spantypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrystoretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/bytedance/sonic"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -30,8 +31,6 @@ var (
|
||||
// written clickhouse query. The column alias indcate which value is
|
||||
// to be considered as final result (or target).
|
||||
legacyReservedColumnTargetAliases = []string{"__result", "__value", "result", "res", "value"}
|
||||
|
||||
CodeFailUnmarshalJSONColumn = errors.MustNewCode("fail_unmarshal_json_column")
|
||||
)
|
||||
|
||||
// stripKeyAlias removes the __SELECT_KEY_<n>_ / __GROUP_BY_KEY_<n>_ prefix from a result
|
||||
@@ -40,6 +39,32 @@ func stripKeyAlias(name string) string {
|
||||
return keyAliasRe.ReplaceAllString(name, "")
|
||||
}
|
||||
|
||||
// unwrapVariant returns the concrete value inside the chcol.Variant envelope the driver scans a
|
||||
// Dynamic column — a JSON path such as body_v2.level — into.
|
||||
func unwrapVariant(val any) any {
|
||||
if v, ok := val.(chcol.Variant); ok {
|
||||
return v.Any()
|
||||
}
|
||||
return val
|
||||
}
|
||||
|
||||
// labelValue renders a group-by value the payload cannot carry as a scalar — a JSON column, or a
|
||||
// Dynamic one — as a stable string, so that rows differing only in that value land in different
|
||||
// series. JSON goes through encoding/json for its sorted map keys: ClickHouse groups documents by
|
||||
// structure, so two rows it considers equal have to produce the same label.
|
||||
func labelValue(val any) string {
|
||||
val = unwrapVariant(val)
|
||||
if val == nil {
|
||||
return ""
|
||||
}
|
||||
if v, ok := val.(telemetrystoretypes.JSONValue); ok {
|
||||
if raw, err := json.Marshal(v); err == nil {
|
||||
return string(raw)
|
||||
}
|
||||
}
|
||||
return fmt.Sprint(val)
|
||||
}
|
||||
|
||||
// consume reads every row and shapes it into the payload expected for the
|
||||
// given request type.
|
||||
//
|
||||
@@ -205,6 +230,14 @@ func readAsTimeSeries(rows driver.Rows, queryWindow *qbtypes.TimeRange, step qbt
|
||||
Value: *val,
|
||||
})
|
||||
|
||||
case *telemetrystoretypes.JSONValue, *chcol.Variant:
|
||||
val := labelValue(derefValue(ptr))
|
||||
lblVals = append(lblVals, val)
|
||||
lblObjs = append(lblObjs, &qbtypes.Label{
|
||||
Key: telemetrytypes.TelemetryFieldKey{Name: name},
|
||||
Value: val,
|
||||
})
|
||||
|
||||
default:
|
||||
continue
|
||||
}
|
||||
@@ -345,7 +378,7 @@ func readAsScalar(rows driver.Rows, queryName string) (*qbtypes.ScalarData, erro
|
||||
// 2. deref each slot into the output row
|
||||
row := make([]any, len(scan))
|
||||
for i, cell := range scan {
|
||||
row[i] = derefValue(cell)
|
||||
row[i] = unwrapVariant(derefValue(cell))
|
||||
}
|
||||
data = append(data, row)
|
||||
}
|
||||
@@ -382,31 +415,13 @@ func readAsRaw(rows driver.Rows, queryName string) (*qbtypes.RawData, error) {
|
||||
colTypes := rows.ColumnTypes()
|
||||
colCnt := len(colNames)
|
||||
|
||||
// Helper that decides scan target per column based on DB type
|
||||
makeScanTarget := func(i int) any {
|
||||
dbt := strings.ToUpper(colTypes[i].DatabaseTypeName())
|
||||
if strings.HasPrefix(dbt, "JSON") {
|
||||
// Since the driver fails to decode JSON/Dynamic into native Go values, we read it as raw bytes
|
||||
// TODO: check in future if fixed in the driver
|
||||
var v []byte
|
||||
return &v
|
||||
}
|
||||
return reflect.New(colTypes[i].ScanType()).Interface()
|
||||
}
|
||||
|
||||
// Build a template slice of correctly-typed pointers once
|
||||
scanTpl := make([]any, colCnt)
|
||||
for i := range colTypes {
|
||||
scanTpl[i] = makeScanTarget(i)
|
||||
}
|
||||
|
||||
var outRows []*qbtypes.RawRow
|
||||
|
||||
for rows.Next() {
|
||||
// fresh copy of the scan slice (otherwise the driver reuses pointers)
|
||||
scan := make([]any, colCnt)
|
||||
for i := range scanTpl {
|
||||
scan[i] = makeScanTarget(i)
|
||||
for i := range colTypes {
|
||||
scan[i] = reflect.New(colTypes[i].ScanType()).Interface()
|
||||
}
|
||||
|
||||
if err := rows.Scan(scan...); err != nil {
|
||||
@@ -421,21 +436,7 @@ func readAsRaw(rows driver.Rows, queryName string) (*qbtypes.RawData, error) {
|
||||
name := stripKeyAlias(colNames[i])
|
||||
|
||||
// de-reference the typed pointer to any
|
||||
val := reflect.ValueOf(cellPtr).Elem().Interface()
|
||||
// Post-process JSON columns: unmarshal bytes into map[string]any
|
||||
if strings.HasPrefix(strings.ToUpper(colTypes[i].DatabaseTypeName()), "JSON") {
|
||||
switch x := val.(type) {
|
||||
case []byte:
|
||||
var m map[string]any
|
||||
err := sonic.Unmarshal(x, &m)
|
||||
if err != nil {
|
||||
return nil, errors.WrapInternalf(err, CodeFailUnmarshalJSONColumn, "failed to unmarshal JSON column %s", name)
|
||||
}
|
||||
val = m
|
||||
default:
|
||||
// already a structured type (map[string]any, []any, etc.)
|
||||
}
|
||||
}
|
||||
val := unwrapVariant(reflect.ValueOf(cellPtr).Elem().Interface())
|
||||
|
||||
// special-case: timestamp column
|
||||
if name == "timestamp" || name == "timestamp_datetime" {
|
||||
|
||||
@@ -3,8 +3,16 @@ package querier
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ClickHouse/clickhouse-go/v2/lib/chcol"
|
||||
cmock "github.com/SigNoz/clickhouse-go-mock"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/spantypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrystoretypes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestMergeSpanAttributeColumns_ParsesEventsAndLinks(t *testing.T) {
|
||||
@@ -75,6 +83,103 @@ func TestMergeSpanAttributeColumns_ParsesEventsAndLinks(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A ClickHouse query can put a JSON column in the result of any request type — e.g.
|
||||
// `select * from signoz_logs.logs_v2` on a body_v2 stack, where `*` covers body_v2.
|
||||
func TestConsume_JSONColumn(t *testing.T) {
|
||||
ts := time.Date(2026, 8, 14, 10, 0, 0, 0, time.UTC)
|
||||
body := `{"level":"error","attrs":{"code":500}}`
|
||||
wantBody := telemetrystoretypes.JSONValue{
|
||||
"level": "error",
|
||||
"attrs": map[string]any{"code": float64(500)},
|
||||
}
|
||||
|
||||
// the scalar reader reuses its scan slots across rows, so each row must still carry its own body
|
||||
t.Run("scalar", func(t *testing.T) {
|
||||
rows := telemetrystore.WrapRows(cmock.NewRows([]cmock.ColumnType{
|
||||
{Name: "body_v2", Type: "JSON"},
|
||||
{Name: "__result_0", Type: "UInt64"},
|
||||
}, [][]any{{body, uint64(3)}, {`{"level":"warn"}`, uint64(1)}}))
|
||||
|
||||
payload, err := consume(rows, qbtypes.RequestTypeScalar, nil, qbtypes.Step{}, "A")
|
||||
require.NoError(t, err)
|
||||
|
||||
data := payload.(*qbtypes.ScalarData)
|
||||
require.Len(t, data.Data, 2)
|
||||
assert.Equal(t, wantBody, data.Data[0][0])
|
||||
assert.Equal(t, uint64(3), data.Data[0][1])
|
||||
assert.Equal(t, telemetrystoretypes.JSONValue{"level": "warn"}, data.Data[1][0])
|
||||
assert.Equal(t, uint64(1), data.Data[1][1])
|
||||
})
|
||||
|
||||
t.Run("time series", func(t *testing.T) {
|
||||
rows := telemetrystore.WrapRows(cmock.NewRows([]cmock.ColumnType{
|
||||
{Name: "ts", Type: "DateTime"},
|
||||
{Name: "body_v2", Type: "JSON"},
|
||||
{Name: "__result_0", Type: "UInt64"},
|
||||
}, [][]any{{ts, body, uint64(3)}}))
|
||||
|
||||
payload, err := consume(rows, qbtypes.RequestTypeTimeSeries, nil, qbtypes.Step{}, "A")
|
||||
require.NoError(t, err)
|
||||
|
||||
data := payload.(*qbtypes.TimeSeriesData)
|
||||
require.Len(t, data.Aggregations, 1)
|
||||
require.Len(t, data.Aggregations[0].Series, 1)
|
||||
require.Len(t, data.Aggregations[0].Series[0].Values, 1)
|
||||
assert.Equal(t, float64(3), data.Aggregations[0].Series[0].Values[0].Value)
|
||||
})
|
||||
|
||||
// grouping by a JSON column is legal in ClickHouse, so each document has to label its own
|
||||
// series rather than being dropped, which would merge every group into one
|
||||
t.Run("time series grouped by the JSON column", func(t *testing.T) {
|
||||
rows := telemetrystore.WrapRows(cmock.NewRows([]cmock.ColumnType{
|
||||
{Name: "ts", Type: "DateTime"},
|
||||
{Name: "body_v2", Type: "JSON"},
|
||||
{Name: "__result_0", Type: "UInt64"},
|
||||
}, [][]any{
|
||||
{ts, `{"level":"error"}`, uint64(7)},
|
||||
{ts, `{"level":"warn"}`, uint64(2)},
|
||||
}))
|
||||
|
||||
payload, err := consume(rows, qbtypes.RequestTypeTimeSeries, nil, qbtypes.Step{}, "A")
|
||||
require.NoError(t, err)
|
||||
|
||||
data := payload.(*qbtypes.TimeSeriesData)
|
||||
require.Len(t, data.Aggregations, 1)
|
||||
require.Len(t, data.Aggregations[0].Series, 2)
|
||||
|
||||
got := map[string]float64{}
|
||||
for _, series := range data.Aggregations[0].Series {
|
||||
require.Len(t, series.Labels, 1)
|
||||
require.Len(t, series.Values, 1)
|
||||
got[series.Labels[0].Value.(string)] = series.Values[0].Value
|
||||
}
|
||||
assert.Equal(t, map[string]float64{`{"level":"error"}`: 7, `{"level":"warn"}`: 2}, got)
|
||||
})
|
||||
|
||||
t.Run("raw", func(t *testing.T) {
|
||||
rows := telemetrystore.WrapRows(cmock.NewRows([]cmock.ColumnType{
|
||||
{Name: "timestamp", Type: "DateTime"},
|
||||
{Name: "body_v2", Type: "JSON"},
|
||||
}, [][]any{{ts, body}}))
|
||||
|
||||
payload, err := consume(rows, qbtypes.RequestTypeRaw, nil, qbtypes.Step{}, "A")
|
||||
require.NoError(t, err)
|
||||
|
||||
data := payload.(*qbtypes.RawData)
|
||||
require.Len(t, data.Rows, 1)
|
||||
assert.Equal(t, ts, data.Rows[0].Timestamp.UTC())
|
||||
assert.Equal(t, wantBody, data.Rows[0].Data["body_v2"])
|
||||
})
|
||||
}
|
||||
|
||||
// A JSON path (e.g. `body_v2.level`) comes back as a Dynamic column, which the driver scans
|
||||
// into a chcol.Variant envelope rather than the value itself.
|
||||
func TestUnwrapVariant(t *testing.T) {
|
||||
assert.Equal(t, "error", unwrapVariant(chcol.NewDynamicWithType("error", "String")))
|
||||
assert.Nil(t, unwrapVariant(chcol.Dynamic{}))
|
||||
assert.Equal(t, uint64(3), unwrapVariant(uint64(3)))
|
||||
}
|
||||
|
||||
func TestMergeSpanAttributeColumns_EmptyEventsAndLinks(t *testing.T) {
|
||||
data := map[string]any{
|
||||
"events": []string{},
|
||||
|
||||
@@ -13,9 +13,10 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/flagger"
|
||||
"github.com/SigNoz/signoz/pkg/types/featuretypes"
|
||||
"github.com/SigNoz/signoz/pkg/querybuilder"
|
||||
"github.com/SigNoz/signoz/pkg/types/featuretypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrystoretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
@@ -72,9 +73,13 @@ func (q *querier) postProcessResults(ctx context.Context, orgID valuer.UUID, res
|
||||
case qbtypes.QueryBuilderQuery[qbtypes.LogAggregation]:
|
||||
if result, ok := typedResults[spec.Name]; ok {
|
||||
result = postProcessBuilderQuery(q, result, spec, req)
|
||||
result = q.postProcessLogBody(ctx, orgID, result, req)
|
||||
result = q.postProcessLogBody(ctx, orgID, result)
|
||||
typedResults[spec.Name] = result
|
||||
}
|
||||
case qbtypes.ClickHouseQuery:
|
||||
if result, ok := typedResults[spec.Name]; ok {
|
||||
typedResults[spec.Name] = q.postProcessLogBody(ctx, orgID, result)
|
||||
}
|
||||
case qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]:
|
||||
if result, ok := typedResults[spec.Name]; ok {
|
||||
result = postProcessMetricQuery(q, result, spec, req)
|
||||
@@ -1051,32 +1056,44 @@ func (q *querier) calculateFormulaStep(expression string, req *qbtypes.QueryRang
|
||||
return result
|
||||
}
|
||||
|
||||
// postProcessLogBody removes the "message" key from the body map when it is empty.
|
||||
// Only runs for raw list queries with the use_json_body feature enabled.
|
||||
func (q *querier) postProcessLogBody(ctx context.Context, orgID valuer.UUID, result *qbtypes.Result, req *qbtypes.QueryRangeRequest) *qbtypes.Result {
|
||||
if req.RequestType != qbtypes.RequestTypeRaw {
|
||||
return result
|
||||
}
|
||||
// postProcessLogBody removes the empty "message" the typed body path materializes into every
|
||||
// document, wherever a decoded body lands in the payload — raw rows and scalar cells, under the
|
||||
// column's own name or the builder's `body` alias. Only runs with the use_json_body feature
|
||||
// enabled. A time-series label keeps the document verbatim: it is the group key.
|
||||
func (q *querier) postProcessLogBody(ctx context.Context, orgID valuer.UUID, result *qbtypes.Result) *qbtypes.Result {
|
||||
if !q.fl.BooleanOrEmpty(ctx, flagger.FeatureUseJSONBody, featuretypes.NewFlaggerEvaluationContext(orgID)) {
|
||||
return result
|
||||
}
|
||||
rawData, ok := result.Value.(*qbtypes.RawData)
|
||||
if !ok {
|
||||
return result
|
||||
}
|
||||
for _, row := range rawData.Rows {
|
||||
bodyMap, ok := row.Data["body"].(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
switch data := result.Value.(type) {
|
||||
case *qbtypes.RawData:
|
||||
for _, row := range data.Rows {
|
||||
for _, name := range []string{"body", "body_v2"} {
|
||||
stripEmptyBodyMessage(row.Data[name])
|
||||
}
|
||||
}
|
||||
if msg, exists := bodyMap["message"]; exists {
|
||||
switch v := msg.(type) {
|
||||
case string:
|
||||
if v == "" {
|
||||
delete(bodyMap, "message")
|
||||
}
|
||||
case *qbtypes.ScalarData:
|
||||
for idx, column := range data.Columns {
|
||||
if column.Name != "body" && column.Name != "body_v2" {
|
||||
continue
|
||||
}
|
||||
for _, row := range data.Data {
|
||||
stripEmptyBodyMessage(row[idx])
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// stripEmptyBodyMessage drops `message: ""` from a decoded body document: the message path is
|
||||
// typed String in the JSON column, so ClickHouse materializes it even for documents that never
|
||||
// carried one. Anything that is not a decoded document — the legacy string body, a NULL cell —
|
||||
// is legal under these names and left alone.
|
||||
func stripEmptyBodyMessage(val any) {
|
||||
bodyMap, ok := val.(telemetrystoretypes.JSONValue)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if msg, ok := bodyMap["message"].(string); ok && msg == "" {
|
||||
delete(bodyMap, "message")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -189,6 +189,7 @@ func TestRunExecutesQueriesConcurrently(t *testing.T) {
|
||||
|
||||
q := &querier{
|
||||
logger: instrumentationtest.New().Logger(),
|
||||
fl: flaggertest.New(t),
|
||||
maxConcurrentQueries: numQueries,
|
||||
}
|
||||
|
||||
@@ -236,6 +237,7 @@ func TestRunRespectsMaxConcurrentQueries(t *testing.T) {
|
||||
|
||||
q := &querier{
|
||||
logger: instrumentationtest.New().Logger(),
|
||||
fl: flaggertest.New(t),
|
||||
maxConcurrentQueries: limit,
|
||||
}
|
||||
|
||||
@@ -273,6 +275,7 @@ func TestRunRespectsMaxConcurrentQueries(t *testing.T) {
|
||||
func TestRunQueryErrorCancelsSiblings(t *testing.T) {
|
||||
q := &querier{
|
||||
logger: instrumentationtest.New().Logger(),
|
||||
fl: flaggertest.New(t),
|
||||
maxConcurrentQueries: 4,
|
||||
}
|
||||
|
||||
|
||||
@@ -387,6 +387,7 @@ func (aH *APIHandler) Respond(w http.ResponseWriter, data interface{}) {
|
||||
func (aH *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
|
||||
router.HandleFunc("/api/v1/query_range", am.ViewAccess(aH.queryRangeMetrics)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/v1/query", am.ViewAccess(aH.queryMetrics)).Methods(http.MethodGet)
|
||||
|
||||
router.HandleFunc("/api/v1/rules", am.ViewAccess(aH.listRules)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/v1/rules/{id}", am.ViewAccess(aH.getRule)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/v1/rules", am.EditAccess(aH.createRule)).Methods(http.MethodPost)
|
||||
|
||||
@@ -75,6 +75,16 @@ func queryRangeVariables(body []byte) (map[string]qbtypes.VariableItem, error) {
|
||||
return variables, nil
|
||||
}
|
||||
|
||||
// PromQLResources is the resource set of a bare PromQL query: metrics on
|
||||
// the promql wildcard, the same ID resourcesForQuery assigns to a PromQL
|
||||
// query inside a composite — one grant covers both entry points.
|
||||
func PromQLResources(coretypes.ExtractorContext) ([]coretypes.ResourceWithID, error) {
|
||||
return []coretypes.ResourceWithID{{
|
||||
Resource: coretypes.ResourceTelemetryResourceMetrics,
|
||||
ID: qbtypes.QueryTypePromQL.StringValue() + "/" + coretypes.WildCardSelectorString,
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func resourcesForQuery(query gjson.Result, variables map[string]qbtypes.VariableItem) ([]coretypes.ResourceWithID, error) {
|
||||
queryType := query.Get("type").String()
|
||||
typeWildcard := queryType + "/" + coretypes.WildCardSelectorString
|
||||
|
||||
@@ -50,6 +50,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/tracedetail/impltracedetail"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tracefunnel"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tracefunnel/impltracefunnel"
|
||||
"github.com/SigNoz/signoz/pkg/prometheus"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
"github.com/SigNoz/signoz/pkg/ruler"
|
||||
"github.com/SigNoz/signoz/pkg/ruler/signozruler"
|
||||
@@ -84,6 +85,7 @@ type Handlers struct {
|
||||
RuleStateHistory rulestatehistory.Handler
|
||||
SpanMapperHandler spanmapper.Handler
|
||||
AlertmanagerHandler alertmanager.Handler
|
||||
PrometheusHandler prometheus.Handler
|
||||
TraceDetail tracedetail.Handler
|
||||
RulerHandler ruler.Handler
|
||||
LLMPricingRuleHandler llmpricingrule.Handler
|
||||
@@ -104,6 +106,7 @@ func NewHandlers(
|
||||
zeusService zeus.Zeus,
|
||||
registryHandler factory.Handler,
|
||||
alertmanagerService alertmanager.Alertmanager,
|
||||
prometheusService prometheus.Prometheus,
|
||||
rulerService ruler.Ruler,
|
||||
statsAggregator statsreporter.Aggregator,
|
||||
) Handlers {
|
||||
@@ -133,6 +136,7 @@ func NewHandlers(
|
||||
CloudIntegrationHandler: implcloudintegration.NewHandler(modules.CloudIntegration),
|
||||
SpanMapperHandler: implspanmapper.NewHandler(modules.SpanMapper),
|
||||
AlertmanagerHandler: signozalertmanager.NewHandler(alertmanagerService),
|
||||
PrometheusHandler: prometheus.NewHandler(providerSettings.Logger, prometheusService),
|
||||
TraceDetail: impltracedetail.NewHandler(modules.TraceDetail),
|
||||
RulerHandler: signozruler.NewHandler(rulerService),
|
||||
LLMPricingRuleHandler: impllmpricingrule.NewHandler(modules.LLMPricingRule),
|
||||
|
||||
@@ -49,7 +49,9 @@ func TestNewHandlers(t *testing.T) {
|
||||
queryParser := queryparser.New(providerSettings)
|
||||
require.NoError(t, err)
|
||||
tagModule := impltag.NewModule(impltag.NewStore(sqlstore))
|
||||
dashboardModule := impldashboard.NewModule(impldashboard.NewStore(sqlstore), providerSettings, nil, orgGetter, queryParser, tagModule)
|
||||
systemDashboardRegistry, err := impldashboard.NewSystemDashboardRegistry()
|
||||
require.NoError(t, err)
|
||||
dashboardModule := impldashboard.NewModule(impldashboard.NewStore(sqlstore), providerSettings, nil, orgGetter, queryParser, tagModule, systemDashboardRegistry)
|
||||
|
||||
flagger, err := flagger.New(context.Background(), instrumentationtest.New().ToProviderSettings(), flagger.Config{}, flagger.MustNewRegistry())
|
||||
require.NoError(t, err)
|
||||
@@ -63,7 +65,7 @@ func TestNewHandlers(t *testing.T) {
|
||||
|
||||
querierHandler := querier.NewHandler(providerSettings, nil, nil)
|
||||
registryHandler := factory.NewHandler(nil)
|
||||
handlers := NewHandlers(modules, providerSettings, nil, querierHandler, nil, nil, nil, nil, nil, nil, nil, registryHandler, alertmanager, nil, nil)
|
||||
handlers := NewHandlers(modules, providerSettings, nil, querierHandler, nil, nil, nil, nil, nil, nil, nil, registryHandler, alertmanager, nil, nil, nil)
|
||||
reflectVal := reflect.ValueOf(handlers)
|
||||
for i := 0; i < reflectVal.NumField(); i++ {
|
||||
f := reflectVal.Field(i)
|
||||
|
||||
@@ -67,35 +67,35 @@ import (
|
||||
)
|
||||
|
||||
type Modules struct {
|
||||
OrgGetter organization.Getter
|
||||
OrgSetter organization.Setter
|
||||
Preference preference.Module
|
||||
UserSetter user.Setter
|
||||
UserGetter user.Getter
|
||||
RetentionGetter retention.Getter
|
||||
SavedView savedview.Module
|
||||
Apdex apdex.Module
|
||||
Dashboard dashboard.Module
|
||||
QuickFilter quickfilter.Module
|
||||
TraceFunnel tracefunnel.Module
|
||||
RawDataExport rawdataexport.Module
|
||||
AuthDomain authdomain.Module
|
||||
Session session.Module
|
||||
Services services.Module
|
||||
SpanPercentile spanpercentile.Module
|
||||
MetricsExplorer metricsexplorer.Module
|
||||
MetricReductionRule metricreductionrule.Module
|
||||
InfraMonitoring inframonitoring.Module
|
||||
OrgGetter organization.Getter
|
||||
OrgSetter organization.Setter
|
||||
Preference preference.Module
|
||||
UserSetter user.Setter
|
||||
UserGetter user.Getter
|
||||
RetentionGetter retention.Getter
|
||||
SavedView savedview.Module
|
||||
Apdex apdex.Module
|
||||
Dashboard dashboard.Module
|
||||
QuickFilter quickfilter.Module
|
||||
TraceFunnel tracefunnel.Module
|
||||
RawDataExport rawdataexport.Module
|
||||
AuthDomain authdomain.Module
|
||||
Session session.Module
|
||||
Services services.Module
|
||||
SpanPercentile spanpercentile.Module
|
||||
MetricsExplorer metricsexplorer.Module
|
||||
MetricReductionRule metricreductionrule.Module
|
||||
InfraMonitoring inframonitoring.Module
|
||||
Promote promote.Module
|
||||
ServiceAccount serviceaccount.Module
|
||||
ServiceAccountGetter serviceaccount.Getter
|
||||
CloudIntegration cloudintegration.Module
|
||||
LogsPipeline logspipeline.Module
|
||||
RuleStateHistory rulestatehistory.Module
|
||||
TraceDetail tracedetail.Module
|
||||
SpanMapper spanmapper.Module
|
||||
LLMPricingRule llmpricingrule.Module
|
||||
Tag tag.Module
|
||||
LogsPipeline logspipeline.Module
|
||||
RuleStateHistory rulestatehistory.Module
|
||||
TraceDetail tracedetail.Module
|
||||
SpanMapper spanmapper.Module
|
||||
LLMPricingRule llmpricingrule.Module
|
||||
Tag tag.Module
|
||||
}
|
||||
|
||||
func NewModules(
|
||||
@@ -126,7 +126,7 @@ func NewModules(
|
||||
metricReductionRule metricreductionrule.Module,
|
||||
) Modules {
|
||||
quickfilter := implquickfilter.NewModule(implquickfilter.NewStore(sqlstore))
|
||||
orgSetter := implorganization.NewSetter(implorganization.NewStore(sqlstore), alertmanager, quickfilter)
|
||||
orgSetter := implorganization.NewSetter(implorganization.NewStore(sqlstore), alertmanager, quickfilter, dashboard)
|
||||
// Cleanup callbacks from other modules, invoked when a user is deleted.
|
||||
onDeleteUser := []user.OnDeleteUser{
|
||||
dashboard.DeletePreferencesForUser,
|
||||
@@ -136,34 +136,34 @@ func NewModules(
|
||||
authDomainModule := implauthdomain.NewModule(implauthdomain.NewStore(sqlstore), authNs, authz)
|
||||
|
||||
return Modules{
|
||||
OrgGetter: orgGetter,
|
||||
OrgSetter: orgSetter,
|
||||
Preference: implpreference.NewModule(implpreference.NewStore(sqlstore), preferencetypes.NewAvailablePreference()),
|
||||
SavedView: implsavedview.NewModule(implsavedview.NewStore(sqlstore)),
|
||||
Apdex: implapdex.NewModule(sqlstore),
|
||||
Dashboard: dashboard,
|
||||
UserSetter: userSetter,
|
||||
UserGetter: userGetter,
|
||||
RetentionGetter: retentionGetter,
|
||||
QuickFilter: quickfilter,
|
||||
TraceFunnel: impltracefunnel.NewModule(impltracefunnel.NewStore(sqlstore)),
|
||||
RawDataExport: implrawdataexport.NewModule(querier),
|
||||
AuthDomain: authDomainModule,
|
||||
Session: implsession.NewModule(providerSettings, authNs, userSetter, userGetter, authDomainModule, tokenizer, orgGetter, authz, config.Global),
|
||||
SpanPercentile: implspanpercentile.NewModule(querier, providerSettings),
|
||||
Services: implservices.NewModule(querier, telemetryStore),
|
||||
MetricsExplorer: implmetricsexplorer.NewModule(telemetryStore, telemetryMetadataStore, cache, ruleStore, dashboard, fl, providerSettings, config.MetricsExplorer),
|
||||
MetricReductionRule: metricReductionRule,
|
||||
InfraMonitoring: implinframonitoring.NewModule(telemetryStore, telemetryMetadataStore, querier, fl, providerSettings, config.InfraMonitoring),
|
||||
Promote: implpromote.NewModule(telemetryMetadataStore, telemetryStore),
|
||||
OrgGetter: orgGetter,
|
||||
OrgSetter: orgSetter,
|
||||
Preference: implpreference.NewModule(implpreference.NewStore(sqlstore), preferencetypes.NewAvailablePreference()),
|
||||
SavedView: implsavedview.NewModule(implsavedview.NewStore(sqlstore)),
|
||||
Apdex: implapdex.NewModule(sqlstore),
|
||||
Dashboard: dashboard,
|
||||
UserSetter: userSetter,
|
||||
UserGetter: userGetter,
|
||||
RetentionGetter: retentionGetter,
|
||||
QuickFilter: quickfilter,
|
||||
TraceFunnel: impltracefunnel.NewModule(impltracefunnel.NewStore(sqlstore)),
|
||||
RawDataExport: implrawdataexport.NewModule(querier),
|
||||
AuthDomain: authDomainModule,
|
||||
Session: implsession.NewModule(providerSettings, authNs, userSetter, userGetter, authDomainModule, tokenizer, orgGetter, authz, config.Global),
|
||||
SpanPercentile: implspanpercentile.NewModule(querier, providerSettings),
|
||||
Services: implservices.NewModule(querier, telemetryStore),
|
||||
MetricsExplorer: implmetricsexplorer.NewModule(telemetryStore, telemetryMetadataStore, cache, ruleStore, dashboard, fl, providerSettings, config.MetricsExplorer),
|
||||
MetricReductionRule: metricReductionRule,
|
||||
InfraMonitoring: implinframonitoring.NewModule(telemetryStore, telemetryMetadataStore, querier, fl, providerSettings, config.InfraMonitoring),
|
||||
Promote: implpromote.NewModule(telemetryMetadataStore, telemetryStore),
|
||||
ServiceAccount: serviceAccount,
|
||||
ServiceAccountGetter: serviceAccountGetter,
|
||||
LogsPipeline: impllogspipeline.NewModule(sqlstore),
|
||||
RuleStateHistory: implrulestatehistory.NewModule(implrulestatehistory.NewStore(telemetryStore, telemetryMetadataStore, providerSettings.Logger), ruleStore),
|
||||
CloudIntegration: cloudIntegrationModule,
|
||||
TraceDetail: impltracedetail.NewModule(impltracedetail.NewTraceStore(telemetryStore), providerSettings, config.TraceDetail),
|
||||
SpanMapper: implspanmapper.NewModule(implspanmapper.NewStore(sqlstore), fl),
|
||||
LLMPricingRule: impllmpricingrule.NewModule(impllmpricingrule.NewStore(sqlstore), fl, querier),
|
||||
Tag: tagModule,
|
||||
LogsPipeline: impllogspipeline.NewModule(sqlstore),
|
||||
RuleStateHistory: implrulestatehistory.NewModule(implrulestatehistory.NewStore(telemetryStore, telemetryMetadataStore, providerSettings.Logger), ruleStore),
|
||||
CloudIntegration: cloudIntegrationModule,
|
||||
TraceDetail: impltracedetail.NewModule(impltracedetail.NewTraceStore(telemetryStore), providerSettings, config.TraceDetail),
|
||||
SpanMapper: implspanmapper.NewModule(implspanmapper.NewStore(sqlstore), fl),
|
||||
LLMPricingRule: impllmpricingrule.NewModule(impllmpricingrule.NewStore(sqlstore), fl, querier),
|
||||
Tag: tagModule,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +51,9 @@ func TestNewModules(t *testing.T) {
|
||||
queryParser := queryparser.New(providerSettings)
|
||||
require.NoError(t, err)
|
||||
tagModule := impltag.NewModule(impltag.NewStore(sqlstore))
|
||||
dashboardModule := impldashboard.NewModule(impldashboard.NewStore(sqlstore), providerSettings, nil, orgGetter, queryParser, tagModule)
|
||||
systemDashboardRegistry, err := impldashboard.NewSystemDashboardRegistry()
|
||||
require.NoError(t, err)
|
||||
dashboardModule := impldashboard.NewModule(impldashboard.NewStore(sqlstore), providerSettings, nil, orgGetter, queryParser, tagModule, systemDashboardRegistry)
|
||||
|
||||
flagger, err := flagger.New(context.Background(), instrumentationtest.New().ToProviderSettings(), flagger.Config{}, flagger.MustNewRegistry())
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -37,6 +37,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanmapper"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tracedetail"
|
||||
"github.com/SigNoz/signoz/pkg/modules/user"
|
||||
"github.com/SigNoz/signoz/pkg/prometheus"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
"github.com/SigNoz/signoz/pkg/ruler"
|
||||
"github.com/SigNoz/signoz/pkg/statsreporter"
|
||||
@@ -88,6 +89,7 @@ func NewOpenAPI(ctx context.Context, instrumentation instrumentation.Instrumenta
|
||||
struct{ rulestatehistory.Handler }{},
|
||||
struct{ spanmapper.Handler }{},
|
||||
struct{ alertmanager.Handler }{},
|
||||
struct{ prometheus.Handler }{},
|
||||
struct{ llmpricingrule.Handler }{},
|
||||
struct{ tracedetail.Handler }{},
|
||||
struct{ ruler.Handler }{},
|
||||
|
||||
@@ -245,6 +245,7 @@ func NewSQLMigrationProviderFactories(
|
||||
sqlmigration.NewMigrateLambdaDashboardsFactory(),
|
||||
sqlmigration.NewAddAuthDomainTuplesFactory(sqlstore),
|
||||
sqlmigration.NewAddDeploymentHostTuplesFactory(sqlstore),
|
||||
sqlmigration.NewAddSystemDashboardFactory(sqlstore, sqlschema),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -343,6 +344,7 @@ func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.Au
|
||||
handlers.RuleStateHistory,
|
||||
handlers.SpanMapperHandler,
|
||||
handlers.AlertmanagerHandler,
|
||||
handlers.PrometheusHandler,
|
||||
handlers.LLMPricingRuleHandler,
|
||||
handlers.TraceDetail,
|
||||
handlers.RulerHandler,
|
||||
|
||||
@@ -60,6 +60,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
pkgtokenizer "github.com/SigNoz/signoz/pkg/tokenizer"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
|
||||
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
|
||||
"github.com/SigNoz/signoz/pkg/version"
|
||||
@@ -175,7 +176,7 @@ func New(
|
||||
telemetrystoreProviderFactories factory.NamedMap[factory.ProviderFactory[telemetrystore.TelemetryStore, telemetrystore.Config]],
|
||||
authNsCallback func(ctx context.Context, providerSettings factory.ProviderSettings, store authtypes.AuthNStore, licensing licensing.Licensing) (map[authtypes.AuthNProvider]authn.AuthN, error),
|
||||
authzCallback func(context.Context, sqlstore.SQLStore, authz.Config, licensing.Licensing, []authz.OnBeforeRoleDelete) (factory.ProviderFactory[authz.AuthZ, authz.Config], error),
|
||||
dashboardModuleCallback func(sqlstore.SQLStore, factory.ProviderSettings, analytics.Analytics, organization.Getter, queryparser.QueryParser, querier.Querier, licensing.Licensing, tag.Module) dashboard.Module,
|
||||
dashboardModuleCallback func(sqlstore.SQLStore, factory.ProviderSettings, analytics.Analytics, organization.Getter, queryparser.QueryParser, querier.Querier, licensing.Licensing, tag.Module, dashboardtypes.SystemDashboardRegistry) dashboard.Module,
|
||||
gatewayProviderFactory func(licensing.Licensing) factory.ProviderFactory[gateway.Gateway, gateway.Config],
|
||||
auditorProviderFactories func(licensing.Licensing) factory.NamedMap[factory.ProviderFactory[auditor.Auditor, auditor.Config]],
|
||||
meterReporterProviderFactories func(context.Context, factory.ProviderSettings, flagger.Flagger, licensing.Licensing, telemetrystore.TelemetryStore, retention.Getter, organization.Getter, zeus.Zeus) (factory.NamedMap[factory.ProviderFactory[meterreporter.Reporter, meterreporter.Config]], string),
|
||||
@@ -440,8 +441,13 @@ func New(
|
||||
// Initialize query parser (needed for dashboard module)
|
||||
queryParser := queryparser.New(providerSettings)
|
||||
|
||||
// Initialize dashboard module
|
||||
dashboard := dashboardModuleCallback(sqlstore, providerSettings, analytics, orgGetter, queryParser, querier, licensing, tagModule)
|
||||
// Initialize dashboard module. The system dashboard registry is parsed here so
|
||||
// a malformed embedded definition fails startup instead of a request.
|
||||
systemDashboardRegistry, err := impldashboard.NewSystemDashboardRegistry()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dashboard := dashboardModuleCallback(sqlstore, providerSettings, analytics, orgGetter, queryParser, querier, licensing, tagModule, systemDashboardRegistry)
|
||||
|
||||
// Initialize user getter
|
||||
userGetter := impluser.NewGetter(userStore, userRoleStore, flagger)
|
||||
@@ -610,6 +616,7 @@ func New(
|
||||
factory.NewNamedService(factory.MustNewName("auditor"), auditor),
|
||||
factory.NewNamedService(factory.MustNewName("meterreporter"), meterReporter, factory.MustNewName("licensing")),
|
||||
factory.NewNamedService(factory.MustNewName("ruler"), rulerInstance),
|
||||
factory.NewNamedService(factory.MustNewName("systemdashboard"), impldashboard.NewService(providerSettings, dashboard, orgGetter)),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -617,7 +624,7 @@ func New(
|
||||
|
||||
// Initialize all handlers for the modules
|
||||
registryHandler := factory.NewHandler(registry)
|
||||
handlers := NewHandlers(modules, providerSettings, analytics, querierHandler, licensing, global, flagger, gateway, telemetryMetadataStore, authz, zeus, registryHandler, alertmanager, rulerInstance, statsAggregator)
|
||||
handlers := NewHandlers(modules, providerSettings, analytics, querierHandler, licensing, global, flagger, gateway, telemetryMetadataStore, authz, zeus, registryHandler, alertmanager, prometheus, rulerInstance, statsAggregator)
|
||||
|
||||
// Initialize the API server (after registry so it can access service health)
|
||||
apiserverInstance, err := factory.NewProviderFromNamedMap(
|
||||
|
||||
93
pkg/sqlmigration/119_add_system_dashboard.go
Normal file
93
pkg/sqlmigration/119_add_system_dashboard.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package sqlmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/sqlschema"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
)
|
||||
|
||||
type addSystemDashboard struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
sqlschema sqlschema.SQLSchema
|
||||
}
|
||||
|
||||
func NewAddSystemDashboardFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.NewProviderFactory(
|
||||
factory.MustNewName("add_system_dashboard"),
|
||||
func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
return &addSystemDashboard{sqlstore: sqlstore, sqlschema: sqlschema}, nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (migration *addSystemDashboard) Register(migrations *migrate.Migrations) error {
|
||||
return migrations.Register(migration.Up, migration.Down)
|
||||
}
|
||||
|
||||
func (migration *addSystemDashboard) Up(ctx context.Context, db *bun.DB) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
sqls := migration.sqlschema.Operator().CreateTable(&sqlschema.Table{
|
||||
Name: "system_dashboard",
|
||||
Columns: []*sqlschema.Column{
|
||||
{Name: "id", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "org_id", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "dashboard_id", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "name", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "version", DataType: sqlschema.DataTypeBigInt, Nullable: false},
|
||||
{Name: "created_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
|
||||
{Name: "updated_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
|
||||
},
|
||||
PrimaryKeyConstraint: &sqlschema.PrimaryKeyConstraint{
|
||||
ColumnNames: []sqlschema.ColumnName{"id"},
|
||||
},
|
||||
ForeignKeyConstraints: []*sqlschema.ForeignKeyConstraint{
|
||||
{
|
||||
ReferencingColumnName: sqlschema.ColumnName("org_id"),
|
||||
ReferencedTableName: sqlschema.TableName("organizations"),
|
||||
ReferencedColumnName: sqlschema.ColumnName("id"),
|
||||
},
|
||||
{
|
||||
ReferencingColumnName: sqlschema.ColumnName("dashboard_id"),
|
||||
ReferencedTableName: sqlschema.TableName("dashboard"),
|
||||
ReferencedColumnName: sqlschema.ColumnName("id"),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// (org_id, name) is what makes provisioning safe across replicas: the state
|
||||
// row is written in the same transaction as the dashboard, so a losing racer
|
||||
// rolls back its dashboard too.
|
||||
sqls = append(sqls, migration.sqlschema.Operator().CreateIndex(
|
||||
&sqlschema.UniqueIndex{
|
||||
TableName: "system_dashboard",
|
||||
ColumnNames: []sqlschema.ColumnName{"org_id", "name"},
|
||||
},
|
||||
)...)
|
||||
sqls = append(sqls, migration.sqlschema.Operator().CreateIndex(
|
||||
&sqlschema.UniqueIndex{
|
||||
TableName: "system_dashboard",
|
||||
ColumnNames: []sqlschema.ColumnName{"dashboard_id"},
|
||||
},
|
||||
)...)
|
||||
|
||||
for _, sql := range sqls {
|
||||
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (migration *addSystemDashboard) Down(context.Context, *bun.DB) error {
|
||||
return nil
|
||||
}
|
||||
@@ -184,7 +184,7 @@ func (p *provider) Query(ctx context.Context, query string, args ...interface{})
|
||||
}
|
||||
|
||||
return &rowsWithHooks{
|
||||
Rows: rows,
|
||||
Rows: telemetrystore.WrapRows(rows),
|
||||
ctx: ctx,
|
||||
event: event,
|
||||
onClose: func() { telemetrystore.WrapAfterQuery(p.hooks, ctx, event) },
|
||||
|
||||
39
pkg/telemetrystore/rows.go
Normal file
39
pkg/telemetrystore/rows.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package telemetrystore
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
|
||||
"github.com/SigNoz/signoz/pkg/types/telemetrystoretypes"
|
||||
)
|
||||
|
||||
// WrapRows reports JSONValue as the scan type of every JSON column. Nested JSON — Array(JSON),
|
||||
// Map(String, JSON) — is not covered.
|
||||
func WrapRows(rows driver.Rows) driver.Rows {
|
||||
return &rowsWithJSONScanType{Rows: rows}
|
||||
}
|
||||
|
||||
type rowsWithJSONScanType struct {
|
||||
driver.Rows
|
||||
}
|
||||
|
||||
func (r *rowsWithJSONScanType) ColumnTypes() []driver.ColumnType {
|
||||
colTypes := r.Rows.ColumnTypes()
|
||||
wrapped := make([]driver.ColumnType, len(colTypes))
|
||||
for i, colType := range colTypes {
|
||||
wrapped[i] = colType
|
||||
if strings.HasPrefix(strings.ToUpper(colType.DatabaseTypeName()), "JSON") {
|
||||
wrapped[i] = jsonColumnType{ColumnType: colType}
|
||||
}
|
||||
}
|
||||
return wrapped
|
||||
}
|
||||
|
||||
type jsonColumnType struct {
|
||||
driver.ColumnType
|
||||
}
|
||||
|
||||
func (jsonColumnType) ScanType() reflect.Type {
|
||||
return reflect.TypeFor[telemetrystoretypes.JSONValue]()
|
||||
}
|
||||
23
pkg/telemetrystore/telemetrystoretest/conn.go
Normal file
23
pkg/telemetrystore/telemetrystoretest/conn.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package telemetrystoretest
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/ClickHouse/clickhouse-go/v2"
|
||||
"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
|
||||
"github.com/SigNoz/signoz/pkg/telemetrystore"
|
||||
)
|
||||
|
||||
// conn wraps rows the way the clickhouse provider does, so mocked JSON columns report the scan
|
||||
// type they do in production.
|
||||
type conn struct {
|
||||
clickhouse.Conn
|
||||
}
|
||||
|
||||
func (c conn) Query(ctx context.Context, query string, args ...any) (driver.Rows, error) {
|
||||
rows, err := c.Conn.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return telemetrystore.WrapRows(rows), nil
|
||||
}
|
||||
@@ -32,7 +32,7 @@ func New(_ telemetrystore.Config, matcher sqlmock.QueryMatcher) *Provider {
|
||||
|
||||
// ClickhouseDB returns the mock Clickhouse connection.
|
||||
func (p *Provider) ClickhouseDB() clickhouse.Conn {
|
||||
return p.clickhouseDB.(clickhouse.Conn)
|
||||
return conn{Conn: p.clickhouseDB.(clickhouse.Conn)}
|
||||
}
|
||||
|
||||
// Cluster returns the cluster name.
|
||||
|
||||
@@ -25,6 +25,10 @@ const (
|
||||
dashboardNameSuffixLen = 8
|
||||
)
|
||||
|
||||
// SystemDashboardNamePrefix is reserved for dashboards SigNoz ships and owns. Generated
|
||||
// names never contain consecutive hyphens, so only a typed name can carry it — create rejects that.
|
||||
const SystemDashboardNamePrefix = "signoz---"
|
||||
|
||||
const (
|
||||
dashboardIconPathPrefix = "/assets/Icons/"
|
||||
dashboardLogoPathPrefix = "/assets/Logos/"
|
||||
@@ -75,8 +79,8 @@ type DashboardV2 struct {
|
||||
}
|
||||
|
||||
func (d *DashboardV2) ErrIfNotMutable() error {
|
||||
if d.Source == SourceIntegration {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardImmutable, "integration dashboards cannot be modified")
|
||||
if d.Source != SourceUser {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardImmutable, "%s dashboards cannot be modified", d.Source)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -95,6 +99,11 @@ func (d *DashboardV2) Update(updatable UpdatableDashboardV2, updatedBy string, r
|
||||
if err := d.ErrIfNotUpdatable(); err != nil {
|
||||
return err
|
||||
}
|
||||
return d.UpdateUnsafe(updatable, updatedBy, resolvedTags)
|
||||
}
|
||||
|
||||
// UpdateUnsafe applies the update without the source/lock gate. Intended for internal system callers.
|
||||
func (d *DashboardV2) UpdateUnsafe(updatable UpdatableDashboardV2, updatedBy string, resolvedTags []*tagtypes.Tag) error {
|
||||
if updatable.Name != d.Name {
|
||||
return errors.NewInvalidInputf(ErrCodeDashboardImmutable, "name is immutable; cannot change from %q to %q", d.Name, updatable.Name)
|
||||
}
|
||||
@@ -129,6 +138,13 @@ func (d *DashboardV2) LockUnlock(lock bool, isAdmin bool, updatedBy string) erro
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DashboardV2) ErrIfNotSystem() error {
|
||||
if d.Source != SourceSystem {
|
||||
return errors.Newf(errors.TypeNotFound, ErrCodeDashboardNotFound, "dashboard %q is not a system dashboard", d.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *DashboardV2) ErrIfNotClonable() error {
|
||||
if !d.Source.isClonable() {
|
||||
return errors.Newf(errors.TypeInvalidInput, ErrCodeDashboardImmutable, "%s dashboards cannot be cloned", d.Source)
|
||||
@@ -205,13 +221,21 @@ type PostableDashboardV2 struct {
|
||||
Spec DashboardSpec `json:"spec" required:"true"`
|
||||
}
|
||||
|
||||
func (postable PostableDashboardV2) NewDashboardV2(orgID valuer.UUID, createdBy string, source Source) *DashboardV2 {
|
||||
func (postable PostableDashboardV2) NewDashboardV2(orgID valuer.UUID, createdBy string, source Source) (*DashboardV2, error) {
|
||||
now := time.Now()
|
||||
|
||||
name := postable.Name
|
||||
if postable.GenerateName {
|
||||
name = generateDashboardName(postable.Spec.Display.Name)
|
||||
}
|
||||
// Checked on the final name, here rather than in validateName, because only
|
||||
// the constructor knows the source.
|
||||
if source != SourceSystem && strings.HasPrefix(name, SystemDashboardNamePrefix) {
|
||||
return nil, errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "name %q is invalid: the %q prefix is reserved for system dashboards", name, SystemDashboardNamePrefix)
|
||||
}
|
||||
if source == SourceSystem && !strings.HasPrefix(name, SystemDashboardNamePrefix) {
|
||||
return nil, errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "name %q is invalid: system dashboard names must start with the %q prefix", name, SystemDashboardNamePrefix)
|
||||
}
|
||||
|
||||
return &DashboardV2{
|
||||
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
|
||||
@@ -224,7 +248,7 @@ func (postable PostableDashboardV2) NewDashboardV2(orgID valuer.UUID, createdBy
|
||||
Name: name,
|
||||
Tags: tagtypes.NewTagsFromPostableTags(orgID, coretypes.KindDashboard, postable.Tags),
|
||||
Spec: postable.Spec,
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *PostableDashboardV2) UnmarshalJSON(data []byte) error {
|
||||
@@ -365,6 +389,36 @@ func (d DashboardV2) ToGettableDashboardV2() GettableDashboardV2 {
|
||||
}
|
||||
}
|
||||
|
||||
// GettableSystemDashboard is the system-dashboard endpoint's response. System
|
||||
// dashboards are addressed by their stable definition name, so it carries no id.
|
||||
type GettableSystemDashboard struct {
|
||||
types.TimeAuditable
|
||||
types.UserAuditable
|
||||
|
||||
OrgID valuer.UUID `json:"orgId" required:"true"`
|
||||
Locked bool `json:"locked" required:"true"`
|
||||
Source Source `json:"source" required:"true"`
|
||||
|
||||
DashboardV2MetadataBase
|
||||
Name string `json:"name" required:"true"`
|
||||
Tags []*tagtypes.GettableTag `json:"tags" required:"true"`
|
||||
Spec DashboardSpec `json:"spec" required:"true"`
|
||||
}
|
||||
|
||||
func (d DashboardV2) ToGettableSystemDashboard() GettableSystemDashboard {
|
||||
return GettableSystemDashboard{
|
||||
TimeAuditable: d.TimeAuditable,
|
||||
UserAuditable: d.UserAuditable,
|
||||
OrgID: d.OrgID,
|
||||
Locked: d.Locked,
|
||||
Source: d.Source,
|
||||
DashboardV2MetadataBase: d.DashboardV2MetadataBase,
|
||||
Name: d.Name,
|
||||
Tags: tagtypes.NewGettableTagsFromTags(d.Tags),
|
||||
Spec: d.Spec,
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// Storable
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -89,21 +89,25 @@ func TestPostableDashboardV2NewDashboardV2(t *testing.T) {
|
||||
cases := []struct {
|
||||
scenario string
|
||||
source Source
|
||||
name string
|
||||
expectedLocked bool
|
||||
}{
|
||||
{
|
||||
scenario: "user source is not locked",
|
||||
source: SourceUser,
|
||||
name: "my-dashboard",
|
||||
expectedLocked: false,
|
||||
},
|
||||
{
|
||||
scenario: "system source is not locked",
|
||||
source: SourceSystem,
|
||||
name: SystemDashboardNamePrefix + "my-dashboard",
|
||||
expectedLocked: false,
|
||||
},
|
||||
{
|
||||
scenario: "integration source is locked",
|
||||
source: SourceIntegration,
|
||||
name: "my-dashboard",
|
||||
expectedLocked: true,
|
||||
},
|
||||
}
|
||||
@@ -115,7 +119,7 @@ func TestPostableDashboardV2NewDashboardV2(t *testing.T) {
|
||||
SchemaVersion: SchemaVersion,
|
||||
Image: "img",
|
||||
},
|
||||
Name: "my-dashboard",
|
||||
Name: tc.name,
|
||||
Tags: []tagtypes.PostableTag{
|
||||
{Key: "team", Value: "platform"},
|
||||
{Key: "env", Value: "prod"},
|
||||
@@ -124,7 +128,8 @@ func TestPostableDashboardV2NewDashboardV2(t *testing.T) {
|
||||
}
|
||||
|
||||
before := time.Now()
|
||||
dashboard := postable.NewDashboardV2(orgID, "alice", tc.source)
|
||||
dashboard, err := postable.NewDashboardV2(orgID, "alice", tc.source)
|
||||
require.NoError(t, err)
|
||||
after := time.Now()
|
||||
|
||||
require.NotNil(t, dashboard)
|
||||
@@ -160,8 +165,10 @@ func TestPostableDashboardV2NewDashboardV2(t *testing.T) {
|
||||
Spec: DashboardSpec{},
|
||||
}
|
||||
|
||||
first := postable.NewDashboardV2(orgID, "alice", SourceUser)
|
||||
second := postable.NewDashboardV2(orgID, "alice", SourceUser)
|
||||
first, err := postable.NewDashboardV2(orgID, "alice", SourceUser)
|
||||
require.NoError(t, err)
|
||||
second, err := postable.NewDashboardV2(orgID, "alice", SourceUser)
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, first.ID, second.ID, "expected distinct UUIDs across invocations")
|
||||
})
|
||||
|
||||
@@ -174,7 +181,8 @@ func TestPostableDashboardV2NewDashboardV2(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
dashboard := postable.NewDashboardV2(orgID, "alice", SourceUser)
|
||||
dashboard, err := postable.NewDashboardV2(orgID, "alice", SourceUser)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, strings.HasPrefix(dashboard.Name, "my-dashboard-"), "expected slug prefix, got %q", dashboard.Name)
|
||||
assert.Len(t, dashboard.Name, len("my-dashboard-")+dashboardNameSuffixLen)
|
||||
})
|
||||
|
||||
@@ -109,7 +109,8 @@ func TestPatchableDashboardV2_Apply(t *testing.T) {
|
||||
var p PostableDashboardV2
|
||||
require.NoError(t, json.Unmarshal([]byte(basePostableJSON), &p), "base postable JSON must validate")
|
||||
testOrgID := valuer.GenerateUUID()
|
||||
base := p.NewDashboardV2(testOrgID, "somecreatedthisiguess@signoz.io", SourceUser)
|
||||
base, err := p.NewDashboardV2(testOrgID, "somecreatedthisiguess@signoz.io", SourceUser)
|
||||
require.NoError(t, err)
|
||||
base.Tags = []*tagtypes.Tag{
|
||||
{Key: "team", Value: "alpha"},
|
||||
{Key: "env", Value: "prod"},
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/perses/spec/go/dashboard"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -1928,3 +1929,37 @@ func TestEnsureSingleExpressionAggregation(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Guards the constant: a prefixed name must stay a valid DNS-1123 label.
|
||||
func TestSystemDashboardNamePrefix(t *testing.T) {
|
||||
require.NoError(t, validateDashboardName(SystemDashboardNamePrefix+"ai-o11y-overview"))
|
||||
}
|
||||
|
||||
func TestNewDashboardV2RejectsReservedName(t *testing.T) {
|
||||
testCases := []struct {
|
||||
description string
|
||||
name string
|
||||
source Source
|
||||
errContains string
|
||||
}{
|
||||
{description: "reserved name for a system dashboard", name: SystemDashboardNamePrefix + "overview", source: SourceSystem},
|
||||
{description: "reserved name for a user dashboard", name: SystemDashboardNamePrefix + "overview", source: SourceUser, errContains: "reserved for system dashboards"},
|
||||
{description: "reserved name for an integration dashboard", name: SystemDashboardNamePrefix + "overview", source: SourceIntegration, errContains: "reserved for system dashboards"},
|
||||
{description: "unprefixed name for a system dashboard", name: "overview", source: SourceSystem, errContains: "must start with"},
|
||||
{description: "ordinary name for a user dashboard", name: "overview", source: SourceUser},
|
||||
{description: "fewer hyphens than the prefix for a user dashboard", name: "signoz--overview", source: SourceUser},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.description, func(t *testing.T) {
|
||||
postable := PostableDashboardV2{Name: testCase.name}
|
||||
_, err := postable.NewDashboardV2(valuer.GenerateUUID(), "user@signoz.io", testCase.source)
|
||||
if testCase.errContains != "" {
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), testCase.errContains)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@ type Store interface {
|
||||
|
||||
Get(context.Context, valuer.UUID, valuer.UUID) (*StorableDashboard, error)
|
||||
|
||||
// GetByName resolves a dashboard by its per-org unique name.
|
||||
GetByName(ctx context.Context, orgID valuer.UUID, name string) (*StorableDashboard, error)
|
||||
|
||||
GetPublic(context.Context, string) (*StorablePublicDashboard, error)
|
||||
|
||||
GetDashboardByOrgsAndPublicID(context.Context, []string, string) (*StorableDashboard, error)
|
||||
@@ -72,4 +75,13 @@ type Store interface {
|
||||
UpdateDashboardView(ctx context.Context, view *DashboardView) error
|
||||
|
||||
DeleteDashboardView(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
// System dashboard methods
|
||||
// ════════════════════════════════════════════════════════════════════════
|
||||
CreateSystemDashboard(ctx context.Context, storable *StorableSystemDashboard) error
|
||||
|
||||
GetSystemDashboard(ctx context.Context, orgID valuer.UUID, name string) (*StorableSystemDashboard, error)
|
||||
|
||||
UpdateSystemDashboardVersion(ctx context.Context, orgID valuer.UUID, name string, version int) error
|
||||
}
|
||||
|
||||
45
pkg/types/dashboardtypes/system_dashboard.go
Normal file
45
pkg/types/dashboardtypes/system_dashboard.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package dashboardtypes
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrCodeSystemDashboardNotFound = errors.MustNewCode("system_dashboard_not_found")
|
||||
ErrCodeSystemDashboardDefinitionInvalid = errors.MustNewCode("system_dashboard_definition_invalid")
|
||||
ErrCodeSystemDashboardAlreadyProvisioned = errors.MustNewCode("system_dashboard_already_provisioned")
|
||||
)
|
||||
|
||||
// ProvisionerIdentity is stamped into created_by/updated_by by the reconciler.
|
||||
const ProvisionerIdentity = "signoz"
|
||||
|
||||
// StorableSystemDashboard records the shipped version each org's copy of a system
|
||||
// dashboard was last provisioned at. That version is the only thing the dashboard
|
||||
// row cannot answer, since the binary only embeds the latest definition.
|
||||
type StorableSystemDashboard struct {
|
||||
bun.BaseModel `bun:"table:system_dashboard"`
|
||||
|
||||
types.Identifiable
|
||||
types.TimeAuditable
|
||||
OrgID valuer.UUID `bun:"org_id,type:text,notnull"`
|
||||
DashboardID valuer.UUID `bun:"dashboard_id,type:text,notnull"`
|
||||
Name string `bun:"name,type:text,notnull"`
|
||||
Version int `bun:"version,notnull"`
|
||||
}
|
||||
|
||||
func NewStorableSystemDashboard(orgID valuer.UUID, dashboardID valuer.UUID, name string, version int) *StorableSystemDashboard {
|
||||
now := time.Now()
|
||||
return &StorableSystemDashboard{
|
||||
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
|
||||
TimeAuditable: types.TimeAuditable{CreatedAt: now, UpdatedAt: now},
|
||||
OrgID: orgID,
|
||||
DashboardID: dashboardID,
|
||||
Name: name,
|
||||
Version: version,
|
||||
}
|
||||
}
|
||||
95
pkg/types/dashboardtypes/system_dashboard_definition.go
Normal file
95
pkg/types/dashboardtypes/system_dashboard_definition.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package dashboardtypes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
)
|
||||
|
||||
// SystemDashboardDefinition is one shipped system dashboard. Version is bumped on
|
||||
// every content change and drives upgrade detection; the name is the stable key
|
||||
// and never changes.
|
||||
type SystemDashboardDefinition struct {
|
||||
Version int `json:"version"`
|
||||
Dashboard PostableDashboardV2 `json:"definition"`
|
||||
}
|
||||
|
||||
func (definition SystemDashboardDefinition) Name() string {
|
||||
return definition.Dashboard.Name
|
||||
}
|
||||
|
||||
func NewSystemDashboardDefinition(raw []byte) (SystemDashboardDefinition, error) {
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.DisallowUnknownFields()
|
||||
|
||||
var definition SystemDashboardDefinition
|
||||
if err := decoder.Decode(&definition); err != nil {
|
||||
return SystemDashboardDefinition{}, errors.WrapInvalidInputf(err, ErrCodeSystemDashboardDefinitionInvalid, "%s", err.Error())
|
||||
}
|
||||
if err := definition.validate(); err != nil {
|
||||
return SystemDashboardDefinition{}, err
|
||||
}
|
||||
|
||||
return definition, nil
|
||||
}
|
||||
|
||||
func (definition SystemDashboardDefinition) validate() error {
|
||||
if definition.Version < 1 {
|
||||
return errors.NewInvalidInputf(ErrCodeSystemDashboardDefinitionInvalid, "version must be at least 1, got %d", definition.Version)
|
||||
}
|
||||
if !strings.HasPrefix(definition.Name(), SystemDashboardNamePrefix) {
|
||||
return errors.NewInvalidInputf(ErrCodeSystemDashboardDefinitionInvalid, "name %q must start with %q", definition.Name(), SystemDashboardNamePrefix)
|
||||
}
|
||||
if definition.Dashboard.GenerateName {
|
||||
return errors.NewInvalidInputf(ErrCodeSystemDashboardDefinitionInvalid, "%s: generateName is not allowed, the name is the stable key", definition.Name())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ToUpdatable is how an upgrade re-applies a definition onto an existing row:
|
||||
// everything but the dashboard's identity comes from the shipped definition.
|
||||
func (definition SystemDashboardDefinition) ToUpdatable() UpdatableDashboardV2 {
|
||||
return UpdatableDashboardV2{
|
||||
DashboardV2MetadataBase: definition.Dashboard.DashboardV2MetadataBase,
|
||||
Name: definition.Dashboard.Name,
|
||||
Tags: definition.Dashboard.Tags,
|
||||
Spec: definition.Dashboard.Spec,
|
||||
}
|
||||
}
|
||||
|
||||
// SystemDashboardRegistry holds every definition embedded in the binary, keyed by name.
|
||||
type SystemDashboardRegistry struct {
|
||||
definitions map[string]SystemDashboardDefinition
|
||||
}
|
||||
|
||||
func NewSystemDashboardRegistry(definitions []SystemDashboardDefinition) (SystemDashboardRegistry, error) {
|
||||
byName := make(map[string]SystemDashboardDefinition, len(definitions))
|
||||
for _, definition := range definitions {
|
||||
if _, duplicate := byName[definition.Name()]; duplicate {
|
||||
return SystemDashboardRegistry{}, errors.NewInvalidInputf(ErrCodeSystemDashboardDefinitionInvalid, "duplicate system dashboard name %q", definition.Name())
|
||||
}
|
||||
byName[definition.Name()] = definition
|
||||
}
|
||||
|
||||
return SystemDashboardRegistry{definitions: byName}, nil
|
||||
}
|
||||
|
||||
func (registry SystemDashboardRegistry) Get(name string) (SystemDashboardDefinition, bool) {
|
||||
definition, ok := registry.definitions[name]
|
||||
return definition, ok
|
||||
}
|
||||
|
||||
// List returns the definitions sorted by name so provisioning order is stable.
|
||||
func (registry SystemDashboardRegistry) List() []SystemDashboardDefinition {
|
||||
definitions := make([]SystemDashboardDefinition, 0, len(registry.definitions))
|
||||
for _, definition := range registry.definitions {
|
||||
definitions = append(definitions, definition)
|
||||
}
|
||||
slices.SortFunc(definitions, func(a, b SystemDashboardDefinition) int { return strings.Compare(a.Name(), b.Name()) })
|
||||
|
||||
return definitions
|
||||
}
|
||||
@@ -23,6 +23,7 @@ var (
|
||||
NameSpanDetailsPreviewAttributes = Name{valuer.NewString("span_details_preview_attributes")}
|
||||
NameSpanDetailsColorByAttribute = Name{valuer.NewString("span_details_color_by_attribute")}
|
||||
NameSpanPercentileResourceAttributes = Name{valuer.NewString("span_percentile_resource_attributes")}
|
||||
NameLogDetailsPinnedAttributes = Name{valuer.NewString("log_details_pinned_attributes")}
|
||||
)
|
||||
|
||||
type Name struct{ valuer.String }
|
||||
@@ -45,6 +46,7 @@ func NewName(name string) (Name, error) {
|
||||
NameSpanDetailsPreviewAttributes.StringValue(),
|
||||
NameSpanDetailsColorByAttribute.StringValue(),
|
||||
NameSpanPercentileResourceAttributes.StringValue(),
|
||||
NameLogDetailsPinnedAttributes.StringValue(),
|
||||
},
|
||||
name,
|
||||
)
|
||||
|
||||
@@ -190,6 +190,15 @@ func NewAvailablePreference() map[Name]Preference {
|
||||
AllowedValues: []string{},
|
||||
Value: MustNewValue([]any{}, ValueTypeArray),
|
||||
},
|
||||
NameLogDetailsPinnedAttributes: {
|
||||
Name: NameLogDetailsPinnedAttributes,
|
||||
Description: "List of pinned attributes in log details drawer.",
|
||||
ValueType: ValueTypeArray,
|
||||
DefaultValue: MustNewValue([]any{}, ValueTypeArray),
|
||||
AllowedScopes: []Scope{ScopeUser},
|
||||
AllowedValues: []string{},
|
||||
Value: MustNewValue([]any{}, ValueTypeArray),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
37
pkg/types/telemetrystoretypes/json.go
Normal file
37
pkg/types/telemetrystoretypes/json.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package telemetrystoretypes
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/bytedance/sonic"
|
||||
)
|
||||
|
||||
var ErrCodeUnmarshalJSONColumn = errors.MustNewCode("fail_unmarshal_json_column")
|
||||
|
||||
// JSONValue is the scan target for a ClickHouse JSON column: the connection sets
|
||||
// output_format_native_write_json_as_string, so the column arrives as a raw document rather than
|
||||
// the chcol.JSON the driver reports as its scan type.
|
||||
type JSONValue map[string]any
|
||||
|
||||
// Scan decodes into a fresh map every time: a scan target is reused across rows, and unmarshalling
|
||||
// into the map already there would both keep its keys and hand every row the same map.
|
||||
func (v *JSONValue) Scan(src any) error {
|
||||
var raw []byte
|
||||
switch value := src.(type) {
|
||||
case nil:
|
||||
*v = nil
|
||||
return nil
|
||||
case string:
|
||||
raw = []byte(value)
|
||||
case []byte:
|
||||
raw = value
|
||||
default:
|
||||
return errors.NewInternalf(ErrCodeUnmarshalJSONColumn, "cannot decode %T as a JSON column", src)
|
||||
}
|
||||
|
||||
decoded := JSONValue{}
|
||||
if err := sonic.Unmarshal(raw, &decoded); err != nil {
|
||||
return errors.WrapInternalf(err, ErrCodeUnmarshalJSONColumn, "failed to unmarshal JSON column")
|
||||
}
|
||||
*v = decoded
|
||||
return nil
|
||||
}
|
||||
66
tests/fixtures/promqltestcorpus.py
vendored
Normal file
66
tests/fixtures/promqltestcorpus.py
vendored
Normal file
@@ -0,0 +1,66 @@
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from fixtures.metrics import Metrics
|
||||
|
||||
TESTDATA_DIR = os.path.join(os.path.dirname(__file__), "..", "integration", "testdata", "promqltestcorpus")
|
||||
CORPUS_FILE = os.path.join(TESTDATA_DIR, "corpus.json")
|
||||
|
||||
# Datasets sit on disjoint time windows (2h gaps, far beyond the 5m lookback)
|
||||
# so one bulk ingest serves every case without cross-talk.
|
||||
ISOLATION_GAP_MS = 2 * 3600 * 1000
|
||||
SPECIALS = {"NaN": math.nan, "Inf": math.inf, "-Inf": -math.inf}
|
||||
|
||||
|
||||
def ingest_promqltest_corpus(insert_metrics: Callable[[list[Metrics]], None]) -> tuple[dict, dict[int, int]]:
|
||||
"""Loads the frozen corpus, lays its datasets end to end on the timeline
|
||||
(newest last, ending safely in the past), ingests every sample, and
|
||||
returns (corpus, dataset base timestamps).
|
||||
|
||||
Dataset bases are hour-aligned: registration rows are hour-bucketed, so
|
||||
behavior depends on where samples fall relative to hour boundaries, and
|
||||
exact known-divergences enforcement needs identical placement every run."""
|
||||
with open(CORPUS_FILE, encoding="utf-8") as f:
|
||||
corpus = json.load(f)
|
||||
|
||||
cases_by_dataset: dict[int, list[dict]] = {}
|
||||
for case in corpus["cases"]:
|
||||
cases_by_dataset.setdefault(case["dataset"], []).append(case)
|
||||
|
||||
spans = {}
|
||||
for ds in corpus["datasets"]:
|
||||
sample_max = max((s["samples"][-1][0] for s in ds["series"] if s["samples"]), default=0)
|
||||
case_max = max((c["end_ms"] for c in cases_by_dataset.get(ds["id"], [])), default=0)
|
||||
spans[ds["id"]] = max(sample_max, case_max) + corpus["meta"]["lookback_ms"]
|
||||
|
||||
hour_ms = 3_600_000
|
||||
advances = {ds["id"]: -(-(spans[ds["id"]] + ISOLATION_GAP_MS) // hour_ms) * hour_ms for ds in corpus["datasets"]}
|
||||
total = sum(advances.values())
|
||||
now = datetime.now(tz=UTC).replace(second=0, microsecond=0)
|
||||
cursor = (int((now - timedelta(hours=1)).timestamp() * 1000) - total) // hour_ms * hour_ms
|
||||
|
||||
bases: dict[int, int] = {}
|
||||
metrics: list[Metrics] = []
|
||||
for ds in corpus["datasets"]:
|
||||
bases[ds["id"]] = cursor
|
||||
for series in ds["series"]:
|
||||
labels = dict(series["labels"])
|
||||
metric_name = labels.pop("__name__")
|
||||
for off_ms, raw in series["samples"]:
|
||||
stale = raw == "stale"
|
||||
metrics.append(
|
||||
Metrics(
|
||||
metric_name=metric_name,
|
||||
labels=labels,
|
||||
timestamp=datetime.fromtimestamp((cursor + off_ms) / 1000, tz=UTC),
|
||||
value=0.0 if stale else (SPECIALS[raw] if isinstance(raw, str) else float(raw)),
|
||||
flags=1 if stale else 0,
|
||||
)
|
||||
)
|
||||
cursor += advances[ds["id"]]
|
||||
|
||||
insert_metrics(metrics)
|
||||
return corpus, bases
|
||||
192
tests/integration/tests/dashboard/07_system_dashboard.py
Normal file
192
tests/integration/tests/dashboard/07_system_dashboard.py
Normal file
@@ -0,0 +1,192 @@
|
||||
from collections.abc import Callable
|
||||
from http import HTTPStatus
|
||||
|
||||
import requests
|
||||
from sqlalchemy import sql
|
||||
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.dashboards import DASHBOARDS_BASE_URL, MAX_LIST_LIMIT
|
||||
from fixtures.types import Operation, SigNoz
|
||||
|
||||
SYSTEM_BASE_URL = "/api/v2/dashboards/system"
|
||||
|
||||
# Provisioned for every org by the reconciler; the path segment is the bare
|
||||
# definition name, the stored name carries the reserved prefix.
|
||||
SYSTEM_DASHBOARD_NAME = "ai-o11y-overview"
|
||||
SYSTEM_DASHBOARD_PREFIX = "signoz---"
|
||||
|
||||
|
||||
def test_get_system_dashboard(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{SYSTEM_BASE_URL}/{SYSTEM_DASHBOARD_NAME}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
dashboard = response.json()["data"]
|
||||
assert dashboard["name"] == SYSTEM_DASHBOARD_PREFIX + SYSTEM_DASHBOARD_NAME
|
||||
assert dashboard["source"] == "system"
|
||||
assert dashboard["createdBy"] == "signoz"
|
||||
assert dashboard["schemaVersion"] == "v6"
|
||||
assert "id" not in dashboard
|
||||
|
||||
|
||||
def test_get_system_dashboard_rejects_prefixed_name(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{SYSTEM_BASE_URL}/{SYSTEM_DASHBOARD_PREFIX}{SYSTEM_DASHBOARD_NAME}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
|
||||
assert "must not carry" in response.json()["error"]["message"]
|
||||
|
||||
|
||||
def test_get_missing_system_dashboard_returns_not_found(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{SYSTEM_BASE_URL}/no-such-dashboard"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.NOT_FOUND, response.text
|
||||
|
||||
|
||||
def test_system_dashboard_hidden_from_list_but_gettable_by_id(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
# The API never exposes a system dashboard's id; read it from the state row.
|
||||
with signoz.sqlstore.conn.connect() as conn:
|
||||
dashboard_id = conn.execute(
|
||||
sql.text("SELECT dashboard_id FROM system_dashboard WHERE name = :name"),
|
||||
{"name": SYSTEM_DASHBOARD_PREFIX + SYSTEM_DASHBOARD_NAME},
|
||||
).scalar_one()
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{DASHBOARDS_BASE_URL}?limit={MAX_LIST_LIMIT}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
listed = response.json()["data"]["dashboards"] or []
|
||||
assert all(dashboard["source"] != "system" for dashboard in listed)
|
||||
assert all(dashboard["id"] != dashboard_id for dashboard in listed)
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{DASHBOARDS_BASE_URL}/{dashboard_id}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
assert response.json()["data"]["source"] == "system"
|
||||
|
||||
|
||||
def test_system_dashboard_is_immutable(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"{SYSTEM_BASE_URL}/{SYSTEM_DASHBOARD_NAME}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, response.text
|
||||
dashboard = response.json()["data"]
|
||||
|
||||
with signoz.sqlstore.conn.connect() as conn:
|
||||
dashboard_id = conn.execute(
|
||||
sql.text("SELECT dashboard_id FROM system_dashboard WHERE name = :name"),
|
||||
{"name": SYSTEM_DASHBOARD_PREFIX + SYSTEM_DASHBOARD_NAME},
|
||||
).scalar_one()
|
||||
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"{DASHBOARDS_BASE_URL}/{dashboard_id}"),
|
||||
json={
|
||||
"schemaVersion": dashboard["schemaVersion"],
|
||||
"name": dashboard["name"],
|
||||
"tags": [],
|
||||
"spec": dashboard["spec"],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
|
||||
assert response.json()["error"]["code"] == "dashboard_immutable"
|
||||
|
||||
response = requests.delete(
|
||||
signoz.self.host_configs["8080"].get(f"{DASHBOARDS_BASE_URL}/{dashboard_id}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
|
||||
assert response.json()["error"]["code"] == "dashboard_immutable"
|
||||
|
||||
response = requests.put(
|
||||
signoz.self.host_configs["8080"].get(f"{DASHBOARDS_BASE_URL}/{dashboard_id}/lock"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
|
||||
assert response.json()["error"]["code"] == "dashboard_immutable"
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(f"{DASHBOARDS_BASE_URL}/{dashboard_id}/clone"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
|
||||
assert response.json()["error"]["code"] == "dashboard_immutable"
|
||||
|
||||
|
||||
def test_create_rejects_reserved_prefix_name(
|
||||
signoz: SigNoz,
|
||||
create_user_admin: Operation, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
):
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
response = requests.post(
|
||||
signoz.self.host_configs["8080"].get(DASHBOARDS_BASE_URL),
|
||||
json={
|
||||
"schemaVersion": "v6",
|
||||
"name": f"{SYSTEM_DASHBOARD_PREFIX}custom",
|
||||
"tags": [],
|
||||
"spec": {
|
||||
"display": {"name": "Custom"},
|
||||
"variables": [],
|
||||
"panels": {},
|
||||
"layouts": [],
|
||||
},
|
||||
},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST, response.text
|
||||
assert "reserved for system dashboards" in response.json()["error"]["message"]
|
||||
@@ -0,0 +1,138 @@
|
||||
import json
|
||||
import math
|
||||
from collections.abc import Callable
|
||||
from http import HTTPStatus
|
||||
|
||||
import requests
|
||||
|
||||
from fixtures import types
|
||||
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
|
||||
from fixtures.metrics import Metrics
|
||||
from fixtures.promqltestcorpus import ingest_promqltest_corpus
|
||||
|
||||
# The same frozen corpus the promqlconformance package replays through
|
||||
# /api/v5/query_range, here replayed against the /prometheus/api/v1 endpoints
|
||||
# with clickhousev2 as the serving provider (see conftest.py) — the two paths
|
||||
# nothing else exercises. Range cases go to query_range, where a
|
||||
# RangeExecutor provider serves transpiled statements when the shape allows.
|
||||
# Instant cases go to /query with a real `time` parameter, so they need no
|
||||
# grid encoding.
|
||||
#
|
||||
# Prometheus API sample values are strings, "NaN"/"+Inf"/"-Inf" included.
|
||||
SPECIALS = {"NaN": math.nan, "Inf": math.inf, "+Inf": math.inf, "-Inf": -math.inf}
|
||||
QUERY_TIMEOUT = 30
|
||||
|
||||
|
||||
def test_prometheus_api_corpus(
|
||||
signoz: types.SigNoz,
|
||||
create_user_admin: None, # pylint: disable=unused-argument
|
||||
get_token: Callable[[str, str], str],
|
||||
insert_metrics: Callable[[list[Metrics]], None],
|
||||
) -> None:
|
||||
corpus, bases = ingest_promqltest_corpus(insert_metrics)
|
||||
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
|
||||
|
||||
failures: list[str] = []
|
||||
for case in corpus["cases"]:
|
||||
# instant-coarse variants encode an instant eval as a coarse-step
|
||||
# range because the v5 API cannot run true instants. This API can:
|
||||
# the [base] form of the same eval goes through /query below, and the
|
||||
# transpiled coarse-step serving the encoding exercises is covered
|
||||
# (and its known divergences ledgered) by promqlconformance's
|
||||
# clickhousev2 leg.
|
||||
if case["variant"] == "instant-coarse":
|
||||
continue
|
||||
|
||||
base = bases[case["dataset"]]
|
||||
start_ms = base + case["start_ms"]
|
||||
end_ms = base + case["end_ms"]
|
||||
step_s = max(1, case["step_ms"] // 1000)
|
||||
case_id = f"{case['source']}[{case['variant']}]"
|
||||
|
||||
if case["instant"]:
|
||||
path, params = "/prometheus/api/v1/query", {"query": case["expr"], "time": end_ms / 1000}
|
||||
else:
|
||||
path, params = (
|
||||
"/prometheus/api/v1/query_range",
|
||||
{
|
||||
"query": case["expr"],
|
||||
"start": start_ms / 1000,
|
||||
"end": end_ms / 1000,
|
||||
"step": step_s,
|
||||
},
|
||||
)
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(path),
|
||||
params=params,
|
||||
timeout=QUERY_TIMEOUT,
|
||||
headers={"authorization": f"Bearer {token}"},
|
||||
)
|
||||
if response.status_code != HTTPStatus.OK:
|
||||
failures.append(f"{case_id}: HTTP {response.status_code} for {case['expr']!r}: {response.text[:200]}")
|
||||
continue
|
||||
body = response.json()
|
||||
if body.get("status") != "success":
|
||||
failures.append(f"{case_id}: status {body.get('status')!r} for {case['expr']!r}: {json.dumps(body)[:200]}")
|
||||
continue
|
||||
|
||||
result_type, result = body["data"]["resultType"], body["data"]["result"]
|
||||
actual: dict[tuple, dict[int, float]] = {}
|
||||
if result_type == "matrix":
|
||||
for series in result:
|
||||
points = {round(float(ts) * 1000): SPECIALS[v] if v in SPECIALS else float(v) for ts, v in series.get("values") or []}
|
||||
actual[tuple(sorted((series.get("metric") or {}).items()))] = points
|
||||
elif result_type == "vector":
|
||||
for series in result:
|
||||
ts, v = series["value"]
|
||||
actual[tuple(sorted((series.get("metric") or {}).items()))] = {round(float(ts) * 1000): SPECIALS[v] if v in SPECIALS else float(v)}
|
||||
elif result_type == "scalar":
|
||||
ts, v = result
|
||||
actual[()] = {round(float(ts) * 1000): SPECIALS[v] if v in SPECIALS else float(v)}
|
||||
|
||||
expected: dict[tuple, dict[int, float]] = {}
|
||||
for res in case["expected"]:
|
||||
points = {base + off_ms: SPECIALS[v] if isinstance(v, str) else float(v) for off_ms, v in res["points"]}
|
||||
expected[tuple(sorted(res["labels"].items()))] = points
|
||||
|
||||
if set(actual) != set(expected):
|
||||
missing = set(expected) - set(actual)
|
||||
extra = set(actual) - set(expected)
|
||||
failures.append(f"{case_id}: series mismatch for {case['expr']!r} (missing={sorted(missing)[:3]} extra={sorted(extra)[:3]})")
|
||||
continue
|
||||
|
||||
mismatch = None
|
||||
for lset, exp_points in expected.items():
|
||||
act_points = actual[lset]
|
||||
if set(act_points) != set(exp_points):
|
||||
mismatch = f"{case_id}: timestamp mismatch for {case['expr']!r} series {dict(lset)} (expected {len(exp_points)} points, got {len(act_points)})"
|
||||
break
|
||||
for ts, exp_v in exp_points.items():
|
||||
act_v = act_points[ts]
|
||||
if math.isnan(act_v) or math.isnan(exp_v):
|
||||
close = math.isnan(act_v) and math.isnan(exp_v)
|
||||
elif math.isinf(act_v) or math.isinf(exp_v):
|
||||
close = act_v == exp_v
|
||||
elif act_v == exp_v:
|
||||
close = True
|
||||
else:
|
||||
# Expected values carry the v5 API's rounding (>=1: three
|
||||
# decimal places; <1: three significant digits); this API
|
||||
# returns raw floats. One rounding quantum covers the
|
||||
# largest possible rounding difference.
|
||||
scale = max(abs(act_v), abs(exp_v))
|
||||
if scale >= 1:
|
||||
quantum = max(1e-3, scale * 1e-9)
|
||||
else:
|
||||
quantum = 10 ** (math.floor(math.log10(scale)) - 2)
|
||||
close = abs(act_v - exp_v) <= quantum + 1e-12
|
||||
if not close:
|
||||
mismatch = f"{case_id}: value mismatch for {case['expr']!r} series {dict(lset)} at {ts}: expected {exp_v}, got {act_v}"
|
||||
break
|
||||
if mismatch:
|
||||
break
|
||||
if mismatch:
|
||||
failures.append(mismatch)
|
||||
|
||||
for f_line in failures:
|
||||
print("DIVERGED", f_line)
|
||||
assert not failures, f"{len(failures)} corpus cases diverged:\n" + "\n".join(failures[:25])
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user