mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-03 01:50:40 +01:00
Compare commits
20 Commits
qf-sidebar
...
issue_5947
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f537154df9 | ||
|
|
7c50fe3763 | ||
|
|
160a1b018c | ||
|
|
27acdbd970 | ||
|
|
7eb610287e | ||
|
|
74c946fe79 | ||
|
|
23dd98bee7 | ||
|
|
aca35a1309 | ||
|
|
1c6d966afa | ||
|
|
f22a18d0a7 | ||
|
|
752899a7a4 | ||
|
|
eb4c570d53 | ||
|
|
c65f6fa77d | ||
|
|
4d015a927e | ||
|
|
abebea532b | ||
|
|
764fe8ec69 | ||
|
|
7ce405aff7 | ||
|
|
72618d1d83 | ||
|
|
80b7edc22a | ||
|
|
da9b4644df |
1
.github/workflows/integrationci.yaml
vendored
1
.github/workflows/integrationci.yaml
vendored
@@ -59,6 +59,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)
|
||||
|
||||
@@ -109,6 +109,57 @@ components:
|
||||
webhook_url:
|
||||
$ref: '#/components/schemas/ConfigSecretURL'
|
||||
type: object
|
||||
AlertmanagertypesJSMOpsReceiverConfig:
|
||||
properties:
|
||||
api_key:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
http_config:
|
||||
$ref: '#/components/schemas/ConfigHTTPClientConfig'
|
||||
message:
|
||||
type: string
|
||||
priority:
|
||||
type: string
|
||||
send_resolved:
|
||||
type: boolean
|
||||
tags:
|
||||
type: string
|
||||
type: object
|
||||
AlertmanagertypesJiraReceiverConfig:
|
||||
properties:
|
||||
custom_fields:
|
||||
additionalProperties: {}
|
||||
type: object
|
||||
description:
|
||||
type: string
|
||||
http_config:
|
||||
$ref: '#/components/schemas/ConfigHTTPClientConfig'
|
||||
issue_type:
|
||||
type: string
|
||||
labels:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
priority:
|
||||
type: string
|
||||
project:
|
||||
type: string
|
||||
reopen_duration:
|
||||
$ref: '#/components/schemas/ModelDuration'
|
||||
reopen_transition:
|
||||
type: string
|
||||
resolve_transition:
|
||||
type: string
|
||||
send_resolved:
|
||||
type: boolean
|
||||
site:
|
||||
type: string
|
||||
summary:
|
||||
type: string
|
||||
wont_fix_resolution:
|
||||
type: string
|
||||
type: object
|
||||
AlertmanagertypesMaintenanceKind:
|
||||
enum:
|
||||
- fixed
|
||||
@@ -162,6 +213,10 @@ components:
|
||||
oneOf:
|
||||
- required:
|
||||
- googlechat_configs
|
||||
- required:
|
||||
- jira_configs
|
||||
- required:
|
||||
- jsmops_configs
|
||||
- required:
|
||||
- discord_configs
|
||||
- required:
|
||||
@@ -192,8 +247,6 @@ components:
|
||||
- msteams_configs
|
||||
- required:
|
||||
- msteamsv2_configs
|
||||
- required:
|
||||
- jira_configs
|
||||
- required:
|
||||
- rocketchat_configs
|
||||
- required:
|
||||
@@ -217,7 +270,11 @@ components:
|
||||
type: array
|
||||
jira_configs:
|
||||
items:
|
||||
$ref: '#/components/schemas/ConfigJiraConfig'
|
||||
$ref: '#/components/schemas/AlertmanagertypesJiraReceiverConfig'
|
||||
type: array
|
||||
jsmops_configs:
|
||||
items:
|
||||
$ref: '#/components/schemas/AlertmanagertypesJSMOpsReceiverConfig'
|
||||
type: array
|
||||
mattermost_configs:
|
||||
items:
|
||||
@@ -344,7 +401,11 @@ components:
|
||||
type: array
|
||||
jira_configs:
|
||||
items:
|
||||
$ref: '#/components/schemas/ConfigJiraConfig'
|
||||
$ref: '#/components/schemas/AlertmanagertypesJiraReceiverConfig'
|
||||
type: array
|
||||
jsmops_configs:
|
||||
items:
|
||||
$ref: '#/components/schemas/AlertmanagertypesJSMOpsReceiverConfig'
|
||||
type: array
|
||||
mattermost_configs:
|
||||
items:
|
||||
@@ -2944,6 +3005,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 +6561,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:
|
||||
@@ -7383,25 +7626,36 @@ components:
|
||||
- custom
|
||||
- text
|
||||
type: string
|
||||
QuickfiltertypesSignalFilters:
|
||||
QuickfiltertypesSourceFilters:
|
||||
properties:
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
filters:
|
||||
items:
|
||||
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
|
||||
nullable: true
|
||||
type: array
|
||||
signal:
|
||||
id:
|
||||
type: string
|
||||
orgId:
|
||||
type: string
|
||||
source:
|
||||
type: string
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- filters
|
||||
type: object
|
||||
QuickfiltertypesUpdatableQuickFilters:
|
||||
properties:
|
||||
filters:
|
||||
items:
|
||||
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
|
||||
nullable: true
|
||||
type: array
|
||||
signal:
|
||||
type: string
|
||||
required:
|
||||
- filters
|
||||
type: object
|
||||
RenderErrorResponse:
|
||||
properties:
|
||||
@@ -15379,6 +15633,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
|
||||
@@ -18342,7 +18663,7 @@ paths:
|
||||
/api/v2/quick_filters:
|
||||
get:
|
||||
deprecated: false
|
||||
description: Returns the org's quick filters for every signal, each filter as
|
||||
description: Returns the org's quick filters for every source, each filter as
|
||||
a telemetry field key.
|
||||
operationId: ListQuickFilters
|
||||
responses:
|
||||
@@ -18353,7 +18674,7 @@ paths:
|
||||
properties:
|
||||
data:
|
||||
items:
|
||||
$ref: '#/components/schemas/QuickfiltertypesSignalFilters'
|
||||
$ref: '#/components/schemas/QuickfiltertypesSourceFilters'
|
||||
nullable: true
|
||||
type: array
|
||||
status:
|
||||
@@ -18395,10 +18716,75 @@ paths:
|
||||
summary: List quick filters
|
||||
tags:
|
||||
- quick_filter
|
||||
/api/v2/quick_filters/{source}:
|
||||
get:
|
||||
deprecated: false
|
||||
description: Returns the org's quick filters for one source, each filter as
|
||||
a telemetry field key.
|
||||
operationId: GetQuickFilters
|
||||
parameters:
|
||||
- in: path
|
||||
name: source
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/QuickfiltertypesSourceFilters'
|
||||
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
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- quick-filter:read
|
||||
- tokenizer:
|
||||
- quick-filter:read
|
||||
summary: Get a source's quick filters
|
||||
tags:
|
||||
- quick_filter
|
||||
put:
|
||||
deprecated: false
|
||||
description: Replaces the org's quick filters for the signal named in the body.
|
||||
description: Replaces the org's quick filters for the source named in the path.
|
||||
operationId: UpdateQuickFilters
|
||||
parameters:
|
||||
- in: path
|
||||
name: source
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
@@ -18439,65 +18825,6 @@ paths:
|
||||
summary: Update quick filters
|
||||
tags:
|
||||
- quick_filter
|
||||
/api/v2/quick_filters/{signal_name}:
|
||||
get:
|
||||
deprecated: false
|
||||
description: Returns the org's quick filters for one signal, each filter as
|
||||
a telemetry field key.
|
||||
operationId: GetQuickFilters
|
||||
parameters:
|
||||
- in: path
|
||||
name: signal_name
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/QuickfiltertypesSignalFilters'
|
||||
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
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- quick-filter:read
|
||||
- tokenizer:
|
||||
- quick-filter:read
|
||||
summary: Get a signal's quick filters
|
||||
tags:
|
||||
- quick_filter
|
||||
/api/v2/readyz:
|
||||
get:
|
||||
operationId: Readyz
|
||||
@@ -24990,6 +25317,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));
|
||||
};
|
||||
@@ -23,13 +23,14 @@ import type {
|
||||
ListQuickFilters200,
|
||||
QuickfiltertypesUpdatableQuickFiltersDTO,
|
||||
RenderErrorResponseDTO,
|
||||
UpdateQuickFiltersPathParameters,
|
||||
} from '../sigNoz.schemas';
|
||||
|
||||
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
|
||||
|
||||
/**
|
||||
* Returns the org's quick filters for every signal, each filter as a telemetry field key.
|
||||
* Returns the org's quick filters for every source, each filter as a telemetry field key.
|
||||
* @summary List quick filters
|
||||
*/
|
||||
export const listQuickFilters = (signal?: AbortSignal) => {
|
||||
@@ -113,15 +114,118 @@ export const invalidateListQuickFilters = async (
|
||||
};
|
||||
|
||||
/**
|
||||
* Replaces the org's quick filters for the signal named in the body.
|
||||
* Returns the org's quick filters for one source, each filter as a telemetry field key.
|
||||
* @summary Get a source's quick filters
|
||||
*/
|
||||
export const getQuickFilters = (
|
||||
{ source }: GetQuickFiltersPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetQuickFilters200>({
|
||||
url: `/api/v2/quick_filters/${source}`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetQuickFiltersQueryKey = ({
|
||||
source,
|
||||
}: GetQuickFiltersPathParameters) => {
|
||||
return [`/api/v2/quick_filters/${source}`] as const;
|
||||
};
|
||||
|
||||
export const getGetQuickFiltersQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getQuickFilters>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ source }: GetQuickFiltersPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getQuickFilters>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetQuickFiltersQueryKey({ source });
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getQuickFilters>>> = ({
|
||||
signal,
|
||||
}) => getQuickFilters({ source }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!source,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getQuickFilters>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetQuickFiltersQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getQuickFilters>>
|
||||
>;
|
||||
export type GetQuickFiltersQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get a source's quick filters
|
||||
*/
|
||||
|
||||
export function useGetQuickFilters<
|
||||
TData = Awaited<ReturnType<typeof getQuickFilters>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ source }: GetQuickFiltersPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getQuickFilters>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetQuickFiltersQueryOptions({ source }, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get a source's quick filters
|
||||
*/
|
||||
export const invalidateGetQuickFilters = async (
|
||||
queryClient: QueryClient,
|
||||
{ source }: GetQuickFiltersPathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetQuickFiltersQueryKey({ source }) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Replaces the org's quick filters for the source named in the path.
|
||||
* @summary Update quick filters
|
||||
*/
|
||||
export const updateQuickFilters = (
|
||||
{ source }: UpdateQuickFiltersPathParameters,
|
||||
quickfiltertypesUpdatableQuickFiltersDTO?: BodyType<QuickfiltertypesUpdatableQuickFiltersDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v2/quick_filters`,
|
||||
url: `/api/v2/quick_filters/${source}`,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: quickfiltertypesUpdatableQuickFiltersDTO,
|
||||
@@ -136,13 +240,19 @@ export const getUpdateQuickFiltersMutationOptions = <
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateQuickFilters>>,
|
||||
TError,
|
||||
{ data?: BodyType<QuickfiltertypesUpdatableQuickFiltersDTO> },
|
||||
{
|
||||
pathParams: UpdateQuickFiltersPathParameters;
|
||||
data?: BodyType<QuickfiltertypesUpdatableQuickFiltersDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateQuickFilters>>,
|
||||
TError,
|
||||
{ data?: BodyType<QuickfiltertypesUpdatableQuickFiltersDTO> },
|
||||
{
|
||||
pathParams: UpdateQuickFiltersPathParameters;
|
||||
data?: BodyType<QuickfiltertypesUpdatableQuickFiltersDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['updateQuickFilters'];
|
||||
@@ -156,11 +266,14 @@ export const getUpdateQuickFiltersMutationOptions = <
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof updateQuickFilters>>,
|
||||
{ data?: BodyType<QuickfiltertypesUpdatableQuickFiltersDTO> }
|
||||
{
|
||||
pathParams: UpdateQuickFiltersPathParameters;
|
||||
data?: BodyType<QuickfiltertypesUpdatableQuickFiltersDTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
const { pathParams, data } = props ?? {};
|
||||
|
||||
return updateQuickFilters(data);
|
||||
return updateQuickFilters(pathParams, data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
@@ -184,115 +297,20 @@ export const useUpdateQuickFilters = <
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateQuickFilters>>,
|
||||
TError,
|
||||
{ data?: BodyType<QuickfiltertypesUpdatableQuickFiltersDTO> },
|
||||
{
|
||||
pathParams: UpdateQuickFiltersPathParameters;
|
||||
data?: BodyType<QuickfiltertypesUpdatableQuickFiltersDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof updateQuickFilters>>,
|
||||
TError,
|
||||
{ data?: BodyType<QuickfiltertypesUpdatableQuickFiltersDTO> },
|
||||
{
|
||||
pathParams: UpdateQuickFiltersPathParameters;
|
||||
data?: BodyType<QuickfiltertypesUpdatableQuickFiltersDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getUpdateQuickFiltersMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns the org's quick filters for one signal, each filter as a telemetry field key.
|
||||
* @summary Get a signal's quick filters
|
||||
*/
|
||||
export const getQuickFilters = (
|
||||
{ signalName }: GetQuickFiltersPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetQuickFilters200>({
|
||||
url: `/api/v2/quick_filters/${signalName}`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetQuickFiltersQueryKey = ({
|
||||
signalName,
|
||||
}: GetQuickFiltersPathParameters) => {
|
||||
return [`/api/v2/quick_filters/${signalName}`] as const;
|
||||
};
|
||||
|
||||
export const getGetQuickFiltersQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getQuickFilters>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ signalName }: GetQuickFiltersPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getQuickFilters>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetQuickFiltersQueryKey({ signalName });
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getQuickFilters>>> = ({
|
||||
signal,
|
||||
}) => getQuickFilters({ signalName }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!signalName,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getQuickFilters>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetQuickFiltersQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getQuickFilters>>
|
||||
>;
|
||||
export type GetQuickFiltersQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get a signal's quick filters
|
||||
*/
|
||||
|
||||
export function useGetQuickFilters<
|
||||
TData = Awaited<ReturnType<typeof getQuickFilters>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ signalName }: GetQuickFiltersPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getQuickFilters>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetQuickFiltersQueryOptions({ signalName }, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get a signal's quick filters
|
||||
*/
|
||||
export const invalidateGetQuickFilters = async (
|
||||
queryClient: QueryClient,
|
||||
{ signalName }: GetQuickFiltersPathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetQuickFiltersQueryKey({ signalName }) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
@@ -385,6 +385,93 @@ export interface AlertmanagertypesGoogleChatReceiverConfigDTO {
|
||||
webhook_url?: ConfigSecretURLDTO;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesJSMOpsReceiverConfigDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
api_key?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description?: string;
|
||||
http_config?: ConfigHTTPClientConfigDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
message?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
priority?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
send_resolved?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
tags?: string;
|
||||
}
|
||||
|
||||
export type AlertmanagertypesJiraReceiverConfigDTOCustomFields = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export type ModelDurationDTO = number;
|
||||
|
||||
export interface AlertmanagertypesJiraReceiverConfigDTO {
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
custom_fields?: AlertmanagertypesJiraReceiverConfigDTOCustomFields;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
description?: string;
|
||||
http_config?: ConfigHTTPClientConfigDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
issue_type?: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
labels?: string[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
priority?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
project?: string;
|
||||
reopen_duration?: ModelDurationDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
reopen_transition?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
resolve_transition?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
send_resolved?: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
site?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
summary?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
wont_fix_resolution?: string;
|
||||
}
|
||||
|
||||
export enum AlertmanagertypesMaintenanceKindDTO {
|
||||
fixed = 'fixed',
|
||||
recurring = 'recurring',
|
||||
@@ -631,69 +718,6 @@ export interface ConfigIncidentioConfigDTO {
|
||||
url_file?: string;
|
||||
}
|
||||
|
||||
export interface ConfigJiraFieldConfigDTO {
|
||||
/**
|
||||
* @type boolean,null
|
||||
*/
|
||||
enable_update?: boolean | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
template?: string;
|
||||
}
|
||||
|
||||
export type ModelDurationDTO = number;
|
||||
|
||||
export type ConfigJiraConfigDTOCustomFields = { [key: string]: unknown };
|
||||
|
||||
export interface ConfigJiraConfigDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
api_type?: string;
|
||||
api_url?: ConfigURLType2DTO;
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
custom_fields?: ConfigJiraConfigDTOCustomFields;
|
||||
description?: ConfigJiraFieldConfigDTO;
|
||||
http_config?: ConfigHTTPClientConfigDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
issue_type?: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
labels?: string[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
priority?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
project?: string;
|
||||
reopen_duration?: ModelDurationDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
reopen_transition?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
resolve_transition?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
send_resolved?: boolean;
|
||||
summary?: ConfigJiraFieldConfigDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
wont_fix_resolution?: string;
|
||||
}
|
||||
|
||||
export interface ConfigMattermostFieldDTO {
|
||||
/**
|
||||
* @type boolean,null
|
||||
@@ -1652,7 +1676,11 @@ export type AlertmanagertypesPostableChannelDTO = unknown & {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
jira_configs?: ConfigJiraConfigDTO[];
|
||||
jira_configs?: AlertmanagertypesJiraReceiverConfigDTO[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
jsmops_configs?: AlertmanagertypesJSMOpsReceiverConfigDTO[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
@@ -1779,7 +1807,11 @@ export interface AlertmanagertypesReceiverDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
jira_configs?: ConfigJiraConfigDTO[];
|
||||
jira_configs?: AlertmanagertypesJiraReceiverConfigDTO[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
jsmops_configs?: AlertmanagertypesJSMOpsReceiverConfigDTO[];
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
@@ -3268,6 +3300,67 @@ export interface CommonJSONRefDTO {
|
||||
$ref?: string;
|
||||
}
|
||||
|
||||
export type ConfigJiraConfigDTOCustomFields = { [key: string]: unknown };
|
||||
|
||||
export interface ConfigJiraFieldConfigDTO {
|
||||
/**
|
||||
* @type boolean,null
|
||||
*/
|
||||
enable_update?: boolean | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
template?: string;
|
||||
}
|
||||
|
||||
export interface ConfigJiraConfigDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
api_type?: string;
|
||||
api_url?: ConfigURLType2DTO;
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
custom_fields?: ConfigJiraConfigDTOCustomFields;
|
||||
description?: ConfigJiraFieldConfigDTO;
|
||||
http_config?: ConfigHTTPClientConfigDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
issue_type?: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
labels?: string[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
priority?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
project?: string;
|
||||
reopen_duration?: ModelDurationDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
reopen_transition?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
resolve_transition?: string;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
send_resolved?: boolean;
|
||||
summary?: ConfigJiraFieldConfigDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
wont_fix_resolution?: string;
|
||||
}
|
||||
|
||||
export interface DashboardGridItemDTO {
|
||||
content?: CommonJSONRefDTO;
|
||||
/**
|
||||
@@ -4960,6 +5053,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 +8114,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;
|
||||
/**
|
||||
@@ -8437,26 +8735,40 @@ export enum Querybuildertypesv5QueryTypeDTO {
|
||||
clickhouse_sql = 'clickhouse_sql',
|
||||
promql = 'promql',
|
||||
}
|
||||
export interface QuickfiltertypesSignalFiltersDTO {
|
||||
export interface QuickfiltertypesSourceFiltersDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
filters?: TelemetrytypesTelemetryFieldKeyDTO[] | null;
|
||||
createdAt?: string;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
filters: TelemetrytypesTelemetryFieldKeyDTO[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
signal?: string;
|
||||
id: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
orgId?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
source?: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface QuickfiltertypesUpdatableQuickFiltersDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
* @type array
|
||||
*/
|
||||
filters?: TelemetrytypesTelemetryFieldKeyDTO[] | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
signal?: string;
|
||||
filters: TelemetrytypesTelemetryFieldKeyDTO[];
|
||||
}
|
||||
|
||||
export interface RenderErrorResponseDTO {
|
||||
@@ -11335,6 +11647,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
|
||||
@@ -11853,7 +12176,7 @@ export type ListQuickFilters200 = {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
data: QuickfiltertypesSignalFiltersDTO[] | null;
|
||||
data: QuickfiltertypesSourceFiltersDTO[] | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -11861,16 +12184,19 @@ export type ListQuickFilters200 = {
|
||||
};
|
||||
|
||||
export type GetQuickFiltersPathParameters = {
|
||||
signalName: string;
|
||||
source: string;
|
||||
};
|
||||
export type GetQuickFilters200 = {
|
||||
data: QuickfiltertypesSignalFiltersDTO;
|
||||
data: QuickfiltertypesSourceFiltersDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type UpdateQuickFiltersPathParameters = {
|
||||
source: string;
|
||||
};
|
||||
export type Readyz200 = {
|
||||
data: FactoryResponseDTO;
|
||||
/**
|
||||
@@ -12515,3 +12841,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;
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
557
pkg/alertmanager/alertmanagernotify/jira/jira.go
Normal file
557
pkg/alertmanager/alertmanagernotify/jira/jira.go
Normal file
@@ -0,0 +1,557 @@
|
||||
// Copyright (c) 2026 SigNoz, Inc.
|
||||
// Copyright 2023 Prometheus Team
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package jira
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf16"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/templating/markdownrenderer/adf"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
"github.com/prometheus/alertmanager/notify"
|
||||
"github.com/prometheus/alertmanager/template"
|
||||
"github.com/prometheus/alertmanager/types"
|
||||
)
|
||||
|
||||
const Integration = "jira"
|
||||
|
||||
const (
|
||||
maxSummaryLenRunes = 255
|
||||
maxDescriptionLenRunes = 32767
|
||||
)
|
||||
|
||||
// Notifier implements notify.Notifier for Jira.
|
||||
type Notifier struct {
|
||||
conf *alertmanagertypes.JiraReceiverConfig
|
||||
logger *slog.Logger
|
||||
client *http.Client
|
||||
retrier *notify.Retrier
|
||||
templater alertmanagertypes.Templater
|
||||
}
|
||||
|
||||
func New(conf *alertmanagertypes.JiraReceiverConfig, _ *template.Template, l *slog.Logger, templater alertmanagertypes.Templater) (*Notifier, error) {
|
||||
if conf.HTTPConfig == nil {
|
||||
return nil, errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "jira http_config is nil")
|
||||
}
|
||||
client, err := notify.NewClientWithTracing(*conf.HTTPConfig, Integration)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Notifier{
|
||||
conf: conf,
|
||||
logger: l,
|
||||
client: client,
|
||||
retrier: ¬ify.Retrier{RetryCodes: []int{http.StatusTooManyRequests}},
|
||||
templater: templater,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
|
||||
key, err := notify.ExtractGroupKey(ctx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
groupID := key.Hash()
|
||||
firing := types.Alerts(as...).HasFiring()
|
||||
n.logger.DebugContext(ctx, "sending jira notification", slog.String("group_key", key.String()), slog.Bool("firing", firing))
|
||||
|
||||
customTitle, customBody := alertmanagertemplate.ExtractTemplatesFromAnnotations(as)
|
||||
result, err := n.templater.Expand(ctx, alertmanagertypes.ExpandRequest{
|
||||
TitleTemplate: customTitle,
|
||||
BodyTemplate: customBody,
|
||||
DefaultTitleTemplate: n.conf.Summary,
|
||||
DefaultBodyTemplate: n.conf.Description,
|
||||
}, as)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
summary := truncateRunes(result.Title, maxSummaryLenRunes)
|
||||
|
||||
var parts []string
|
||||
for _, body := range result.Body {
|
||||
if body != "" {
|
||||
parts = append(parts, body)
|
||||
}
|
||||
}
|
||||
// custom body templates render per alert; join them under ADF rule dividers.
|
||||
// The default body is a single combined part, so the join is a no-op there.
|
||||
descText := truncateRunes(strings.Join(parts, "\n\n---\n\n"), maxDescriptionLenRunes)
|
||||
|
||||
baseURL, retry, err := n.resolveAPIBaseURL(ctx)
|
||||
if err != nil {
|
||||
return retry, err
|
||||
}
|
||||
|
||||
existing, retry, err := n.searchIssue(ctx, baseURL, groupID, firing)
|
||||
if err != nil {
|
||||
return retry, err
|
||||
}
|
||||
|
||||
fields := n.buildFields(groupID, summary, descText, as, firing)
|
||||
|
||||
// No existing issue: create for firing groups; never create for resolved-only.
|
||||
if existing == nil {
|
||||
if !firing {
|
||||
return false, nil
|
||||
}
|
||||
return n.createIssue(ctx, baseURL, fields)
|
||||
}
|
||||
|
||||
// Existing issue: refresh it, then transition + comment based on the new state.
|
||||
if retry, err := n.updateIssue(ctx, baseURL, existing, fields); err != nil {
|
||||
return retry, err
|
||||
}
|
||||
|
||||
// Each state-change comment carries the same rich snapshot as the description
|
||||
// (panel + details + deep-links), so the comment timeline mirrors the card
|
||||
// Google Chat re-posts on every notification.
|
||||
switch {
|
||||
case firing && existing.isDone(): // re-fired after resolution → reopen
|
||||
if retry, err := n.applyTransition(ctx, baseURL, existing.Key, false, n.conf.ReopenTransition); err != nil {
|
||||
return retry, err
|
||||
}
|
||||
case !firing: // resolved (search returns only open issues, so this one is open)
|
||||
if retry, err := n.applyTransition(ctx, baseURL, existing.Key, true, n.conf.ResolveTransition); err != nil {
|
||||
return retry, err
|
||||
}
|
||||
}
|
||||
// firing && !isDone (still firing) needs no transition.
|
||||
return n.addComment(ctx, baseURL, existing.Key, fields.Description)
|
||||
}
|
||||
|
||||
func (n *Notifier) buildFields(groupID, summary, descText string, alerts []*types.Alert, firing bool) *issueFields {
|
||||
f := &issueFields{
|
||||
Project: &idKey{Key: n.conf.Project},
|
||||
Issuetype: &idName{Name: n.conf.IssueType},
|
||||
Summary: summary,
|
||||
Labels: n.labels(groupID),
|
||||
Description: n.buildBoundedDescription(descText, alerts, firing),
|
||||
}
|
||||
if n.conf.Priority != "" {
|
||||
f.Priority = &idName{Name: n.conf.Priority}
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// buildBoundedDescription builds the ADF issue body and keeps it within Jira's
|
||||
// description limit, which counts text characters plus per-node overhead — so a
|
||||
// text-only markdown cap is not enough. Over-limit bodies are shrunk at the
|
||||
// markdown level and rebuilt; the panel and deep-links are part of the measured
|
||||
// document, so the result always fits.
|
||||
func (n *Notifier) buildBoundedDescription(descText string, alerts []*types.Alert, firing bool) map[string]any {
|
||||
doc := n.buildDescription(descText, alerts, firing)
|
||||
for range 4 {
|
||||
size := adfDocLen(doc)
|
||||
if size <= maxDescriptionLenRunes {
|
||||
return doc
|
||||
}
|
||||
runes := []rune(descText)
|
||||
keep := len(runes) * maxDescriptionLenRunes / size * 9 / 10
|
||||
if keep >= len(runes) {
|
||||
keep = len(runes) - 1
|
||||
}
|
||||
if keep <= 0 {
|
||||
break
|
||||
}
|
||||
descText = string(runes[:keep]) + "…"
|
||||
doc = n.buildDescription(descText, alerts, firing)
|
||||
}
|
||||
if adfDocLen(doc) <= maxDescriptionLenRunes {
|
||||
return doc
|
||||
}
|
||||
// still over after shrinking: keep just the panel and deep-links
|
||||
return n.buildDescription("", alerts, firing)
|
||||
}
|
||||
|
||||
// adfDocLen approximates how Jira measures an ADF document against the 32767
|
||||
// limit: text length in UTF-16 code units, plus per-node overhead (block
|
||||
// boundaries count like newlines), plus link targets. Deliberately counts on
|
||||
// the high side so a passing measurement never 400s.
|
||||
func adfDocLen(node any) int {
|
||||
m, ok := node.(map[string]any)
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
size := 2
|
||||
if text, ok := m["text"].(string); ok {
|
||||
for _, r := range text {
|
||||
size += utf16.RuneLen(r)
|
||||
}
|
||||
}
|
||||
if marks, ok := m["marks"].([]any); ok {
|
||||
for _, mark := range marks {
|
||||
if mm, ok := mark.(map[string]any); ok {
|
||||
if attrs, ok := mm["attrs"].(map[string]any); ok {
|
||||
if href, ok := attrs["href"].(string); ok {
|
||||
size += len(href)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if content, ok := m["content"].([]any); ok {
|
||||
for _, child := range content {
|
||||
size += adfDocLen(child)
|
||||
}
|
||||
}
|
||||
return size
|
||||
}
|
||||
|
||||
// buildDescription assembles the ADF issue body: a firing/resolved status panel,
|
||||
// the rendered markdown body, and SigNoz deep-links.
|
||||
func (n *Notifier) buildDescription(descText string, alerts []*types.Alert, firing bool) map[string]any {
|
||||
content := []any{statusPanel(firing)}
|
||||
content = append(content, adf.Render(descText)...)
|
||||
if links := deepLinks(alerts); links != nil {
|
||||
content = append(content, links)
|
||||
}
|
||||
return map[string]any{"type": "doc", "version": 1, "content": content}
|
||||
}
|
||||
|
||||
func statusPanel(firing bool) map[string]any {
|
||||
panelType, label := "success", "🟢 RESOLVED"
|
||||
if firing {
|
||||
panelType, label = "error", "🔴 FIRING"
|
||||
}
|
||||
return map[string]any{
|
||||
"type": "panel",
|
||||
"attrs": map[string]any{"panelType": panelType},
|
||||
"content": []any{map[string]any{
|
||||
"type": "paragraph",
|
||||
"content": []any{map[string]any{"type": "text", "text": label, "marks": []any{map[string]any{"type": "strong"}}}},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// deepLinks builds a paragraph of SigNoz links from the per-rule ruleSource label
|
||||
// and the related-logs/traces annotations. Returns nil when none are present.
|
||||
func deepLinks(alerts []*types.Alert) map[string]any {
|
||||
if len(alerts) == 0 {
|
||||
return nil
|
||||
}
|
||||
a := alerts[0]
|
||||
var parts []any
|
||||
add := func(label, url string) {
|
||||
if url == "" {
|
||||
return
|
||||
}
|
||||
if len(parts) > 0 {
|
||||
parts = append(parts, map[string]any{"type": "text", "text": " · "})
|
||||
}
|
||||
parts = append(parts, map[string]any{
|
||||
"type": "text",
|
||||
"text": label,
|
||||
"marks": []any{map[string]any{"type": "link", "attrs": map[string]any{"href": url}}},
|
||||
})
|
||||
}
|
||||
add("Open in SigNoz", string(a.Labels[ruletypes.LabelRuleSource]))
|
||||
add("View Related Logs", string(a.Annotations[ruletypes.AnnotationRelatedLogs]))
|
||||
add("View Related Traces", string(a.Annotations[ruletypes.AnnotationRelatedTraces]))
|
||||
if len(parts) == 0 {
|
||||
return nil
|
||||
}
|
||||
return map[string]any{"type": "paragraph", "content": parts}
|
||||
}
|
||||
|
||||
func (n *Notifier) labels(groupID string) []string {
|
||||
out := append([]string{}, n.conf.Labels...)
|
||||
out = append(out, "signoz-alert", fmt.Sprintf("ALERT{%s}", groupID))
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func (n *Notifier) searchIssue(ctx context.Context, baseURL, groupID string, firing bool) (*issue, bool, error) {
|
||||
var jql strings.Builder
|
||||
if n.conf.WontFixResolution != "" {
|
||||
// != alone also drops unresolved (EMPTY) issues, so keep those explicitly.
|
||||
fmt.Fprintf(&jql, `(resolution is EMPTY or resolution != %q) and `, n.conf.WontFixResolution)
|
||||
}
|
||||
if reopenMin := int64(time.Duration(n.conf.ReopenDuration).Minutes()); firing && reopenMin > 0 {
|
||||
fmt.Fprintf(&jql, `(resolutiondate is EMPTY OR resolutiondate >= -%dm) and `, reopenMin)
|
||||
} else {
|
||||
jql.WriteString(`statusCategory != Done and `)
|
||||
}
|
||||
fmt.Fprintf(&jql, `project=%q and labels=%q order by status ASC, resolutiondate DESC`, n.conf.Project, fmt.Sprintf("ALERT{%s}", groupID))
|
||||
|
||||
body, retry, err := n.callAPI(ctx, http.MethodPost, baseURL+"/search/jql", searchRequest{
|
||||
JQL: jql.String(), MaxResults: 2, Fields: []string{"status", "labels"},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, retry, err
|
||||
}
|
||||
var res searchResult
|
||||
if err := json.Unmarshal(body, &res); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if len(res.Issues) == 0 {
|
||||
return nil, false, nil
|
||||
}
|
||||
// the JQL order is not category-aware, so prefer an open issue over a done
|
||||
// one; all done falls back to the most recently resolved (resolutiondate DESC)
|
||||
for i := range res.Issues {
|
||||
if !res.Issues[i].isDone() {
|
||||
return &res.Issues[i], false, nil
|
||||
}
|
||||
}
|
||||
return &res.Issues[0], false, nil
|
||||
}
|
||||
|
||||
func (n *Notifier) createIssue(ctx context.Context, baseURL string, fields *issueFields) (bool, error) {
|
||||
_, retry, err := n.callAPI(ctx, http.MethodPost, baseURL+"/issue", issue{Fields: fields})
|
||||
return retry, err
|
||||
}
|
||||
|
||||
func (n *Notifier) updateIssue(ctx context.Context, baseURL string, existing *issue, fields *issueFields) (bool, error) {
|
||||
// project and issue type are set at creation and cannot be edited.
|
||||
upd := *fields
|
||||
upd.Project = nil
|
||||
upd.Issuetype = nil
|
||||
// Jira replaces the labels array wholesale, so union in the labels already
|
||||
// on the issue to keep user-added ones.
|
||||
if existing.Fields != nil {
|
||||
upd.Labels = mergeLabels(existing.Fields.Labels, fields.Labels)
|
||||
}
|
||||
_, retry, err := n.callAPI(ctx, http.MethodPut, n.issueURL(baseURL, existing.Key, ""), issue{Fields: &upd})
|
||||
return retry, err
|
||||
}
|
||||
|
||||
func mergeLabels(existing, ours []string) []string {
|
||||
seen := make(map[string]bool, len(existing)+len(ours))
|
||||
var merged []string
|
||||
for _, label := range append(append([]string{}, existing...), ours...) {
|
||||
if !seen[label] {
|
||||
seen[label] = true
|
||||
merged = append(merged, label)
|
||||
}
|
||||
}
|
||||
sort.Strings(merged)
|
||||
return merged
|
||||
}
|
||||
|
||||
// applyTransition moves the issue into (toDone) or out of (!toDone) the "done"
|
||||
// status category, preferring the named override, else the first matching
|
||||
// transition, else skipping without error when none is available.
|
||||
func (n *Notifier) applyTransition(ctx context.Context, baseURL, key string, toDone bool, override string) (bool, error) {
|
||||
transitions, retry, err := n.getTransitions(ctx, baseURL, key)
|
||||
if err != nil {
|
||||
return retry, err
|
||||
}
|
||||
id := selectTransition(transitions, toDone, override)
|
||||
if id == "" {
|
||||
n.logger.WarnContext(ctx, "jira: no matching transition, leaving issue as-is", slog.String("issue", key), slog.Bool("to_done", toDone))
|
||||
return false, nil
|
||||
}
|
||||
_, retry, err = n.callAPI(ctx, http.MethodPost, n.issueURL(baseURL, key, "transitions"), issue{Transition: &idName{ID: id}})
|
||||
return retry, err
|
||||
}
|
||||
|
||||
func (n *Notifier) getTransitions(ctx context.Context, baseURL, key string) ([]jiraTransition, bool, error) {
|
||||
body, retry, err := n.callAPI(ctx, http.MethodGet, n.issueURL(baseURL, key, "transitions"), nil)
|
||||
if err != nil {
|
||||
return nil, retry, err
|
||||
}
|
||||
var tr transitionsResponse
|
||||
if err := json.Unmarshal(body, &tr); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return tr.Transitions, false, nil
|
||||
}
|
||||
|
||||
func (n *Notifier) addComment(ctx context.Context, baseURL, key string, body any) (bool, error) {
|
||||
_, retry, err := n.callAPI(ctx, http.MethodPost, n.issueURL(baseURL, key, "comment"), comment{Body: body})
|
||||
return retry, err
|
||||
}
|
||||
|
||||
func (n *Notifier) issueURL(baseURL, key, sub string) string {
|
||||
u := baseURL + "/issue/" + key
|
||||
if sub != "" {
|
||||
u += "/" + sub
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func (n *Notifier) callAPI(ctx context.Context, method, url string, reqBody any) ([]byte, bool, error) {
|
||||
var body io.Reader
|
||||
if reqBody != nil {
|
||||
var buf bytes.Buffer
|
||||
if err := json.NewEncoder(&buf).Encode(reqBody); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
body = &buf
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, url, body)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := n.client.Do(req) //nolint:bodyclose // notify.Drain closes the body
|
||||
if err != nil {
|
||||
return nil, true, notify.RedactURL(err)
|
||||
}
|
||||
defer notify.Drain(resp)
|
||||
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
shouldRetry, err := n.retrier.Check(resp.StatusCode, bytes.NewReader(respBody))
|
||||
if err != nil {
|
||||
return respBody, shouldRetry, notify.NewErrorWithReason(notify.GetFailureReasonFromStatusCode(resp.StatusCode), err)
|
||||
}
|
||||
return respBody, false, nil
|
||||
}
|
||||
|
||||
// resolveAPIBaseURL resolves the service-account cloud id per notification (it
|
||||
// is never persisted); personal API tokens use the site host directly.
|
||||
func (n *Notifier) resolveAPIBaseURL(ctx context.Context) (string, bool, error) {
|
||||
if !n.conf.IsServiceAccount() {
|
||||
return n.conf.APIBaseURL(""), false, nil
|
||||
}
|
||||
cloudID, retry, err := n.resolveCloudID(ctx)
|
||||
if err != nil {
|
||||
return "", retry, err
|
||||
}
|
||||
return n.conf.APIBaseURL(cloudID), false, nil
|
||||
}
|
||||
|
||||
// resolveCloudID fetches the site's cloud id from its unauthenticated
|
||||
// tenant_info endpoint; transport failures are retryable, bad responses are not.
|
||||
func (n *Notifier) resolveCloudID(ctx context.Context) (string, bool, error) {
|
||||
url := strings.TrimRight(n.conf.Site, "/") + "/_edge/tenant_info"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return "", false, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := n.client.Do(req)
|
||||
if err != nil {
|
||||
return "", true, errors.WrapInternalf(err, errors.CodeInternal, "failed to fetch jira cloud id")
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", true, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", false, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "failed to resolve jira cloud id from %s: status %d", url, resp.StatusCode)
|
||||
}
|
||||
|
||||
var out struct {
|
||||
CloudID string `json:"cloudId"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
return "", false, errors.WrapInternalf(err, errors.CodeInternal, "failed to parse jira tenant_info response")
|
||||
}
|
||||
if out.CloudID == "" {
|
||||
return "", false, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "jira tenant_info returned an empty cloud id for %s", n.conf.Site)
|
||||
}
|
||||
return out.CloudID, false, nil
|
||||
}
|
||||
|
||||
// selectTransition returns the id of the transition whose target status category
|
||||
// matches toDone, preferring one named override when present.
|
||||
func selectTransition(transitions []jiraTransition, toDone bool, override string) string {
|
||||
if override != "" {
|
||||
for _, t := range transitions {
|
||||
if t.Name == override {
|
||||
return t.ID
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, t := range transitions {
|
||||
if (t.To.StatusCategory.Key == "done") == toDone {
|
||||
return t.ID
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Jira API types.
|
||||
type issue struct {
|
||||
Key string `json:"key,omitempty"`
|
||||
Fields *issueFields `json:"fields,omitempty"`
|
||||
Transition *idName `json:"transition,omitempty"`
|
||||
}
|
||||
|
||||
type issueFields struct {
|
||||
Project *idKey `json:"project,omitempty"`
|
||||
Issuetype *idName `json:"issuetype,omitempty"`
|
||||
Summary string `json:"summary,omitempty"`
|
||||
Labels []string `json:"labels,omitempty"`
|
||||
Priority *idName `json:"priority,omitempty"`
|
||||
Description any `json:"description,omitempty"`
|
||||
Status *issueStatus `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
type idKey struct {
|
||||
Key string `json:"key"`
|
||||
}
|
||||
|
||||
type idName struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
}
|
||||
|
||||
type issueStatus struct {
|
||||
StatusCategory struct {
|
||||
Key string `json:"key"`
|
||||
} `json:"statusCategory"`
|
||||
}
|
||||
|
||||
func (i *issue) isDone() bool {
|
||||
return i.Fields != nil && i.Fields.Status != nil && i.Fields.Status.StatusCategory.Key == "done"
|
||||
}
|
||||
|
||||
type searchRequest struct {
|
||||
JQL string `json:"jql"`
|
||||
MaxResults int `json:"maxResults"`
|
||||
Fields []string `json:"fields"`
|
||||
}
|
||||
|
||||
type searchResult struct {
|
||||
Issues []issue `json:"issues"`
|
||||
}
|
||||
|
||||
type transitionsResponse struct {
|
||||
Transitions []jiraTransition `json:"transitions"`
|
||||
}
|
||||
|
||||
type jiraTransition struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
To struct {
|
||||
StatusCategory struct {
|
||||
Key string `json:"key"`
|
||||
} `json:"statusCategory"`
|
||||
} `json:"to"`
|
||||
}
|
||||
|
||||
type comment struct {
|
||||
Body any `json:"body"`
|
||||
}
|
||||
|
||||
func truncateRunes(s string, max int) string {
|
||||
r := []rune(s)
|
||||
if len(r) <= max {
|
||||
return s
|
||||
}
|
||||
return string(r[:max])
|
||||
}
|
||||
489
pkg/alertmanager/alertmanagernotify/jira/jira_test.go
Normal file
489
pkg/alertmanager/alertmanagernotify/jira/jira_test.go
Normal file
@@ -0,0 +1,489 @@
|
||||
package jira
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/ruletypes"
|
||||
"github.com/prometheus/alertmanager/notify"
|
||||
"github.com/prometheus/alertmanager/notify/test"
|
||||
"github.com/prometheus/alertmanager/types"
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
"github.com/prometheus/common/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type mockReq struct {
|
||||
method string
|
||||
path string
|
||||
body map[string]any
|
||||
}
|
||||
|
||||
type mockJira struct {
|
||||
srv *httptest.Server
|
||||
mu sync.Mutex
|
||||
reqs []mockReq
|
||||
searchIssues []issue
|
||||
transitions []jiraTransition
|
||||
createStatus int
|
||||
}
|
||||
|
||||
func newMockJira(t *testing.T) *mockJira {
|
||||
t.Helper()
|
||||
m := &mockJira{}
|
||||
m.srv = httptest.NewServer(http.HandlerFunc(m.handle))
|
||||
t.Cleanup(m.srv.Close)
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *mockJira) handle(w http.ResponseWriter, r *http.Request) {
|
||||
var body map[string]any
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
m.mu.Lock()
|
||||
m.reqs = append(m.reqs, mockReq{r.Method, r.URL.Path, body})
|
||||
m.mu.Unlock()
|
||||
|
||||
p := r.URL.Path
|
||||
switch {
|
||||
case strings.HasSuffix(p, "/search/jql"):
|
||||
_ = json.NewEncoder(w).Encode(searchResult{Issues: m.searchIssues})
|
||||
case strings.HasSuffix(p, "/transitions") && r.Method == http.MethodGet:
|
||||
_ = json.NewEncoder(w).Encode(transitionsResponse{Transitions: m.transitions})
|
||||
case strings.HasSuffix(p, "/transitions"):
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
case strings.HasSuffix(p, "/comment"):
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_, _ = w.Write([]byte(`{"id":"1"}`))
|
||||
case strings.HasSuffix(p, "/issue") && r.Method == http.MethodPost:
|
||||
st := m.createStatus
|
||||
if st == 0 {
|
||||
st = http.StatusCreated
|
||||
}
|
||||
w.WriteHeader(st)
|
||||
_, _ = w.Write([]byte(`{"key":"KAN-1"}`))
|
||||
case r.Method == http.MethodPut:
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockJira) countPost(suffix string) int {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
c := 0
|
||||
for _, r := range m.reqs {
|
||||
if r.method == http.MethodPost && strings.HasSuffix(r.path, suffix) {
|
||||
c++
|
||||
}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (m *mockJira) countPuts() int {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
c := 0
|
||||
for _, r := range m.reqs {
|
||||
if r.method == http.MethodPut {
|
||||
c++
|
||||
}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func newNotifier(t *testing.T, m *mockJira) *Notifier {
|
||||
t.Helper()
|
||||
tmpl := test.CreateTmpl(t)
|
||||
n, err := New(&alertmanagertypes.JiraReceiverConfig{
|
||||
Site: m.srv.URL,
|
||||
Project: "KAN",
|
||||
IssueType: "Task",
|
||||
Summary: alertmanagertypes.DefaultJiraSummaryTemplate,
|
||||
Description: alertmanagertypes.DefaultJiraDescriptionTemplate,
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
ReopenDuration: model.Duration(3 * 24 * time.Hour),
|
||||
}, tmpl, slog.New(slog.DiscardHandler), alertmanagertemplate.New(tmpl, slog.New(slog.DiscardHandler)))
|
||||
require.NoError(t, err)
|
||||
return n
|
||||
}
|
||||
|
||||
func alert(firing bool) *types.Alert {
|
||||
a := &types.Alert{Alert: model.Alert{
|
||||
Labels: model.LabelSet{"alertname": "HighCPU", "severity": "critical"},
|
||||
Annotations: model.LabelSet{"summary": "cpu high"},
|
||||
StartsAt: time.Now().Add(-time.Minute),
|
||||
}}
|
||||
if firing {
|
||||
a.EndsAt = time.Now().Add(time.Hour)
|
||||
} else {
|
||||
a.EndsAt = time.Now().Add(-time.Minute)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func ctx() context.Context {
|
||||
return notify.WithGroupKey(context.Background(), "test-jira")
|
||||
}
|
||||
|
||||
func doneIssue() issue {
|
||||
i := issue{Key: "KAN-1", Fields: &issueFields{Status: &issueStatus{}}}
|
||||
i.Fields.Status.StatusCategory.Key = "done"
|
||||
return i
|
||||
}
|
||||
|
||||
func openIssue() issue {
|
||||
i := issue{Key: "KAN-1", Fields: &issueFields{Status: &issueStatus{}}}
|
||||
i.Fields.Status.StatusCategory.Key = "new"
|
||||
return i
|
||||
}
|
||||
|
||||
func transition(id, name, category string) jiraTransition {
|
||||
tr := jiraTransition{ID: id, Name: name}
|
||||
tr.To.StatusCategory.Key = category
|
||||
return tr
|
||||
}
|
||||
|
||||
func TestNotifyCreatesWhenNoExistingIssue(t *testing.T) {
|
||||
m := newMockJira(t)
|
||||
retry, err := newNotifier(t, m).Notify(ctx(), alert(true))
|
||||
require.NoError(t, err)
|
||||
assert.False(t, retry)
|
||||
assert.Equal(t, 1, m.countPost("/issue"))
|
||||
assert.Equal(t, 0, m.countPost("/comment")) // no comment on create
|
||||
assert.Equal(t, 0, m.countPuts()) // no update
|
||||
}
|
||||
|
||||
func TestNotifyResolvedOnlyWithNoIssueIsNoop(t *testing.T) {
|
||||
m := newMockJira(t)
|
||||
retry, err := newNotifier(t, m).Notify(ctx(), alert(false))
|
||||
require.NoError(t, err)
|
||||
assert.False(t, retry)
|
||||
assert.Equal(t, 1, m.countPost("/search/jql"))
|
||||
assert.Equal(t, 0, m.countPost("/issue"))
|
||||
}
|
||||
|
||||
func TestNotifyStillFiringUpdatesAndComments(t *testing.T) {
|
||||
m := newMockJira(t)
|
||||
m.searchIssues = []issue{openIssue()}
|
||||
retry, err := newNotifier(t, m).Notify(ctx(), alert(true))
|
||||
require.NoError(t, err)
|
||||
assert.False(t, retry)
|
||||
assert.Equal(t, 0, m.countPost("/issue")) // no create
|
||||
assert.Equal(t, 1, m.countPuts()) // update
|
||||
assert.Equal(t, 1, m.countPost("/comment"))
|
||||
assert.Equal(t, 0, m.countPost("/transitions")) // still open, no transition
|
||||
|
||||
// comment carries the full rich snapshot (panel + labeled body), not a one-liner.
|
||||
cjs, err := json.Marshal(m.lastBody(t, "/comment"))
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(cjs), `"panel"`)
|
||||
assert.Contains(t, string(cjs), "Summary:")
|
||||
}
|
||||
|
||||
func TestNotifyResolveTransitionsToDoneAndComments(t *testing.T) {
|
||||
m := newMockJira(t)
|
||||
m.searchIssues = []issue{openIssue()}
|
||||
m.transitions = []jiraTransition{transition("11", "To Do", "new"), transition("41", "Done", "done")}
|
||||
retry, err := newNotifier(t, m).Notify(ctx(), alert(false))
|
||||
require.NoError(t, err)
|
||||
assert.False(t, retry)
|
||||
assert.Equal(t, 1, m.countPuts()) // update
|
||||
assert.Equal(t, 1, m.countPost("/transitions")) // resolve transition
|
||||
assert.Equal(t, 1, m.countPost("/comment"))
|
||||
}
|
||||
|
||||
func TestNotifyReopensDoneIssue(t *testing.T) {
|
||||
m := newMockJira(t)
|
||||
m.searchIssues = []issue{doneIssue()}
|
||||
m.transitions = []jiraTransition{transition("11", "To Do", "new"), transition("41", "Done", "done")}
|
||||
retry, err := newNotifier(t, m).Notify(ctx(), alert(true))
|
||||
require.NoError(t, err)
|
||||
assert.False(t, retry)
|
||||
assert.Equal(t, 1, m.countPost("/transitions")) // reopen transition
|
||||
assert.Equal(t, 1, m.countPost("/comment"))
|
||||
}
|
||||
|
||||
func TestNotifySafeSkipsWhenNoMatchingTransition(t *testing.T) {
|
||||
m := newMockJira(t)
|
||||
m.searchIssues = []issue{openIssue()}
|
||||
m.transitions = []jiraTransition{transition("11", "To Do", "new")} // no done-category transition
|
||||
retry, err := newNotifier(t, m).Notify(ctx(), alert(false))
|
||||
require.NoError(t, err) // must not error
|
||||
assert.False(t, retry)
|
||||
assert.Equal(t, 0, m.countPost("/transitions")) // skipped
|
||||
assert.Equal(t, 1, m.countPost("/comment")) // comment still posted
|
||||
}
|
||||
|
||||
func TestNotifyPrefersOpenIssueOverRecentlyDone(t *testing.T) {
|
||||
m := newMockJira(t)
|
||||
open := openIssue()
|
||||
open.Key = "KAN-2"
|
||||
// the JQL order can put a recently-done issue first; the open one must win
|
||||
m.searchIssues = []issue{doneIssue(), open}
|
||||
|
||||
retry, err := newNotifier(t, m).Notify(ctx(), alert(true))
|
||||
require.NoError(t, err)
|
||||
assert.False(t, retry)
|
||||
assert.Equal(t, 0, m.countPost("/issue")) // no duplicate create
|
||||
assert.Equal(t, 0, m.countPost("/transitions")) // open issue → no reopen
|
||||
assert.Equal(t, 1, m.countPuts())
|
||||
assert.Equal(t, 1, m.countPost("/comment"))
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
for _, r := range m.reqs {
|
||||
if r.method == http.MethodPut || strings.HasSuffix(r.path, "/comment") {
|
||||
assert.Contains(t, r.path, "KAN-2")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifyRetriesOn429(t *testing.T) {
|
||||
m := newMockJira(t)
|
||||
m.createStatus = http.StatusTooManyRequests
|
||||
retry, err := newNotifier(t, m).Notify(ctx(), alert(true))
|
||||
require.Error(t, err)
|
||||
assert.True(t, retry)
|
||||
}
|
||||
|
||||
func (m *mockJira) lastBody(t *testing.T, suffix string) map[string]any {
|
||||
t.Helper()
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
for i := len(m.reqs) - 1; i >= 0; i-- {
|
||||
if m.reqs[i].method == http.MethodPost && strings.HasSuffix(m.reqs[i].path, suffix) {
|
||||
return m.reqs[i].body
|
||||
}
|
||||
}
|
||||
t.Fatalf("no POST request to %s", suffix)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestNotifyRichDescriptionPanelAndLinks(t *testing.T) {
|
||||
m := newMockJira(t)
|
||||
a := alert(true)
|
||||
a.Labels[ruletypes.LabelRuleSource] = model.LabelValue("https://app.signoz.io/alerts?ruleId=1")
|
||||
a.Annotations[ruletypes.AnnotationRelatedLogs] = model.LabelValue("https://app.signoz.io/logs")
|
||||
|
||||
_, err := newNotifier(t, m).Notify(ctx(), a)
|
||||
require.NoError(t, err)
|
||||
|
||||
body := m.lastBody(t, "/issue")
|
||||
js, err := json.Marshal(body)
|
||||
require.NoError(t, err)
|
||||
s := string(js)
|
||||
assert.Contains(t, s, `"panel"`) // status panel present
|
||||
assert.Contains(t, s, `"error"`) // firing → error panel
|
||||
assert.Contains(t, s, "Open in SigNoz") // rule deep-link
|
||||
assert.Contains(t, s, "https://app.signoz.io/alerts?ruleId=1") // rule url
|
||||
assert.Contains(t, s, "View Related Logs") // related-logs deep-link
|
||||
assert.Contains(t, s, "Summary:") // labeled body section
|
||||
assert.Contains(t, s, "cpu high") // rendered annotation
|
||||
}
|
||||
|
||||
func TestNotifyCustomTemplateAnnotationsOverrideDefaults(t *testing.T) {
|
||||
m := newMockJira(t)
|
||||
a1 := alert(true)
|
||||
a1.Labels["service"] = "payment"
|
||||
a1.Labels["namespace"] = "ns-one"
|
||||
a1.Annotations[ruletypes.AnnotationTitleTemplate] = "High throughput for $service"
|
||||
a1.Annotations[ruletypes.AnnotationBodyTemplate] = "Firing in NS: $labels.namespace"
|
||||
a2 := alert(true)
|
||||
a2.Labels["service"] = "payment"
|
||||
a2.Labels["namespace"] = "ns-two"
|
||||
a2.Annotations[ruletypes.AnnotationTitleTemplate] = "High throughput for $service"
|
||||
a2.Annotations[ruletypes.AnnotationBodyTemplate] = "Firing in NS: $labels.namespace"
|
||||
|
||||
_, err := newNotifier(t, m).Notify(ctx(), a1, a2)
|
||||
require.NoError(t, err)
|
||||
|
||||
body := m.lastBody(t, "/issue")
|
||||
fields, ok := body["fields"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "High throughput for payment", fields["summary"])
|
||||
|
||||
js, err := json.Marshal(fields["description"])
|
||||
require.NoError(t, err)
|
||||
s := string(js)
|
||||
assert.Contains(t, s, "Firing in NS: ns-one")
|
||||
assert.Contains(t, s, "Firing in NS: ns-two")
|
||||
// per-alert custom bodies are separated by an ADF rule divider
|
||||
assert.Contains(t, s, `"rule"`)
|
||||
assert.NotContains(t, s, "Summary:") // default body template not used
|
||||
}
|
||||
|
||||
// Jira replaces labels wholesale on PUT, so the update must union in the
|
||||
// labels already on the issue or user-added ones get wiped.
|
||||
func TestNotifyUpdatePreservesUserAddedLabels(t *testing.T) {
|
||||
m := newMockJira(t)
|
||||
existing := openIssue()
|
||||
existing.Fields.Labels = []string{"user-added-label", "signoz-alert"}
|
||||
m.searchIssues = []issue{existing}
|
||||
|
||||
_, err := newNotifier(t, m).Notify(ctx(), alert(true))
|
||||
require.NoError(t, err)
|
||||
|
||||
search := m.lastBody(t, "/search/jql")
|
||||
assert.Contains(t, search["fields"], "labels")
|
||||
|
||||
m.mu.Lock()
|
||||
var putLabels []any
|
||||
for _, r := range m.reqs {
|
||||
if r.method == http.MethodPut {
|
||||
putLabels, _ = r.body["fields"].(map[string]any)["labels"].([]any)
|
||||
}
|
||||
}
|
||||
m.mu.Unlock()
|
||||
assert.Contains(t, putLabels, "user-added-label")
|
||||
assert.Contains(t, putLabels, "signoz-alert")
|
||||
assert.Equal(t, 1, strings.Count(fmt.Sprint(putLabels), "signoz-alert")) // no duplicates
|
||||
// the dedup label is re-asserted
|
||||
found := false
|
||||
for _, l := range putLabels {
|
||||
if s, ok := l.(string); ok && strings.HasPrefix(s, "ALERT{") {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
assert.True(t, found)
|
||||
}
|
||||
|
||||
func TestADFDocLen(t *testing.T) {
|
||||
text := func(s string) map[string]any { return map[string]any{"type": "text", "text": s} }
|
||||
para := func(children ...any) map[string]any {
|
||||
return map[string]any{"type": "paragraph", "content": children}
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
node any
|
||||
want int
|
||||
}{
|
||||
{"text node", text("hello"), 7}, // 5 utf16 + 2 overhead
|
||||
{"emoji counts utf16", text("🔴"), 4}, // 2 utf16 units + 2 overhead
|
||||
{"paragraph wraps text", para(text("hi")), 6}, // 2 + (2+2)
|
||||
{"link href counted", map[string]any{"type": "text", "text": "a", "marks": []any{map[string]any{"type": "link", "attrs": map[string]any{"href": "https://x"}}}}, 12}, // 1 + 9 href + 2
|
||||
{"non-map is zero", "junk", 0},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
assert.Equal(t, c.want, adfDocLen(c.node))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 30 fat custom bodies overflow Jira's description accounting (text + per-node
|
||||
// overhead); the built doc must be shrunk under the limit, never rejected.
|
||||
func TestNotifyDescriptionShrunkUnderJiraLimit(t *testing.T) {
|
||||
m := newMockJira(t)
|
||||
filler := strings.Repeat("This is a long runbook detail line used to inflate the alert body. ", 25)
|
||||
alerts := make([]*types.Alert, 0, 30)
|
||||
for i := range 30 {
|
||||
a := alert(true)
|
||||
a.Labels["service"] = model.LabelValue(strings.Repeat("s", 3) + string(rune('a'+i%26)))
|
||||
a.Annotations[ruletypes.AnnotationTitleTemplate] = "overflow probe"
|
||||
a.Annotations[ruletypes.AnnotationBodyTemplate] = model.LabelValue("**Alert in service** $labels.service\n\n" + filler)
|
||||
alerts = append(alerts, a)
|
||||
}
|
||||
|
||||
_, err := newNotifier(t, m).Notify(ctx(), alerts...)
|
||||
require.NoError(t, err)
|
||||
|
||||
body := m.lastBody(t, "/issue")
|
||||
fields, ok := body["fields"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
desc := fields["description"]
|
||||
assert.LessOrEqual(t, adfDocLen(desc), maxDescriptionLenRunes)
|
||||
|
||||
js, err := json.Marshal(desc)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(js), "FIRING") // status panel survives the shrink
|
||||
assert.Contains(t, string(js), "…") // body ends with the shrink marker
|
||||
}
|
||||
|
||||
func TestFiringSearchJQLHasReopenWindow(t *testing.T) {
|
||||
m := newMockJira(t)
|
||||
_, err := newNotifier(t, m).Notify(ctx(), alert(true))
|
||||
require.NoError(t, err)
|
||||
|
||||
body := m.lastBody(t, "/search/jql")
|
||||
jql, ok := body["jql"].(string)
|
||||
require.True(t, ok)
|
||||
// newNotifier uses a 3d window → 4320 minutes.
|
||||
assert.Contains(t, jql, "resolutiondate >= -4320m")
|
||||
}
|
||||
|
||||
func TestSelectTransition(t *testing.T) {
|
||||
ts := []jiraTransition{
|
||||
transition("41", "Done", "done"),
|
||||
transition("51", "Won't Do", "done"),
|
||||
transition("11", "To Do", "new"),
|
||||
}
|
||||
assert.Equal(t, "41", selectTransition(ts, true, "")) // first done-category
|
||||
assert.Equal(t, "51", selectTransition(ts, true, "Won't Do")) // named override
|
||||
assert.Equal(t, "11", selectTransition(ts, false, "")) // first non-done
|
||||
assert.Equal(t, "41", selectTransition(ts, true, "Nonexistent")) // bad override → fallback
|
||||
assert.Equal(t, "", selectTransition([]jiraTransition{transition("11", "To Do", "new")}, true, "")) // none → skip
|
||||
}
|
||||
|
||||
func TestResolveCloudID(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
handler http.HandlerFunc
|
||||
want string
|
||||
wantErr bool
|
||||
wantRetry bool
|
||||
}{
|
||||
{
|
||||
name: "success",
|
||||
handler: func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/_edge/tenant_info", r.URL.Path)
|
||||
_, _ = w.Write([]byte(`{"cloudId":"abc-123"}`))
|
||||
},
|
||||
want: "abc-123",
|
||||
},
|
||||
{
|
||||
name: "non-200 is not retryable",
|
||||
handler: func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) },
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "empty cloud id",
|
||||
handler: func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`{"cloudId":""}`)) },
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "bad json",
|
||||
handler: func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`not json`)) },
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
srv := httptest.NewServer(c.handler)
|
||||
defer srv.Close()
|
||||
|
||||
n, err := New(&alertmanagertypes.JiraReceiverConfig{Site: srv.URL, HTTPConfig: &commoncfg.HTTPClientConfig{}}, nil, slog.New(slog.DiscardHandler), nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
got, retry, err := n.resolveCloudID(context.Background())
|
||||
if c.wantErr {
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, c.wantRetry, retry)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, c.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
61
pkg/alertmanager/alertmanagernotify/jsmops/jsmops.go
Normal file
61
pkg/alertmanager/alertmanagernotify/jsmops/jsmops.go
Normal file
@@ -0,0 +1,61 @@
|
||||
// Copyright (c) 2026 SigNoz, Inc.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package jsmops delivers Jira Service Management Ops alerts by reusing the
|
||||
// Opsgenie notifier: JSM Ops is the ex-Opsgenie alert API, so we map the JSM
|
||||
// config onto config.OpsGenieConfig with APIURL pinned to the JSM native
|
||||
// integration-events gateway.
|
||||
package jsmops
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/url"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/opsgenie"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
"github.com/prometheus/alertmanager/template"
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
)
|
||||
|
||||
const (
|
||||
Integration = "jsmops"
|
||||
source = "SigNoz"
|
||||
)
|
||||
|
||||
// New builds an Opsgenie notifier pointed at the JSM native endpoint.
|
||||
// advancedFeatures enables the rich treatment: HTML body and a note timeline
|
||||
// (per fire and on resolve).
|
||||
func New(c *alertmanagertypes.JSMOpsReceiverConfig, t *template.Template, l *slog.Logger, templater alertmanagertypes.Templater, advancedFeatures bool) (*opsgenie.Notifier, error) {
|
||||
conf, err := toOpsGenieConfig(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return opsgenie.New(conf, t, l, templater, advancedFeatures)
|
||||
}
|
||||
|
||||
// toOpsGenieConfig maps the JSM config onto config.OpsGenieConfig with APIURL
|
||||
// pinned to the JSM native gateway.
|
||||
func toOpsGenieConfig(c *alertmanagertypes.JSMOpsReceiverConfig) (*config.OpsGenieConfig, error) {
|
||||
apiURL, err := url.Parse(alertmanagertypes.JSMOpsAPIBaseURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
httpConfig := c.HTTPConfig
|
||||
if httpConfig == nil {
|
||||
httpConfig = &commoncfg.HTTPClientConfig{}
|
||||
}
|
||||
|
||||
return &config.OpsGenieConfig{
|
||||
NotifierConfig: c.NotifierConfig,
|
||||
HTTPConfig: httpConfig,
|
||||
APIKey: c.APIKey,
|
||||
APIURL: &config.URL{URL: apiURL},
|
||||
Message: c.Message,
|
||||
Description: c.Description,
|
||||
Priority: c.Priority,
|
||||
Tags: c.Tags,
|
||||
Source: source,
|
||||
}, nil
|
||||
}
|
||||
41
pkg/alertmanager/alertmanagernotify/jsmops/jsmops_test.go
Normal file
41
pkg/alertmanager/alertmanagernotify/jsmops/jsmops_test.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package jsmops
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestToOpsGenieConfig(t *testing.T) {
|
||||
c := &alertmanagertypes.JSMOpsReceiverConfig{
|
||||
APIKey: "key-123",
|
||||
Message: "msg",
|
||||
Description: "desc",
|
||||
Priority: "P1",
|
||||
Tags: "signoz",
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
}
|
||||
|
||||
og, err := toOpsGenieConfig(c)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Trailing slash is required: the Opsgenie notifier appends "v2/alerts..."
|
||||
// with no separator, yielding /jsm/ops/integration/v2/alerts.
|
||||
assert.Equal(t, "https://api.atlassian.com/jsm/ops/integration/", og.APIURL.String())
|
||||
assert.Equal(t, "key-123", string(og.APIKey))
|
||||
assert.Equal(t, "msg", og.Message)
|
||||
assert.Equal(t, "desc", og.Description)
|
||||
assert.Equal(t, "P1", og.Priority)
|
||||
assert.Equal(t, "signoz", og.Tags)
|
||||
assert.Equal(t, source, og.Source)
|
||||
assert.Same(t, c.HTTPConfig, og.HTTPConfig)
|
||||
}
|
||||
|
||||
func TestToOpsGenieConfigNilHTTPConfig(t *testing.T) {
|
||||
og, err := toOpsGenieConfig(&alertmanagertypes.JSMOpsReceiverConfig{APIKey: "k"})
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, og.HTTPConfig)
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
@@ -32,8 +33,13 @@ const (
|
||||
Integration = "opsgenie"
|
||||
)
|
||||
|
||||
// https://docs.opsgenie.com/docs/alert-api - 130 characters meaning runes.
|
||||
const maxMessageLenRunes = 130
|
||||
// https://support.atlassian.com/opsgenie/docs/alert-fields/ - message 130,
|
||||
// description 15000, note 25000 runes.
|
||||
const (
|
||||
maxMessageLenRunes = 130
|
||||
maxDescriptionLenRunes = 15000
|
||||
maxNoteLenRunes = 25000
|
||||
)
|
||||
|
||||
// Notifier implements a Notifier for OpsGenie notifications.
|
||||
type Notifier struct {
|
||||
@@ -43,21 +49,29 @@ type Notifier struct {
|
||||
client *http.Client
|
||||
retrier *notify.Retrier
|
||||
templater alertmanagertypes.Templater
|
||||
// advancedFeatures bundles the JSM Ops enrichments: render the default body as
|
||||
// HTML (markdown -> HTML), and post a note per fire and on resolve to build an
|
||||
// immutable timeline. Off for plain OpsGenie. The alert-refresh-on-refire part
|
||||
// rides on the upstream UpdateAlerts config flag, set alongside this.
|
||||
advancedFeatures bool
|
||||
}
|
||||
|
||||
// New returns a new OpsGenie notifier.
|
||||
func New(c *config.OpsGenieConfig, t *template.Template, l *slog.Logger, templater alertmanagertypes.Templater, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
|
||||
// New returns a new OpsGenie notifier. advancedFeatures enables the JSM Ops
|
||||
// enrichments (HTML default body + a note timeline per fire and on resolve);
|
||||
// pass false for plain OpsGenie.
|
||||
func New(c *config.OpsGenieConfig, t *template.Template, l *slog.Logger, templater alertmanagertypes.Templater, advancedFeatures bool, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) {
|
||||
client, err := notify.NewClientWithTracing(*c.HTTPConfig, Integration, httpOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Notifier{
|
||||
conf: c,
|
||||
tmpl: t,
|
||||
logger: l,
|
||||
client: client,
|
||||
retrier: ¬ify.Retrier{RetryCodes: []int{http.StatusTooManyRequests}},
|
||||
templater: templater,
|
||||
conf: c,
|
||||
tmpl: t,
|
||||
logger: l,
|
||||
client: client,
|
||||
retrier: ¬ify.Retrier{RetryCodes: []int{http.StatusTooManyRequests}},
|
||||
templater: templater,
|
||||
advancedFeatures: advancedFeatures,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -94,6 +108,30 @@ type opsGenieUpdateDescriptionMessage struct {
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
type opsGenieAddNoteMessage struct {
|
||||
Note string `json:"note"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
// noteRequest builds a POST to the alert's notes endpoint (append-only timeline).
|
||||
func (n *Notifier) noteRequest(ctx context.Context, alias, note, source string) (*http.Request, error) {
|
||||
noteEndpointURL := n.conf.APIURL.Copy()
|
||||
noteEndpointURL.Path += fmt.Sprintf("v2/alerts/%s/notes", alias)
|
||||
q := noteEndpointURL.Query()
|
||||
q.Set("identifierType", "alias")
|
||||
noteEndpointURL.RawQuery = q.Encode()
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := json.NewEncoder(&buf).Encode(&opsGenieAddNoteMessage{Note: note, Source: source}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, err := http.NewRequest("POST", noteEndpointURL.String(), &buf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return req.WithContext(ctx), nil
|
||||
}
|
||||
|
||||
// Notify implements the Notifier interface.
|
||||
func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) {
|
||||
requests, retry, err := n.createRequests(ctx, as...)
|
||||
@@ -110,12 +148,24 @@ func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error)
|
||||
shouldRetry, err := n.retrier.Check(resp.StatusCode, resp.Body)
|
||||
notify.Drain(resp)
|
||||
if err != nil {
|
||||
// notes are enrichment; a permanently-failed note (e.g. the first-fire
|
||||
// note racing JSM's async alert create) must not fail the notification
|
||||
if !shouldRetry && isNoteRequest(req) {
|
||||
n.logger.WarnContext(ctx, "dropping failed note", slog.Int("status_code", resp.StatusCode), errors.Attr(err))
|
||||
continue
|
||||
}
|
||||
return shouldRetry, notify.NewErrorWithReason(notify.GetFailureReasonFromStatusCode(resp.StatusCode), err)
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// isNoteRequest reports whether req targets the notes endpoint, the only one
|
||||
// built by noteRequest.
|
||||
func isNoteRequest(req *http.Request) bool {
|
||||
return strings.HasSuffix(req.URL.Path, "/notes")
|
||||
}
|
||||
|
||||
// Like Split but filter out empty strings.
|
||||
func safeSplit(s, sep string) []string {
|
||||
a := strings.Split(strings.TrimSpace(s), sep)
|
||||
@@ -145,28 +195,13 @@ func (n *Notifier) prepareContent(ctx context.Context, alerts []*types.Alert) (s
|
||||
}
|
||||
|
||||
var description string
|
||||
if result.IsDefaultBody {
|
||||
if result.IsDefaultBody && !n.advancedFeatures {
|
||||
description = strings.Join(result.Body, "\n")
|
||||
} else {
|
||||
var b strings.Builder
|
||||
first := true
|
||||
for _, part := range result.Body {
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
rendered, renderErr := markdownrenderer.RenderHTML(part)
|
||||
if renderErr != nil {
|
||||
return "", "", renderErr
|
||||
}
|
||||
if !first {
|
||||
b.WriteString("<hr>")
|
||||
}
|
||||
b.WriteString("<div>")
|
||||
b.WriteString(rendered)
|
||||
b.WriteString("</div>")
|
||||
first = false
|
||||
description, err = buildHTMLDescription(result.Body, maxDescriptionLenRunes)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
description = b.String()
|
||||
}
|
||||
|
||||
title, truncated := notify.TruncateInRunes(result.Title, maxMessageLenRunes)
|
||||
@@ -174,9 +209,141 @@ func (n *Notifier) prepareContent(ctx context.Context, alerts []*types.Alert) (s
|
||||
n.logger.WarnContext(ctx, "Truncated message", slog.Int("max_runes", maxMessageLenRunes))
|
||||
}
|
||||
|
||||
// The API silently truncates over-limit descriptions, which would drop the
|
||||
// trailing SigNoz link; cap here with an ellipsis instead. The HTML path is
|
||||
// pre-fitted above, so this only ever cuts the plain-text default body.
|
||||
description, descTruncated := notify.TruncateInRunes(description, maxDescriptionLenRunes)
|
||||
if descTruncated {
|
||||
n.logger.WarnContext(ctx, "Truncated description", slog.Int("max_runes", maxDescriptionLenRunes))
|
||||
}
|
||||
|
||||
return title, description, nil
|
||||
}
|
||||
|
||||
const (
|
||||
// room reserved for the "+N more" trailer appended when parts are dropped.
|
||||
descriptionTrailerReserveRunes = 80
|
||||
// below this rendering budget a shrunk part carries no signal; drop it instead.
|
||||
minShrinkBudgetRunes = 64
|
||||
)
|
||||
|
||||
// buildHTMLDescription renders each markdown part to HTML (<div>-wrapped,
|
||||
// <hr>-joined) while keeping the total within budget runes. An over-budget part
|
||||
// is shrunk at the markdown level and re-rendered so the HTML stays well-formed;
|
||||
// fully dropped parts are summarized by a "+N more" trailer.
|
||||
func buildHTMLDescription(parts []string, budget int) (string, error) {
|
||||
rendering := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if part != "" {
|
||||
rendering = append(rendering, part)
|
||||
}
|
||||
}
|
||||
|
||||
budget -= descriptionTrailerReserveRunes
|
||||
var b strings.Builder
|
||||
used, included := 0, 0
|
||||
for _, part := range rendering {
|
||||
rendered, err := markdownrenderer.RenderHTML(part)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
overhead := len("<div></div>")
|
||||
if included > 0 {
|
||||
overhead += len("<hr>")
|
||||
}
|
||||
if used+overhead+utf8.RuneCountInString(rendered) > budget {
|
||||
rendered, err = shrinkMarkdownToFit(part, budget-used-overhead)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if rendered == "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
if included > 0 {
|
||||
b.WriteString("<hr>")
|
||||
}
|
||||
b.WriteString("<div>")
|
||||
b.WriteString(rendered)
|
||||
b.WriteString("</div>")
|
||||
used += overhead + utf8.RuneCountInString(rendered)
|
||||
included++
|
||||
}
|
||||
if dropped := len(rendering) - included; dropped > 0 {
|
||||
fmt.Fprintf(&b, "<hr><div><i>…and %d more alerts. Open in SigNoz for the full list.</i></div>", dropped)
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
// shrinkMarkdownToFit cuts markdown until its rendered HTML fits within budget
|
||||
// runes, returning "" when the budget is too small to carry anything useful.
|
||||
// Only the markdown is ever cut, never the rendered HTML, so goldmark always
|
||||
// emits balanced markup.
|
||||
func shrinkMarkdownToFit(md string, budget int) (string, error) {
|
||||
if budget < minShrinkBudgetRunes {
|
||||
return "", nil
|
||||
}
|
||||
for range 4 {
|
||||
rendered, err := markdownrenderer.RenderHTML(md)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
renderedLen := utf8.RuneCountInString(rendered)
|
||||
if renderedLen <= budget {
|
||||
return rendered, nil
|
||||
}
|
||||
runes := []rune(md)
|
||||
keep := len(runes) * budget / renderedLen * 9 / 10
|
||||
if keep >= len(runes) {
|
||||
keep = len(runes) - 1
|
||||
}
|
||||
if keep < minShrinkBudgetRunes {
|
||||
return "", nil
|
||||
}
|
||||
md = string(runes[:keep]) + "…"
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// prepareNote renders the same body template as plain text for a timeline note.
|
||||
// JSM Ops notes render neither HTML nor markdown, so links flatten to
|
||||
// "text (url)" and all markers are stripped.
|
||||
func (n *Notifier) prepareNote(ctx context.Context, alerts []*types.Alert) (string, error) {
|
||||
customTitle, customBody := alertmanagertemplate.ExtractTemplatesFromAnnotations(alerts)
|
||||
result, err := n.templater.Expand(ctx, alertmanagertypes.ExpandRequest{
|
||||
TitleTemplate: customTitle,
|
||||
BodyTemplate: customBody,
|
||||
DefaultTitleTemplate: n.conf.Message,
|
||||
DefaultBodyTemplate: n.conf.Description,
|
||||
}, alerts)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
first := true
|
||||
for _, part := range result.Body {
|
||||
text, renderErr := markdownrenderer.RenderPlainText(part)
|
||||
if renderErr != nil {
|
||||
return "", renderErr
|
||||
}
|
||||
if text = strings.TrimSpace(text); text == "" {
|
||||
continue
|
||||
}
|
||||
if !first {
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
b.WriteString(text)
|
||||
first = false
|
||||
}
|
||||
|
||||
note, truncated := notify.TruncateInRunes(b.String(), maxNoteLenRunes)
|
||||
if truncated {
|
||||
n.logger.WarnContext(ctx, "Truncated note", slog.Int("max_runes", maxNoteLenRunes))
|
||||
}
|
||||
return note, nil
|
||||
}
|
||||
|
||||
// Create requests for a list of alerts.
|
||||
func (n *Notifier) createRequests(ctx context.Context, as ...*types.Alert) ([]*http.Request, bool, error) {
|
||||
key, err := notify.ExtractGroupKey(ctx)
|
||||
@@ -206,6 +373,21 @@ func (n *Notifier) createRequests(ctx context.Context, as ...*types.Alert) ([]*h
|
||||
)
|
||||
switch alerts.Status() {
|
||||
case model.AlertResolved:
|
||||
// Post the resolved snapshot to the timeline before closing (closed alerts
|
||||
// reject notes), so the note lands first.
|
||||
if n.advancedFeatures {
|
||||
note, err := n.prepareNote(ctx, as)
|
||||
if err != nil {
|
||||
n.logger.ErrorContext(ctx, "failed to prepare notification content", errors.Attr(err))
|
||||
return nil, false, err
|
||||
}
|
||||
noteReq, err := n.noteRequest(ctx, alias, note, tmpl(n.conf.Source))
|
||||
if err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
requests = append(requests, noteReq)
|
||||
}
|
||||
|
||||
resolvedEndpointURL := n.conf.APIURL.Copy()
|
||||
resolvedEndpointURL.Path += fmt.Sprintf("v2/alerts/%s/close", alias)
|
||||
q := resolvedEndpointURL.Query()
|
||||
@@ -322,6 +504,21 @@ func (n *Notifier) createRequests(ctx context.Context, as ...*types.Alert) ([]*h
|
||||
}
|
||||
requests = append(requests, req.WithContext(ctx))
|
||||
}
|
||||
|
||||
// Append this fire's snapshot to the timeline (every fire, including the
|
||||
// first, so no datapoint is lost when the description is overwritten).
|
||||
// Notes are plain text, so this uses the plain-text render, not the HTML body.
|
||||
if n.advancedFeatures {
|
||||
note, err := n.prepareNote(ctx, as)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
noteReq, err := n.noteRequest(ctx, alias, note, tmpl(n.conf.Source))
|
||||
if err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
requests = append(requests, noteReq)
|
||||
}
|
||||
}
|
||||
|
||||
var apiKey string
|
||||
|
||||
@@ -6,14 +6,18 @@ package opsgenie
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagertemplate"
|
||||
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
|
||||
@@ -44,6 +48,7 @@ func TestOpsGenieRetry(t *testing.T) {
|
||||
tmpl,
|
||||
promslog.NewNopLogger(),
|
||||
newTestTemplater(tmpl),
|
||||
false,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -69,6 +74,7 @@ func TestOpsGenieRedactedURL(t *testing.T) {
|
||||
tmpl,
|
||||
promslog.NewNopLogger(),
|
||||
newTestTemplater(tmpl),
|
||||
false,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -96,6 +102,7 @@ func TestGettingOpsGegineApikeyFromFile(t *testing.T) {
|
||||
tmpl,
|
||||
promslog.NewNopLogger(),
|
||||
newTestTemplater(tmpl),
|
||||
false,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -216,7 +223,7 @@ func TestOpsGenie(t *testing.T) {
|
||||
},
|
||||
} {
|
||||
t.Run(tc.title, func(t *testing.T) {
|
||||
notifier, err := New(tc.cfg, tmpl, logger, newTestTemplater(tmpl))
|
||||
notifier, err := New(tc.cfg, tmpl, logger, newTestTemplater(tmpl), false)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := context.Background()
|
||||
@@ -292,7 +299,7 @@ func TestOpsGenieWithUpdate(t *testing.T) {
|
||||
APIURL: &config.URL{URL: u},
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
}
|
||||
notifierWithUpdate, err := New(&opsGenieConfigWithUpdate, tmpl, promslog.NewNopLogger(), newTestTemplater(tmpl))
|
||||
notifierWithUpdate, err := New(&opsGenieConfigWithUpdate, tmpl, promslog.NewNopLogger(), newTestTemplater(tmpl), false)
|
||||
alert := &types.Alert{
|
||||
Alert: model.Alert{
|
||||
StartsAt: time.Now(),
|
||||
@@ -324,6 +331,111 @@ func TestOpsGenieWithUpdate(t *testing.T) {
|
||||
assert.JSONEq(t, `{"description":"new description"}`, body2)
|
||||
}
|
||||
|
||||
func TestOpsGenieAdvancedFeatures(t *testing.T) {
|
||||
u, err := url.Parse("https://test-opsgenie-url")
|
||||
require.NoError(t, err)
|
||||
tmpl := test.CreateTmpl(t)
|
||||
ctx := notify.WithGroupKey(context.Background(), "1")
|
||||
key, _ := notify.ExtractGroupKey(ctx)
|
||||
alias := key.Hash()
|
||||
|
||||
cfg := &config.OpsGenieConfig{
|
||||
NotifierConfig: config.NotifierConfig{VSendResolved: true},
|
||||
Message: `{{ .CommonLabels.Message }}`,
|
||||
Description: `{{ .CommonLabels.Description }}`,
|
||||
UpdateAlerts: true,
|
||||
APIKey: "k",
|
||||
APIURL: &config.URL{URL: u},
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
}
|
||||
notifier, err := New(cfg, tmpl, promslog.NewNopLogger(), newTestTemplater(tmpl), true)
|
||||
require.NoError(t, err)
|
||||
|
||||
firing := &types.Alert{Alert: model.Alert{
|
||||
StartsAt: time.Now(),
|
||||
EndsAt: time.Now().Add(time.Hour),
|
||||
Labels: model.LabelSet{"Message": "m", "Description": "**Alert:** d [View](https://s.io/a)"},
|
||||
}}
|
||||
|
||||
// Fire: create + update message + update description + a timeline note.
|
||||
reqs, _, err := notifier.createRequests(ctx, firing)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, reqs, 4)
|
||||
assert.Equal(t, "https://test-opsgenie-url/v2/alerts", reqs[0].URL.String())
|
||||
assert.Equal(t, fmt.Sprintf("https://test-opsgenie-url/v2/alerts/%s/notes?identifierType=alias", alias), reqs[3].URL.String())
|
||||
assert.Equal(t, http.MethodPost, reqs[3].Method)
|
||||
|
||||
// the note body is the plain-text render: markers stripped, link flattened
|
||||
var noteMsg opsGenieAddNoteMessage
|
||||
require.NoError(t, json.Unmarshal([]byte(readBody(t, reqs[3])), ¬eMsg))
|
||||
assert.Equal(t, "Alert: d View (https://s.io/a)", noteMsg.Note)
|
||||
|
||||
// Resolve: note posted before the close.
|
||||
resolved := &types.Alert{Alert: model.Alert{
|
||||
StartsAt: time.Now().Add(-time.Hour),
|
||||
EndsAt: time.Now().Add(-time.Minute),
|
||||
Labels: model.LabelSet{"Message": "m", "Description": "d"},
|
||||
}}
|
||||
reqs, _, err = notifier.createRequests(ctx, resolved)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, reqs, 2)
|
||||
assert.Equal(t, fmt.Sprintf("https://test-opsgenie-url/v2/alerts/%s/notes?identifierType=alias", alias), reqs[0].URL.String())
|
||||
assert.Equal(t, fmt.Sprintf("https://test-opsgenie-url/v2/alerts/%s/close?identifierType=alias", alias), reqs[1].URL.String())
|
||||
}
|
||||
|
||||
func TestOpsGenieNotifyBestEffortNote(t *testing.T) {
|
||||
tmpl := test.CreateTmpl(t)
|
||||
ctx := notify.WithGroupKey(context.Background(), "1")
|
||||
|
||||
firing := &types.Alert{Alert: model.Alert{
|
||||
StartsAt: time.Now(),
|
||||
EndsAt: time.Now().Add(time.Hour),
|
||||
Labels: model.LabelSet{"Message": "m", "Description": "d"},
|
||||
}}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
createStatus int
|
||||
noteStatus int
|
||||
wantErr bool
|
||||
wantRetry bool
|
||||
}{
|
||||
{name: "note_404_is_dropped", createStatus: http.StatusAccepted, noteStatus: http.StatusNotFound, wantErr: false, wantRetry: true},
|
||||
{name: "note_429_still_retries", createStatus: http.StatusAccepted, noteStatus: http.StatusTooManyRequests, wantErr: true, wantRetry: true},
|
||||
{name: "create_404_still_fails", createStatus: http.StatusNotFound, noteStatus: http.StatusAccepted, wantErr: true, wantRetry: false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasSuffix(r.URL.Path, "/notes") {
|
||||
w.WriteHeader(tc.noteStatus)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(tc.createStatus)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
u, err := url.Parse(srv.URL)
|
||||
require.NoError(t, err)
|
||||
notifier, err := New(&config.OpsGenieConfig{
|
||||
Message: `{{ .CommonLabels.Message }}`,
|
||||
Description: `{{ .CommonLabels.Description }}`,
|
||||
APIKey: "k",
|
||||
APIURL: &config.URL{URL: u},
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
}, tmpl, promslog.NewNopLogger(), newTestTemplater(tmpl), true)
|
||||
require.NoError(t, err)
|
||||
|
||||
retry, err := notifier.Notify(ctx, firing)
|
||||
if tc.wantErr {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
assert.Equal(t, tc.wantRetry, retry)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpsGenieApiKeyFile(t *testing.T) {
|
||||
u, err := url.Parse("https://test-opsgenie-url")
|
||||
require.NoError(t, err)
|
||||
@@ -335,7 +447,7 @@ func TestOpsGenieApiKeyFile(t *testing.T) {
|
||||
APIURL: &config.URL{URL: u},
|
||||
HTTPConfig: &commoncfg.HTTPClientConfig{},
|
||||
}
|
||||
notifierWithUpdate, err := New(&opsGenieConfigWithUpdate, tmpl, promslog.NewNopLogger(), newTestTemplater(tmpl))
|
||||
notifierWithUpdate, err := New(&opsGenieConfigWithUpdate, tmpl, promslog.NewNopLogger(), newTestTemplater(tmpl), false)
|
||||
|
||||
require.NoError(t, err)
|
||||
requests, _, err := notifierWithUpdate.createRequests(ctx)
|
||||
@@ -437,6 +549,109 @@ func TestPrepareContent(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestShrinkMarkdownToFit(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
md string
|
||||
budget int
|
||||
wantEmpty bool
|
||||
}{
|
||||
{"fits untouched", "**bold** text", 1000, false},
|
||||
{"shrinks to fit", strings.Repeat("lorem ipsum ", 500), 1000, false},
|
||||
{"budget too small", strings.Repeat("lorem ipsum ", 500), 10, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got, err := shrinkMarkdownToFit(c.md, c.budget)
|
||||
require.NoError(t, err)
|
||||
if c.wantEmpty {
|
||||
assert.Empty(t, got)
|
||||
return
|
||||
}
|
||||
assert.NotEmpty(t, got)
|
||||
assert.LessOrEqual(t, utf8.RuneCountInString(got), c.budget)
|
||||
assert.Equal(t, strings.Count(got, "<p>"), strings.Count(got, "</p>"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHTMLDescriptionOverflow(t *testing.T) {
|
||||
bigPart := strings.Repeat("alpha beta gamma ", 100)
|
||||
cases := []struct {
|
||||
name string
|
||||
parts []string
|
||||
budget int
|
||||
wantTrailer bool
|
||||
}{
|
||||
{"all parts fit", []string{"**a**", "**b**"}, maxDescriptionLenRunes, false},
|
||||
{"empty parts skipped", []string{"", "hello", ""}, maxDescriptionLenRunes, false},
|
||||
{"overflow drops parts with trailer", repeatParts(bigPart, 12), maxDescriptionLenRunes, true},
|
||||
{"single huge part shrunk without trailer", []string{strings.Repeat(bigPart, 20)}, maxDescriptionLenRunes, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got, err := buildHTMLDescription(c.parts, c.budget)
|
||||
require.NoError(t, err)
|
||||
assert.LessOrEqual(t, utf8.RuneCountInString(got), c.budget)
|
||||
assert.Equal(t, strings.Count(got, "<div>"), strings.Count(got, "</div>"))
|
||||
assert.True(t, strings.HasSuffix(got, "</div>"))
|
||||
if c.wantTrailer {
|
||||
assert.Regexp(t, `…and \d+ more alerts\. Open in SigNoz for the full list\.`, got)
|
||||
} else {
|
||||
assert.NotContains(t, got, "more alerts")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// prepareContent end-to-end: 40 custom-template alerts overflow the description
|
||||
// budget yet the posted HTML stays within limits and well-formed.
|
||||
func TestPrepareContentDescriptionOverflow(t *testing.T) {
|
||||
tmpl := test.CreateTmpl(t)
|
||||
notifier := &Notifier{
|
||||
conf: &config.OpsGenieConfig{
|
||||
Message: `{{ .CommonLabels.alertname }}`,
|
||||
Description: `{{ .CommonLabels.alertname }}`,
|
||||
},
|
||||
tmpl: tmpl,
|
||||
logger: promslog.NewNopLogger(),
|
||||
templater: newTestTemplater(tmpl),
|
||||
advancedFeatures: true,
|
||||
}
|
||||
|
||||
bodyTemplate := "**Alert in** $labels.namespace\n\n" + strings.Repeat("detail line for the runbook ", 30)
|
||||
alerts := make([]*types.Alert, 0, 40)
|
||||
for i := range 40 {
|
||||
alerts = append(alerts, &types.Alert{
|
||||
Alert: model.Alert{
|
||||
Labels: model.LabelSet{
|
||||
"alertname": "overflow",
|
||||
"namespace": model.LabelValue(fmt.Sprintf("ns-%d", i)),
|
||||
},
|
||||
Annotations: model.LabelSet{
|
||||
ruletypes.AnnotationBodyTemplate: model.LabelValue(bodyTemplate),
|
||||
},
|
||||
StartsAt: time.Now(),
|
||||
EndsAt: time.Now().Add(time.Hour),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
_, desc, err := notifier.prepareContent(notify.WithGroupKey(context.Background(), "1"), alerts)
|
||||
require.NoError(t, err)
|
||||
assert.LessOrEqual(t, utf8.RuneCountInString(desc), maxDescriptionLenRunes)
|
||||
assert.Equal(t, strings.Count(desc, "<div>"), strings.Count(desc, "</div>"))
|
||||
assert.Regexp(t, `…and \d+ more alerts\. Open in SigNoz for the full list\.`, desc)
|
||||
}
|
||||
|
||||
func repeatParts(part string, n int) []string {
|
||||
parts := make([]string, n)
|
||||
for i := range parts {
|
||||
parts[i] = part
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
func readBody(t *testing.T, r *http.Request) string {
|
||||
t.Helper()
|
||||
body, err := io.ReadAll(r.Body)
|
||||
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/email"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/googlechat"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/jira"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/jsmops"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/msteamsv2"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/opsgenie"
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager/alertmanagernotify/pagerduty"
|
||||
@@ -26,6 +28,8 @@ var customNotifierIntegrations = []string{
|
||||
slack.Integration,
|
||||
msteamsv2.Integration,
|
||||
googlechat.Integration,
|
||||
jira.Integration,
|
||||
jsmops.Integration,
|
||||
}
|
||||
|
||||
func NewReceiverIntegrations(nc *alertmanagertypes.Receiver, tmpl *template.Template, logger *slog.Logger, templater alertmanagertypes.Templater) ([]notify.Integration, error) {
|
||||
@@ -66,7 +70,7 @@ func NewReceiverIntegrations(nc *alertmanagertypes.Receiver, tmpl *template.Temp
|
||||
add(pagerduty.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return pagerduty.New(c, tmpl, l, templater) })
|
||||
}
|
||||
for i, c := range nc.OpsGenieConfigs {
|
||||
add(opsgenie.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return opsgenie.New(c, tmpl, l, templater) })
|
||||
add(opsgenie.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return opsgenie.New(c, tmpl, l, templater, false) })
|
||||
}
|
||||
for i, c := range nc.SlackConfigs {
|
||||
add(slack.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) { return slack.New(c, tmpl, l, templater) })
|
||||
@@ -81,6 +85,16 @@ func NewReceiverIntegrations(nc *alertmanagertypes.Receiver, tmpl *template.Temp
|
||||
return googlechat.New(c, tmpl, l, templater)
|
||||
})
|
||||
}
|
||||
for i, c := range nc.JiraConfigs {
|
||||
add(jira.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) {
|
||||
return jira.New(c, tmpl, l, templater)
|
||||
})
|
||||
}
|
||||
for i, c := range nc.JSMOpsConfigs {
|
||||
add(jsmops.Integration, i, c, func(l *slog.Logger) (notify.Notifier, error) {
|
||||
return jsmops.New(c, tmpl, l, templater, true)
|
||||
})
|
||||
}
|
||||
|
||||
if errs.Len() > 0 {
|
||||
return nil, &errs
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -33,6 +33,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"
|
||||
@@ -76,11 +77,13 @@ type provider struct {
|
||||
ruleStateHistoryHandler rulestatehistory.Handler
|
||||
spanMapperHandler spanmapper.Handler
|
||||
alertmanagerHandler alertmanager.Handler
|
||||
prometheusHandler prometheus.Handler
|
||||
traceDetailHandler tracedetail.Handler
|
||||
rulerHandler ruler.Handler
|
||||
llmPricingRuleHandler llmpricingrule.Handler
|
||||
statsHandler statsreporter.Handler
|
||||
savedViewHandler savedview.Handler
|
||||
quickFilterModule quickfilter.Module
|
||||
quickFilterHandler quickfilter.Handler
|
||||
}
|
||||
|
||||
@@ -115,11 +118,13 @@ func NewFactory(
|
||||
ruleStateHistoryHandler rulestatehistory.Handler,
|
||||
spanMapperHandler spanmapper.Handler,
|
||||
alertmanagerHandler alertmanager.Handler,
|
||||
prometheusHandler prometheus.Handler,
|
||||
llmPricingRuleHandler llmpricingrule.Handler,
|
||||
traceDetailHandler tracedetail.Handler,
|
||||
rulerHandler ruler.Handler,
|
||||
statsHandler statsreporter.Handler,
|
||||
savedViewHandler savedview.Handler,
|
||||
quickFilterModule quickfilter.Module,
|
||||
quickFilterHandler quickfilter.Handler,
|
||||
) factory.ProviderFactory[apiserver.APIServer, apiserver.Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("signoz"), func(ctx context.Context, providerSettings factory.ProviderSettings, config apiserver.Config) (apiserver.APIServer, error) {
|
||||
@@ -157,11 +162,13 @@ func NewFactory(
|
||||
ruleStateHistoryHandler,
|
||||
spanMapperHandler,
|
||||
alertmanagerHandler,
|
||||
prometheusHandler,
|
||||
llmPricingRuleHandler,
|
||||
traceDetailHandler,
|
||||
rulerHandler,
|
||||
statsHandler,
|
||||
savedViewHandler,
|
||||
quickFilterModule,
|
||||
quickFilterHandler,
|
||||
)
|
||||
})
|
||||
@@ -201,11 +208,13 @@ func newProvider(
|
||||
ruleStateHistoryHandler rulestatehistory.Handler,
|
||||
spanMapperHandler spanmapper.Handler,
|
||||
alertmanagerHandler alertmanager.Handler,
|
||||
prometheusHandler prometheus.Handler,
|
||||
llmPricingRuleHandler llmpricingrule.Handler,
|
||||
traceDetailHandler tracedetail.Handler,
|
||||
rulerHandler ruler.Handler,
|
||||
statsHandler statsreporter.Handler,
|
||||
savedViewHandler savedview.Handler,
|
||||
quickFilterModule quickfilter.Module,
|
||||
quickFilterHandler quickfilter.Handler,
|
||||
) (apiserver.APIServer, error) {
|
||||
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/apiserver/signozapiserver")
|
||||
@@ -244,11 +253,13 @@ func newProvider(
|
||||
ruleStateHistoryHandler: ruleStateHistoryHandler,
|
||||
spanMapperHandler: spanMapperHandler,
|
||||
alertmanagerHandler: alertmanagerHandler,
|
||||
prometheusHandler: prometheusHandler,
|
||||
traceDetailHandler: traceDetailHandler,
|
||||
rulerHandler: rulerHandler,
|
||||
llmPricingRuleHandler: llmPricingRuleHandler,
|
||||
statsHandler: statsHandler,
|
||||
savedViewHandler: savedViewHandler,
|
||||
quickFilterModule: quickFilterModule,
|
||||
quickFilterHandler: quickFilterHandler,
|
||||
}
|
||||
|
||||
@@ -346,6 +357,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
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
package signozapiserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/quickfiltertypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
@@ -17,10 +20,10 @@ func (provider *provider) addQuickFilterRoutes(router *mux.Router) error {
|
||||
ID: "ListQuickFilters",
|
||||
Tags: []string{"quick_filter"},
|
||||
Summary: "List quick filters",
|
||||
Description: "Returns the org's quick filters for every signal, each filter as a telemetry field key.",
|
||||
Description: "Returns the org's quick filters for every source, each filter as a telemetry field key.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new([]*quickfiltertypes.SignalFilters),
|
||||
Response: new([]*quickfiltertypes.SourceFilters),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
@@ -37,16 +40,16 @@ func (provider *provider) addQuickFilterRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/quick_filters/{signal_name}", handler.New(
|
||||
if err := router.Handle("/api/v2/quick_filters/{source}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.quickFilterHandler.GetQuickFiltersV2, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetQuickFilters",
|
||||
Tags: []string{"quick_filter"},
|
||||
Summary: "Get a signal's quick filters",
|
||||
Description: "Returns the org's quick filters for one signal, each filter as a telemetry field key.",
|
||||
Summary: "Get a source's quick filters",
|
||||
Description: "Returns the org's quick filters for one source, each filter as a telemetry field key.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(quickfiltertypes.SignalFilters),
|
||||
Response: new(quickfiltertypes.SourceFilters),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
@@ -57,19 +60,20 @@ func (provider *provider) addQuickFilterRoutes(router *mux.Router) error {
|
||||
Resource: coretypes.ResourceMetaResourceQuickFilter,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
ID: coretypes.PathParam("source"),
|
||||
Selector: provider.quickFilterSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/quick_filters", handler.New(
|
||||
if err := router.Handle("/api/v2/quick_filters/{source}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.quickFilterHandler.UpdateQuickFiltersV2, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "UpdateQuickFilters",
|
||||
Tags: []string{"quick_filter"},
|
||||
Summary: "Update quick filters",
|
||||
Description: "Replaces the org's quick filters for the signal named in the body.",
|
||||
Description: "Replaces the org's quick filters for the source named in the path.",
|
||||
Request: new(quickfiltertypes.UpdatableQuickFilters),
|
||||
RequestContentType: "application/json",
|
||||
Response: nil,
|
||||
@@ -83,7 +87,8 @@ func (provider *provider) addQuickFilterRoutes(router *mux.Router) error {
|
||||
Resource: coretypes.ResourceMetaResourceQuickFilter,
|
||||
Verb: coretypes.VerbUpdate,
|
||||
Category: coretypes.ActionCategoryConfigurationChange,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
ID: coretypes.PathParam("source"),
|
||||
Selector: provider.quickFilterSelector,
|
||||
}),
|
||||
)).Methods(http.MethodPut).GetError(); err != nil {
|
||||
return err
|
||||
@@ -91,3 +96,25 @@ func (provider *provider) addQuickFilterRoutes(router *mux.Router) error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (provider *provider) quickFilterSelector(ctx context.Context, resource coretypes.Resource, source string, orgID valuer.UUID) ([]coretypes.Selector, error) {
|
||||
validatedSource, err := quickfiltertypes.NewSource(source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// A source can have no stored row yet: GET serves it as empty and PUT
|
||||
// creates it, so only the wildcard grant applies until the row exists.
|
||||
quickFilter, err := provider.quickFilterModule.Get(ctx, orgID, validatedSource)
|
||||
if err != nil {
|
||||
if errors.Ast(err, errors.TypeNotFound) {
|
||||
return []coretypes.Selector{resource.Type().MustSelector(coretypes.WildCardSelectorString)}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return []coretypes.Selector{
|
||||
resource.Type().MustSelector(quickFilter.ID.StringValue()),
|
||||
resource.Type().MustSelector(coretypes.WildCardSelectorString),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -23,18 +23,168 @@ func NewHandler(module quickfilter.Module) quickfilter.Handler {
|
||||
return &handler{module: module}
|
||||
}
|
||||
|
||||
// legacySignalFilters is the v1 API shape: filters as v3 attribute keys.
|
||||
type legacySignalFilters struct {
|
||||
Signal quickfiltertypes.Signal `json:"signal"`
|
||||
// legacySourceFilters is the v1 API shape: filters as v3 attribute keys,
|
||||
// with the source still spelled "signal" on the wire.
|
||||
type legacySourceFilters struct {
|
||||
Source quickfiltertypes.Source `json:"signal"`
|
||||
Filters []v3.AttributeKey `json:"filters"`
|
||||
}
|
||||
|
||||
func (handler *handler) GetQuickFilters(rw http.ResponseWriter, r *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
filters, err := handler.module.GetQuickFilters(r.Context(), valuer.MustNewUUID(claims.OrgID), quickfiltertypes.Source{})
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
legacyFilters := make([]*legacySourceFilters, 0, len(filters))
|
||||
for _, sourceFilters := range filters {
|
||||
legacyFilters = append(legacyFilters, newLegacySourceFilters(sourceFilters))
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, legacyFilters)
|
||||
}
|
||||
|
||||
func (handler *handler) GetSourceFilters(rw http.ResponseWriter, r *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
source := mux.Vars(r)["signal"]
|
||||
validatedSource, err := quickfiltertypes.NewSource(source)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
filters, err := handler.module.GetQuickFilters(r.Context(), valuer.MustNewUUID(claims.OrgID), validatedSource)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, newLegacySourceFilters(handler.sourceFiltersOrEmpty(filters, validatedSource)))
|
||||
}
|
||||
|
||||
func (handler *handler) UpdateQuickFilters(rw http.ResponseWriter, r *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
var req legacySourceFilters
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
fieldKeys, err := newTelemetryFieldKeysFromLegacy(req.Source, req.Filters)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.module.UpsertQuickFilters(r.Context(), valuer.MustNewUUID(claims.OrgID), req.Source, fieldKeys)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
func (handler *handler) ListQuickFiltersV2(rw http.ResponseWriter, r *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
filters, err := handler.module.GetQuickFilters(r.Context(), valuer.MustNewUUID(claims.OrgID), quickfiltertypes.Source{})
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, filters)
|
||||
}
|
||||
|
||||
func (handler *handler) UpdateQuickFiltersV2(rw http.ResponseWriter, r *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
source := mux.Vars(r)["source"]
|
||||
validatedSource, err := quickfiltertypes.NewSource(source)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
var req quickfiltertypes.UpdatableQuickFilters
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.module.UpsertQuickFilters(r.Context(), valuer.MustNewUUID(claims.OrgID), validatedSource, req.Filters)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
func (handler *handler) GetQuickFiltersV2(rw http.ResponseWriter, r *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
source := mux.Vars(r)["source"]
|
||||
validatedSource, err := quickfiltertypes.NewSource(source)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
filters, err := handler.module.GetQuickFilters(r.Context(), valuer.MustNewUUID(claims.OrgID), validatedSource)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, handler.sourceFiltersOrEmpty(filters, validatedSource))
|
||||
}
|
||||
|
||||
// sourceFiltersOrEmpty keeps the single-source response contract: a source
|
||||
// with no stored filters is served as an empty filter list, not an error.
|
||||
func (handler *handler) sourceFiltersOrEmpty(filters []*quickfiltertypes.SourceFilters, source quickfiltertypes.Source) *quickfiltertypes.SourceFilters {
|
||||
if len(filters) == 0 {
|
||||
return quickfiltertypes.NewSourceFiltersFromSource(source)
|
||||
}
|
||||
return filters[0]
|
||||
}
|
||||
|
||||
// newTelemetryFieldKeysFromLegacy converts a v1 write payload with the same
|
||||
// normalizations as the storage migration: alias contexts, numerics to number.
|
||||
// The v1 shape carries no per filter signal, so meter keys get it restored.
|
||||
func newTelemetryFieldKeysFromLegacy(signal quickfiltertypes.Signal, filters []v3.AttributeKey) ([]telemetrytypes.TelemetryFieldKey, error) {
|
||||
func newTelemetryFieldKeysFromLegacy(source quickfiltertypes.Source, filters []v3.AttributeKey) ([]telemetrytypes.TelemetryFieldKey, error) {
|
||||
var fieldSignal telemetrytypes.Signal
|
||||
if signal == quickfiltertypes.SignalMeter {
|
||||
if source == quickfiltertypes.SourceMeter {
|
||||
fieldSignal = telemetrytypes.SignalMetrics
|
||||
}
|
||||
|
||||
@@ -68,11 +218,11 @@ func newTelemetryFieldKeysFromLegacy(signal quickfiltertypes.Signal, filters []v
|
||||
return fieldKeys, nil
|
||||
}
|
||||
|
||||
// newLegacySignalFiltersFromSignalFilters renders stored telemetry field keys
|
||||
// newLegacySourceFilters renders stored telemetry field keys
|
||||
// back into the v1 shape, restoring the legacy spellings v1 clients expect.
|
||||
func newLegacySignalFiltersFromSignalFilters(signalFilters *quickfiltertypes.SignalFilters) *legacySignalFilters {
|
||||
filters := make([]v3.AttributeKey, 0, len(signalFilters.Filters))
|
||||
for _, fieldKey := range signalFilters.Filters {
|
||||
func newLegacySourceFilters(sourceFilters *quickfiltertypes.SourceFilters) *legacySourceFilters {
|
||||
filters := make([]v3.AttributeKey, 0, len(sourceFilters.Filters))
|
||||
for _, fieldKey := range sourceFilters.Filters {
|
||||
// Only tag and resource exist in the v3 enum; other contexts render as
|
||||
// unspecified so v1 clients never see spellings their queries can't use.
|
||||
var attributeType v3.AttributeKeyType
|
||||
@@ -100,141 +250,8 @@ func newLegacySignalFiltersFromSignalFilters(signalFilters *quickfiltertypes.Sig
|
||||
})
|
||||
}
|
||||
|
||||
return &legacySignalFilters{
|
||||
Signal: signalFilters.Signal,
|
||||
return &legacySourceFilters{
|
||||
Source: sourceFilters.Source,
|
||||
Filters: filters,
|
||||
}
|
||||
}
|
||||
|
||||
func (handler *handler) GetQuickFilters(rw http.ResponseWriter, r *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
filters, err := handler.module.GetQuickFilters(r.Context(), valuer.MustNewUUID(claims.OrgID), quickfiltertypes.Signal{})
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
legacyFilters := make([]*legacySignalFilters, 0, len(filters))
|
||||
for _, signalFilters := range filters {
|
||||
legacyFilters = append(legacyFilters, newLegacySignalFiltersFromSignalFilters(signalFilters))
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, legacyFilters)
|
||||
}
|
||||
|
||||
func (handler *handler) GetSignalFilters(rw http.ResponseWriter, r *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
signal := mux.Vars(r)["signal"]
|
||||
validatedSignal, err := quickfiltertypes.NewSignal(signal)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
filters, err := handler.module.GetQuickFilters(r.Context(), valuer.MustNewUUID(claims.OrgID), validatedSignal)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, newLegacySignalFiltersFromSignalFilters(filters[0]))
|
||||
}
|
||||
|
||||
func (handler *handler) UpdateQuickFilters(rw http.ResponseWriter, r *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
var req legacySignalFilters
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
fieldKeys, err := newTelemetryFieldKeysFromLegacy(req.Signal, req.Filters)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.module.UpsertQuickFilters(r.Context(), valuer.MustNewUUID(claims.OrgID), req.Signal, fieldKeys)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
func (handler *handler) ListQuickFiltersV2(rw http.ResponseWriter, r *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
filters, err := handler.module.GetQuickFilters(r.Context(), valuer.MustNewUUID(claims.OrgID), quickfiltertypes.Signal{})
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, filters)
|
||||
}
|
||||
|
||||
func (handler *handler) UpdateQuickFiltersV2(rw http.ResponseWriter, r *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
var req quickfiltertypes.UpdatableQuickFilters
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.module.UpsertQuickFilters(r.Context(), valuer.MustNewUUID(claims.OrgID), req.Signal, req.Filters)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
func (handler *handler) GetQuickFiltersV2(rw http.ResponseWriter, r *http.Request) {
|
||||
claims, err := authtypes.ClaimsFromContext(r.Context())
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
signal := mux.Vars(r)["signal_name"]
|
||||
validatedSignal, err := quickfiltertypes.NewSignal(signal)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
filters, err := handler.module.GetQuickFilters(r.Context(), valuer.MustNewUUID(claims.OrgID), validatedSignal)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, filters[0])
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
)
|
||||
|
||||
func TestNewTelemetryFieldKeysFromLegacy(t *testing.T) {
|
||||
fieldKeys, err := newTelemetryFieldKeysFromLegacy(quickfiltertypes.SignalTraces, []v3.AttributeKey{
|
||||
fieldKeys, err := newTelemetryFieldKeysFromLegacy(quickfiltertypes.SourceTraces, []v3.AttributeKey{
|
||||
{Key: "service.name", Type: v3.AttributeKeyTypeResource, DataType: v3.AttributeKeyDataTypeString},
|
||||
{Key: "http.method", Type: v3.AttributeKeyTypeTag, DataType: v3.AttributeKeyDataTypeString},
|
||||
{Key: "duration_nano", Type: v3.AttributeKeyTypeTag, DataType: v3.AttributeKeyDataTypeFloat64},
|
||||
@@ -25,8 +25,8 @@ func TestNewTelemetryFieldKeysFromLegacy(t *testing.T) {
|
||||
assert.Equal(t, telemetrytypes.FieldDataTypeNumber, fieldKeys[2].FieldDataType)
|
||||
assert.Equal(t, telemetrytypes.FieldDataTypeNumber, fieldKeys[3].FieldDataType)
|
||||
|
||||
t.Run("meter writes restore the per-filter signal", func(t *testing.T) {
|
||||
fieldKeys, err := newTelemetryFieldKeysFromLegacy(quickfiltertypes.SignalMeter, []v3.AttributeKey{
|
||||
t.Run("meter writes restore the per-filter telemetry signal", func(t *testing.T) {
|
||||
fieldKeys, err := newTelemetryFieldKeysFromLegacy(quickfiltertypes.SourceMeter, []v3.AttributeKey{
|
||||
{Key: "host.name", DataType: v3.AttributeKeyDataTypeString},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
@@ -35,14 +35,14 @@ func TestNewTelemetryFieldKeysFromLegacy(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("rejects a filter without a key", func(t *testing.T) {
|
||||
_, err := newTelemetryFieldKeysFromLegacy(quickfiltertypes.SignalTraces, []v3.AttributeKey{{DataType: v3.AttributeKeyDataTypeString}})
|
||||
_, err := newTelemetryFieldKeysFromLegacy(quickfiltertypes.SourceTraces, []v3.AttributeKey{{DataType: v3.AttributeKeyDataTypeString}})
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestNewLegacySignalFiltersFromSignalFilters(t *testing.T) {
|
||||
legacy := newLegacySignalFiltersFromSignalFilters(&quickfiltertypes.SignalFilters{
|
||||
Signal: quickfiltertypes.SignalLogs,
|
||||
func TestNewLegacySourceFilters(t *testing.T) {
|
||||
legacy := newLegacySourceFilters(&quickfiltertypes.SourceFilters{
|
||||
Source: quickfiltertypes.SourceLogs,
|
||||
Filters: []telemetrytypes.TelemetryFieldKey{
|
||||
{Name: "service.name", FieldContext: telemetrytypes.FieldContextResource, FieldDataType: telemetrytypes.FieldDataTypeString},
|
||||
{Name: "http.method", FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeString},
|
||||
@@ -52,7 +52,7 @@ func TestNewLegacySignalFiltersFromSignalFilters(t *testing.T) {
|
||||
},
|
||||
})
|
||||
|
||||
assert.Equal(t, quickfiltertypes.SignalLogs, legacy.Signal)
|
||||
assert.Equal(t, quickfiltertypes.SourceLogs, legacy.Source)
|
||||
require.Len(t, legacy.Filters, 5)
|
||||
assert.Equal(t, v3.AttributeKey{Key: "service.name", Type: v3.AttributeKeyTypeResource, DataType: v3.AttributeKeyDataTypeString}, legacy.Filters[0])
|
||||
assert.Equal(t, v3.AttributeKeyTypeTag, legacy.Filters[1].Type)
|
||||
|
||||
@@ -18,45 +18,49 @@ func NewModule(store quickfiltertypes.QuickFilterStore) quickfilter.Module {
|
||||
return &module{store: store}
|
||||
}
|
||||
|
||||
// GetQuickFilters returns quick filters for a signal, or for every signal when signal is zero.
|
||||
func (module *module) GetQuickFilters(ctx context.Context, orgID valuer.UUID, signal quickfiltertypes.Signal) ([]*quickfiltertypes.SignalFilters, error) {
|
||||
if signal.IsZero() {
|
||||
func (module *module) Get(ctx context.Context, orgID valuer.UUID, source quickfiltertypes.Source) (*quickfiltertypes.StorableQuickFilter, error) {
|
||||
return module.store.GetBySource(ctx, orgID, source.StringValue())
|
||||
}
|
||||
|
||||
// GetQuickFilters returns quick filters for a source, or for every source when source is zero.
|
||||
func (module *module) GetQuickFilters(ctx context.Context, orgID valuer.UUID, source quickfiltertypes.Source) ([]*quickfiltertypes.SourceFilters, error) {
|
||||
if source.IsZero() {
|
||||
storedFilters, err := module.store.Get(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "error fetching organization filters")
|
||||
}
|
||||
|
||||
result := make([]*quickfiltertypes.SignalFilters, 0, len(storedFilters))
|
||||
result := make([]*quickfiltertypes.SourceFilters, 0, len(storedFilters))
|
||||
for _, storedFilter := range storedFilters {
|
||||
signalFilter, err := quickfiltertypes.NewSignalFilterFromStorableQuickFilter(storedFilter)
|
||||
sourceFilter, err := quickfiltertypes.NewSourceFilterFromStorableQuickFilter(storedFilter)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "error processing filter for signal: %s", storedFilter.Signal)
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "error processing filter for source: %s", storedFilter.Source)
|
||||
}
|
||||
result = append(result, signalFilter)
|
||||
result = append(result, sourceFilter)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
storedFilter, err := module.store.GetBySignal(ctx, orgID, signal.StringValue())
|
||||
storedFilter, err := module.store.GetBySource(ctx, orgID, source.StringValue())
|
||||
if err != nil {
|
||||
if errors.Ast(err, errors.TypeNotFound) {
|
||||
return []*quickfiltertypes.SignalFilters{quickfiltertypes.NewSignalFiltersFromSignal(signal)}, nil
|
||||
return []*quickfiltertypes.SourceFilters{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
signalFilter, err := quickfiltertypes.NewSignalFilterFromStorableQuickFilter(storedFilter)
|
||||
sourceFilter, err := quickfiltertypes.NewSourceFilterFromStorableQuickFilter(storedFilter)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "error processing filter for signal: %s", storedFilter.Signal)
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "error processing filter for source: %s", storedFilter.Source)
|
||||
}
|
||||
|
||||
return []*quickfiltertypes.SignalFilters{signalFilter}, nil
|
||||
return []*quickfiltertypes.SourceFilters{sourceFilter}, nil
|
||||
}
|
||||
|
||||
// UpsertQuickFilters replaces quick filters for a specific signal in an organization, creating them if absent.
|
||||
func (module *module) UpsertQuickFilters(ctx context.Context, orgID valuer.UUID, signal quickfiltertypes.Signal, filters []telemetrytypes.TelemetryFieldKey) error {
|
||||
filter, err := quickfiltertypes.NewStorableQuickFilter(orgID, signal, filters)
|
||||
// UpsertQuickFilters replaces quick filters for a specific source in an organization, creating them if absent.
|
||||
func (module *module) UpsertQuickFilters(ctx context.Context, orgID valuer.UUID, source quickfiltertypes.Source, filters []telemetrytypes.TelemetryFieldKey) error {
|
||||
filter, err := quickfiltertypes.NewStorableQuickFilter(orgID, source, filters)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ func (s *store) Get(ctx context.Context, orgID valuer.UUID) ([]*quickfiltertypes
|
||||
NewSelect().
|
||||
Model(&filters).
|
||||
Where("org_id = ?", orgID).
|
||||
Order("signal ASC").
|
||||
Order("source ASC").
|
||||
Scan(ctx)
|
||||
|
||||
if err != nil {
|
||||
@@ -36,7 +36,7 @@ func (s *store) Get(ctx context.Context, orgID valuer.UUID) ([]*quickfiltertypes
|
||||
return filters, nil
|
||||
}
|
||||
|
||||
func (s *store) GetBySignal(ctx context.Context, orgID valuer.UUID, signal string) (*quickfiltertypes.StorableQuickFilter, error) {
|
||||
func (s *store) GetBySource(ctx context.Context, orgID valuer.UUID, source string) (*quickfiltertypes.StorableQuickFilter, error) {
|
||||
filter := new(quickfiltertypes.StorableQuickFilter)
|
||||
|
||||
err := s.store.
|
||||
@@ -44,12 +44,12 @@ func (s *store) GetBySignal(ctx context.Context, orgID valuer.UUID, signal strin
|
||||
NewSelect().
|
||||
Model(filter).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("signal = ?", signal).
|
||||
Where("source = ?", source).
|
||||
Scan(ctx)
|
||||
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, s.store.WrapNotFoundErrf(err, errors.CodeNotFound, "No rows found for org_id: "+orgID.StringValue()+" signal: "+signal)
|
||||
return nil, s.store.WrapNotFoundErrf(err, errors.CodeNotFound, "No rows found for org_id: "+orgID.StringValue()+" source: "+source)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
@@ -62,7 +62,7 @@ func (s *store) Upsert(ctx context.Context, filter *quickfiltertypes.StorableQui
|
||||
BunDB().
|
||||
NewInsert().
|
||||
Model(filter).
|
||||
On("CONFLICT (org_id, signal) DO UPDATE").
|
||||
On("CONFLICT (org_id, source) DO UPDATE").
|
||||
Set("filter = EXCLUDED.filter").
|
||||
Set("updated_at = EXCLUDED.updated_at").
|
||||
Exec(ctx)
|
||||
@@ -78,7 +78,7 @@ func (s *store) Create(ctx context.Context, filters []*quickfiltertypes.Storable
|
||||
BunDBCtx(ctx).
|
||||
NewInsert().
|
||||
Model(&filters).
|
||||
On("CONFLICT (org_id, signal) DO NOTHING").
|
||||
On("CONFLICT (org_id, source) DO NOTHING").
|
||||
Exec(ctx)
|
||||
|
||||
if err != nil {
|
||||
|
||||
@@ -10,9 +10,11 @@ import (
|
||||
)
|
||||
|
||||
type Module interface {
|
||||
// GetQuickFilters returns quick filters for a signal, or for every signal when signal is zero.
|
||||
GetQuickFilters(ctx context.Context, orgID valuer.UUID, signal quickfiltertypes.Signal) ([]*quickfiltertypes.SignalFilters, error)
|
||||
UpsertQuickFilters(ctx context.Context, orgID valuer.UUID, signal quickfiltertypes.Signal, filters []telemetrytypes.TelemetryFieldKey) error
|
||||
// Get returns the stored quick filter row for a source.
|
||||
Get(ctx context.Context, orgID valuer.UUID, source quickfiltertypes.Source) (*quickfiltertypes.StorableQuickFilter, error)
|
||||
// GetQuickFilters returns quick filters for a source, or for every source when source is zero.
|
||||
GetQuickFilters(ctx context.Context, orgID valuer.UUID, source quickfiltertypes.Source) ([]*quickfiltertypes.SourceFilters, error)
|
||||
UpsertQuickFilters(ctx context.Context, orgID valuer.UUID, source quickfiltertypes.Source, filters []telemetrytypes.TelemetryFieldKey) error
|
||||
SetDefaultConfig(ctx context.Context, orgID valuer.UUID) error
|
||||
}
|
||||
|
||||
@@ -20,7 +22,7 @@ type Handler interface {
|
||||
// Legacy v1 endpoints, served by converting to and from the v3 attribute key shape.
|
||||
GetQuickFilters(http.ResponseWriter, *http.Request)
|
||||
UpdateQuickFilters(http.ResponseWriter, *http.Request)
|
||||
GetSignalFilters(http.ResponseWriter, *http.Request)
|
||||
GetSourceFilters(http.ResponseWriter, *http.Request)
|
||||
|
||||
ListQuickFiltersV2(http.ResponseWriter, *http.Request)
|
||||
GetQuickFiltersV2(http.ResponseWriter, *http.Request)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -2,6 +2,7 @@ package prometheus
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/prometheus/promql"
|
||||
)
|
||||
@@ -23,5 +24,11 @@ func NewEngine(logger *slog.Logger, cfg Config) *Engine {
|
||||
Timeout: cfg.Timeout,
|
||||
ActiveQueryTracker: activeQueryTracker,
|
||||
LookbackDelta: cfg.LookbackDelta,
|
||||
// The engine calls this for subqueries that do not set a step, such as
|
||||
// `metric[5m:]`, and segfaults if it is nil. 1m matches the default
|
||||
// global evaluation_interval that Prometheus wires here.
|
||||
NoStepSubqueryIntervalFn: func(int64) int64 {
|
||||
return time.Minute.Milliseconds()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
33
pkg/prometheus/engine_test.go
Normal file
33
pkg/prometheus/engine_test.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package prometheus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/prometheus/prometheus/storage"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNoStepSubqueryDoesNotPanic(t *testing.T) {
|
||||
engine := NewEngine(slog.New(slog.DiscardHandler), Config{Timeout: time.Minute})
|
||||
queryable := storage.QueryableFunc(func(int64, int64) (storage.Querier, error) {
|
||||
return storage.NoopQuerier(), nil
|
||||
})
|
||||
|
||||
qry, err := engine.NewRangeQuery(
|
||||
context.Background(),
|
||||
queryable,
|
||||
nil,
|
||||
"max_over_time(some_metric[5m:])",
|
||||
time.Now().Add(-time.Hour),
|
||||
time.Now(),
|
||||
time.Minute,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
defer qry.Close()
|
||||
|
||||
res := qry.Exec(context.Background())
|
||||
require.NoError(t, res.Err)
|
||||
}
|
||||
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)
|
||||
@@ -452,7 +453,7 @@ func (aH *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
|
||||
|
||||
// Quick Filters (v1 routes serve the legacy v3 shape; v2 lives in signozapiserver)
|
||||
router.HandleFunc("/api/v1/orgs/me/filters", am.ViewAccess(aH.Signoz.Handlers.QuickFilter.GetQuickFilters)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/v1/orgs/me/filters/{signal}", am.ViewAccess(aH.Signoz.Handlers.QuickFilter.GetSignalFilters)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/v1/orgs/me/filters/{signal}", am.ViewAccess(aH.Signoz.Handlers.QuickFilter.GetSourceFilters)).Methods(http.MethodGet)
|
||||
router.HandleFunc("/api/v1/orgs/me/filters", am.AdminAccess(aH.Signoz.Handlers.QuickFilter.UpdateQuickFilters)).Methods(http.MethodPut)
|
||||
|
||||
router.HandleFunc("/api/v1/register", am.OpenAccess(aH.registerUser)).Methods(http.MethodPost)
|
||||
|
||||
@@ -544,7 +544,7 @@ func (m *Manager) deleteTask(taskName string) {
|
||||
}
|
||||
|
||||
// CreateRule stores rule def into db and also
|
||||
// starts an executor for the rule
|
||||
// starts an executor for the rule, unless the rule is disabled
|
||||
func (m *Manager) CreateRule(ctx context.Context, ruleStr string) (*ruletypes.GettableRule, error) {
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
@@ -611,7 +611,7 @@ func (m *Manager) CreateRule(ctx context.Context, ruleStr string) (*ruletypes.Ge
|
||||
}
|
||||
|
||||
taskName := prepareTaskName(id.StringValue())
|
||||
if err = m.addTask(ctx, orgID, &parsedRule, taskName); err != nil {
|
||||
if err = m.syncRuleStateWithTask(ctx, orgID, taskName, &parsedRule); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -38,6 +38,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"
|
||||
@@ -89,11 +90,13 @@ 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 }{},
|
||||
struct{ statsreporter.Handler }{},
|
||||
struct{ savedview.Handler }{},
|
||||
struct{ quickfilter.Module }{},
|
||||
struct{ quickfilter.Handler }{},
|
||||
).New(ctx, instrumentation.ToProviderSettings(), apiserver.Config{})
|
||||
if err != nil {
|
||||
|
||||
@@ -245,6 +245,7 @@ func NewSQLMigrationProviderFactories(
|
||||
sqlmigration.NewMigrateLambdaDashboardsFactory(),
|
||||
sqlmigration.NewAddAuthDomainTuplesFactory(sqlstore),
|
||||
sqlmigration.NewAddDeploymentHostTuplesFactory(sqlstore),
|
||||
sqlmigration.NewAddSystemDashboardFactory(sqlstore, sqlschema),
|
||||
sqlmigration.NewMigrateQuickFiltersFactory(sqlstore),
|
||||
sqlmigration.NewAddQuickFilterTuplesFactory(sqlstore),
|
||||
)
|
||||
@@ -345,11 +346,13 @@ func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.Au
|
||||
handlers.RuleStateHistory,
|
||||
handlers.SpanMapperHandler,
|
||||
handlers.AlertmanagerHandler,
|
||||
handlers.PrometheusHandler,
|
||||
handlers.LLMPricingRuleHandler,
|
||||
handlers.TraceDetail,
|
||||
handlers.RulerHandler,
|
||||
handlers.StatsHandler,
|
||||
handlers.SavedView,
|
||||
modules.QuickFilter,
|
||||
handlers.QuickFilter,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -3,12 +3,13 @@ package sqlmigration
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/quickfiltertypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
@@ -39,6 +40,52 @@ func (m *createQuickFilters) Register(migrations *migrate.Migrations) error {
|
||||
}
|
||||
|
||||
func (m *createQuickFilters) Up(ctx context.Context, db *bun.DB) error {
|
||||
// Frozen copy of the defaults as this migration shipped (hence the old
|
||||
// camelCase keys); migrations must not read live types. 031 replaces these rows.
|
||||
defaultFilters := []struct {
|
||||
signal string
|
||||
filters []map[string]any
|
||||
}{
|
||||
{"traces", []map[string]any{
|
||||
{"key": "duration_nano", "dataType": "float64", "type": "tag"},
|
||||
{"key": "deployment.environment", "dataType": "string", "type": "resource"},
|
||||
{"key": "hasError", "dataType": "bool", "type": "tag"},
|
||||
{"key": "serviceName", "dataType": "string", "type": "tag"},
|
||||
{"key": "name", "dataType": "string", "type": "resource"},
|
||||
{"key": "rpcMethod", "dataType": "string", "type": "tag"},
|
||||
{"key": "responseStatusCode", "dataType": "string", "type": "resource"},
|
||||
{"key": "httpHost", "dataType": "string", "type": "tag"},
|
||||
{"key": "httpMethod", "dataType": "string", "type": "tag"},
|
||||
{"key": "httpRoute", "dataType": "string", "type": "tag"},
|
||||
{"key": "httpUrl", "dataType": "string", "type": "tag"},
|
||||
{"key": "traceID", "dataType": "string", "type": "tag"},
|
||||
}},
|
||||
{"logs", []map[string]any{
|
||||
{"key": "severity_text", "dataType": "string", "type": "resource"},
|
||||
{"key": "deployment.environment", "dataType": "string", "type": "resource"},
|
||||
{"key": "serviceName", "dataType": "string", "type": "tag"},
|
||||
{"key": "host.name", "dataType": "string", "type": "resource"},
|
||||
{"key": "k8s.cluster.name", "dataType": "string", "type": "resource"},
|
||||
{"key": "k8s.deployment.name", "dataType": "string", "type": "resource"},
|
||||
{"key": "k8s.namespace.name", "dataType": "string", "type": "resource"},
|
||||
{"key": "k8s.pod.name", "dataType": "string", "type": "resource"},
|
||||
}},
|
||||
{"api_monitoring", []map[string]any{
|
||||
{"key": "deployment.environment", "dataType": "string", "type": "resource"},
|
||||
{"key": "serviceName", "dataType": "string", "type": "tag"},
|
||||
{"key": "rpcMethod", "dataType": "string", "type": "tag"},
|
||||
}},
|
||||
{"exceptions", []map[string]any{
|
||||
{"key": "deployment.environment", "dataType": "string", "type": "resource"},
|
||||
{"key": "serviceName", "dataType": "string", "type": "tag"},
|
||||
{"key": "host.name", "dataType": "string", "type": "resource"},
|
||||
{"key": "k8s.cluster.name", "dataType": "string", "type": "tag"},
|
||||
{"key": "k8s.deployment.name", "dataType": "string", "type": "resource"},
|
||||
{"key": "k8s.namespace.name", "dataType": "string", "type": "tag"},
|
||||
{"key": "k8s.pod.name", "dataType": "string", "type": "tag"},
|
||||
}},
|
||||
}
|
||||
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -72,15 +119,31 @@ func (m *createQuickFilters) Up(ctx context.Context, db *bun.DB) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get the default quick filters
|
||||
storableQuickFilters, err := quickfiltertypes.NewDefaultQuickFilter(defaultOrg)
|
||||
if err != nil {
|
||||
return err
|
||||
now := time.Now()
|
||||
quickFilters := make([]*quickFilter, 0, len(defaultFilters))
|
||||
for _, defaultFilter := range defaultFilters {
|
||||
filterJSON, err := json.Marshal(defaultFilter.filters)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
quickFilters = append(quickFilters, &quickFilter{
|
||||
Identifiable: types.Identifiable{
|
||||
ID: valuer.GenerateUUID(),
|
||||
},
|
||||
OrgID: defaultOrg.StringValue(),
|
||||
Filter: string(filterJSON),
|
||||
Signal: defaultFilter.signal,
|
||||
TimeAuditable: types.TimeAuditable{
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Insert all filters at once
|
||||
_, err = tx.NewInsert().
|
||||
Model(&storableQuickFilters).
|
||||
Model(&quickFilters).
|
||||
Exec(ctx)
|
||||
|
||||
if err != nil {
|
||||
|
||||
@@ -3,11 +3,13 @@ package sqlmigration
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types/quickfiltertypes"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
@@ -38,6 +40,61 @@ func (migration *updateQuickFilters) Register(migrations *migrate.Migrations) er
|
||||
}
|
||||
|
||||
func (migration *updateQuickFilters) Up(ctx context.Context, db *bun.DB) error {
|
||||
// Frozen copy of the defaults as this migration shipped; migrations must not
|
||||
// read live types. api_monitoring's service.name is "tag" here — 035 fixes it.
|
||||
defaultFilters := []struct {
|
||||
signal string
|
||||
filters []map[string]any
|
||||
}{
|
||||
{"traces", []map[string]any{
|
||||
{"key": "duration_nano", "dataType": "float64", "type": "tag"},
|
||||
{"key": "deployment.environment", "dataType": "string", "type": "resource"},
|
||||
{"key": "hasError", "dataType": "bool", "type": "tag"},
|
||||
{"key": "service.name", "dataType": "string", "type": "resource"},
|
||||
{"key": "name", "dataType": "string", "type": "tag"},
|
||||
{"key": "rpc.method", "dataType": "string", "type": "tag"},
|
||||
{"key": "response_status_code", "dataType": "string", "type": "tag"},
|
||||
{"key": "http_host", "dataType": "string", "type": "tag"},
|
||||
{"key": "http.method", "dataType": "string", "type": "tag"},
|
||||
{"key": "http.route", "dataType": "string", "type": "tag"},
|
||||
{"key": "http_url", "dataType": "string", "type": "tag"},
|
||||
{"key": "trace_id", "dataType": "string", "type": "tag"},
|
||||
}},
|
||||
{"logs", []map[string]any{
|
||||
{"key": "severity_text", "dataType": "string", "type": "resource"},
|
||||
{"key": "deployment.environment", "dataType": "string", "type": "resource"},
|
||||
{"key": "service.name", "dataType": "string", "type": "resource"},
|
||||
{"key": "host.name", "dataType": "string", "type": "resource"},
|
||||
{"key": "k8s.cluster.name", "dataType": "string", "type": "resource"},
|
||||
{"key": "k8s.deployment.name", "dataType": "string", "type": "resource"},
|
||||
{"key": "k8s.namespace.name", "dataType": "string", "type": "resource"},
|
||||
{"key": "k8s.pod.name", "dataType": "string", "type": "resource"},
|
||||
}},
|
||||
{"api_monitoring", []map[string]any{
|
||||
{"key": "deployment.environment", "dataType": "string", "type": "resource"},
|
||||
{"key": "service.name", "dataType": "string", "type": "tag"},
|
||||
{"key": "rpc.method", "dataType": "string", "type": "tag"},
|
||||
}},
|
||||
{"exceptions", []map[string]any{
|
||||
{"key": "deployment.environment", "dataType": "string", "type": "resource"},
|
||||
{"key": "service.name", "dataType": "string", "type": "resource"},
|
||||
{"key": "host.name", "dataType": "string", "type": "resource"},
|
||||
{"key": "k8s.cluster.name", "dataType": "string", "type": "resource"},
|
||||
{"key": "k8s.deployment.name", "dataType": "string", "type": "resource"},
|
||||
{"key": "k8s.namespace.name", "dataType": "string", "type": "resource"},
|
||||
{"key": "k8s.pod.name", "dataType": "string", "type": "resource"},
|
||||
}},
|
||||
}
|
||||
|
||||
signalFilters := make([]struct{ signal, filter string }, 0, len(defaultFilters))
|
||||
for _, defaultFilter := range defaultFilters {
|
||||
filterJSON, err := json.Marshal(defaultFilter.filters)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
signalFilters = append(signalFilters, struct{ signal, filter string }{defaultFilter.signal, string(filterJSON)})
|
||||
}
|
||||
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -73,17 +130,28 @@ func (migration *updateQuickFilters) Up(ctx context.Context, db *bun.DB) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// For each organization, create new quick filters with the updated NewDefaultQuickFilter function
|
||||
// For each organization, create new quick filters with the updated defaults
|
||||
for _, orgID := range orgIDs {
|
||||
// Get the updated default quick filters
|
||||
storableQuickFilters, err := quickfiltertypes.NewDefaultQuickFilter(valuer.MustNewUUID(orgID))
|
||||
if err != nil {
|
||||
return err
|
||||
now := time.Now()
|
||||
quickFilters := make([]*quickFilter, 0, len(signalFilters))
|
||||
for _, signalFilter := range signalFilters {
|
||||
quickFilters = append(quickFilters, &quickFilter{
|
||||
Identifiable: types.Identifiable{
|
||||
ID: valuer.GenerateUUID(),
|
||||
},
|
||||
OrgID: orgID,
|
||||
Filter: signalFilter.filter,
|
||||
Signal: signalFilter.signal,
|
||||
TimeAuditable: types.TimeAuditable{
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// Insert all filters for this organization
|
||||
_, err = tx.NewInsert().
|
||||
Model(&storableQuickFilters).
|
||||
Model(&quickFilters).
|
||||
Exec(ctx)
|
||||
|
||||
if err != nil {
|
||||
|
||||
@@ -2,20 +2,16 @@ package sqlmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types/quickfiltertypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
)
|
||||
|
||||
type updateApiMonitoringFilters struct {
|
||||
store sqlstore.SQLStore
|
||||
}
|
||||
type updateApiMonitoringFilters struct{}
|
||||
|
||||
func NewUpdateApiMonitoringFiltersFactory(store sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("update_api_monitoring_filters"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
@@ -23,10 +19,8 @@ func NewUpdateApiMonitoringFiltersFactory(store sqlstore.SQLStore) factory.Provi
|
||||
})
|
||||
}
|
||||
|
||||
func newUpdateApiMonitoringFilters(_ context.Context, _ factory.ProviderSettings, _ Config, store sqlstore.SQLStore) (SQLMigration, error) {
|
||||
return &updateApiMonitoringFilters{
|
||||
store: store,
|
||||
}, nil
|
||||
func newUpdateApiMonitoringFilters(_ context.Context, _ factory.ProviderSettings, _ Config, _ sqlstore.SQLStore) (SQLMigration, error) {
|
||||
return &updateApiMonitoringFilters{}, nil
|
||||
}
|
||||
|
||||
func (migration *updateApiMonitoringFilters) Register(migrations *migrate.Migrations) error {
|
||||
@@ -38,63 +32,29 @@ func (migration *updateApiMonitoringFilters) Register(migrations *migrate.Migrat
|
||||
}
|
||||
|
||||
func (migration *updateApiMonitoringFilters) Up(ctx context.Context, db *bun.DB) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
// Frozen copy of the api_monitoring defaults as this migration shipped; the
|
||||
// change over 031 is service.name moving from "tag" to "resource".
|
||||
apiMonitoringFilters := []map[string]any{
|
||||
{"key": "deployment.environment", "dataType": "string", "type": "resource"},
|
||||
{"key": "service.name", "dataType": "string", "type": "resource"},
|
||||
{"key": "rpc.method", "dataType": "string", "type": "tag"},
|
||||
}
|
||||
|
||||
apiMonitoringFilterJSON, err := json.Marshal(apiMonitoringFilters)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
_ = tx.Rollback()
|
||||
}()
|
||||
|
||||
// Get all organization IDs as strings
|
||||
var orgIDs []string
|
||||
err = tx.NewSelect().
|
||||
Table("organizations").
|
||||
Column("id").
|
||||
Scan(ctx, &orgIDs)
|
||||
// The filter JSON is org-independent, so one update covers every org's row.
|
||||
_, err = db.NewUpdate().
|
||||
Table("quick_filter").
|
||||
Set("filter = ?, updated_at = ?", string(apiMonitoringFilterJSON), time.Now()).
|
||||
Where("signal = ?", "api_monitoring").
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
for _, orgID := range orgIDs {
|
||||
// Get the updated default quick filters which includes the new API monitoring filters
|
||||
storableQuickFilters, err := quickfiltertypes.NewDefaultQuickFilter(valuer.MustNewUUID(orgID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Find the API monitoring filter from the storable quick filters
|
||||
var apiMonitoringFilterJSON string
|
||||
for _, filter := range storableQuickFilters {
|
||||
if filter.Signal == quickfiltertypes.SignalApiMonitoring {
|
||||
apiMonitoringFilterJSON = filter.Filter
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if apiMonitoringFilterJSON != "" {
|
||||
_, err = tx.NewUpdate().
|
||||
Table("quick_filter").
|
||||
Set("filter = ?, updated_at = ?", apiMonitoringFilterJSON, time.Now()).
|
||||
Where("signal = ? AND org_id = ?", quickfiltertypes.SignalApiMonitoring, orgID).
|
||||
Exec(ctx)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
}
|
||||
@@ -50,18 +50,6 @@ var quickFilterLegacyDataTypeToFieldDataType = map[string]string{
|
||||
"float64": "number",
|
||||
}
|
||||
|
||||
// quickFilterFieldDataType resolves legacy datatype spellings, with unknowns
|
||||
// normalized to unspecified.
|
||||
func quickFilterFieldDataType(legacyDataType string) string {
|
||||
return quickFilterLegacyDataTypeToFieldDataType[strings.ToLower(strings.TrimSpace(legacyDataType))]
|
||||
}
|
||||
|
||||
// quickFilterFieldContext resolves legacy type spellings, with unknowns
|
||||
// normalized to unspecified.
|
||||
func quickFilterFieldContext(legacyType string) string {
|
||||
return quickFilterLegacyTypeToFieldContext[strings.ToLower(strings.TrimSpace(legacyType))]
|
||||
}
|
||||
|
||||
type migrateQuickFilters struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
settings factory.ProviderSettings
|
||||
@@ -109,6 +97,16 @@ func (migration *migrateQuickFilters) Up(ctx context.Context, db *bun.DB) error
|
||||
|
||||
migration.settings.Logger.InfoContext(ctx, "migrated quick filters to telemetry field keys", slog.Int("total", len(rows)), slog.Int("migrated", migrated), slog.Int("skipped", skipped))
|
||||
|
||||
if _, err := migration.sqlstore.Dialect().RenameColumn(ctx, tx, "quick_filter", "signal", "source"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, column := range []string{"created_by", "updated_by"} {
|
||||
if err := migration.sqlstore.Dialect().DropColumn(ctx, tx, "quick_filter", column); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
@@ -168,3 +166,15 @@ func migrateQuickFilterEntries(filter string) (migrated string, changed bool, ok
|
||||
|
||||
return string(migratedJSON), true, true
|
||||
}
|
||||
|
||||
// quickFilterFieldDataType resolves legacy datatype spellings, with unknowns
|
||||
// normalized to unspecified.
|
||||
func quickFilterFieldDataType(legacyDataType string) string {
|
||||
return quickFilterLegacyDataTypeToFieldDataType[strings.ToLower(strings.TrimSpace(legacyDataType))]
|
||||
}
|
||||
|
||||
// quickFilterFieldContext resolves legacy type spellings, with unknowns
|
||||
// normalized to unspecified.
|
||||
func quickFilterFieldContext(legacyType string) string {
|
||||
return quickFilterLegacyTypeToFieldContext[strings.ToLower(strings.TrimSpace(legacyType))]
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
173
pkg/templating/markdownrenderer/adf/adf.go
Normal file
173
pkg/templating/markdownrenderer/adf/adf.go
Normal file
@@ -0,0 +1,173 @@
|
||||
// Package adf converts Markdown into Atlassian Document Format (ADF) nodes,
|
||||
// the JSON rich-text format used by Jira Cloud's v3 API.
|
||||
package adf
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/ast"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
extast "github.com/yuin/goldmark/extension/ast"
|
||||
"github.com/yuin/goldmark/text"
|
||||
)
|
||||
|
||||
// parser is stateless across Parse calls and safe for concurrent use; only
|
||||
// goldmark's renderers hold per-document state (which we don't use). Strikethrough
|
||||
// is included for the strike mark; linkify is deliberately omitted since it
|
||||
// fragments plain text into word tokens while scanning for bare URLs.
|
||||
var parser = goldmark.New(goldmark.WithExtensions(extension.Strikethrough)).Parser()
|
||||
|
||||
// Render returns the ADF block nodes for markdown (without the doc wrapper),
|
||||
// so callers can embed them alongside their own nodes (panels, links, …).
|
||||
func Render(markdown string) []any {
|
||||
src := []byte(markdown)
|
||||
return blockChildren(parser.Parse(text.NewReader(src)), src)
|
||||
}
|
||||
|
||||
func blockChildren(parent ast.Node, src []byte) []any {
|
||||
var out []any
|
||||
for c := parent.FirstChild(); c != nil; c = c.NextSibling() {
|
||||
if b := block(c, src); b != nil {
|
||||
out = append(out, b)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func block(n ast.Node, src []byte) any {
|
||||
switch node := n.(type) {
|
||||
case *ast.Heading:
|
||||
return map[string]any{"type": "heading", "attrs": map[string]any{"level": node.Level}, "content": inlineChildren(node, src, nil)}
|
||||
case *ast.Paragraph:
|
||||
return paragraph(inlineChildren(node, src, nil))
|
||||
case *ast.TextBlock:
|
||||
return paragraph(inlineChildren(node, src, nil))
|
||||
case *ast.List:
|
||||
typ := "bulletList"
|
||||
if node.IsOrdered() {
|
||||
typ = "orderedList"
|
||||
}
|
||||
return map[string]any{"type": typ, "content": blockChildren(node, src)}
|
||||
case *ast.ListItem:
|
||||
return map[string]any{"type": "listItem", "content": blockChildren(node, src)}
|
||||
case *ast.Blockquote:
|
||||
return map[string]any{"type": "blockquote", "content": blockChildren(node, src)}
|
||||
case *ast.FencedCodeBlock:
|
||||
return codeBlock(codeText(node, src), string(node.Language(src)))
|
||||
case *ast.CodeBlock:
|
||||
return codeBlock(codeText(node, src), "")
|
||||
case *ast.ThematicBreak:
|
||||
return map[string]any{"type": "rule"}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func paragraph(content []any) map[string]any {
|
||||
p := map[string]any{"type": "paragraph"}
|
||||
if len(content) > 0 {
|
||||
p["content"] = content
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func codeBlock(code, lang string) map[string]any {
|
||||
cb := map[string]any{"type": "codeBlock"}
|
||||
if lang != "" {
|
||||
cb["attrs"] = map[string]any{"language": lang}
|
||||
}
|
||||
if code = strings.TrimRight(code, "\n"); code != "" {
|
||||
cb["content"] = []any{map[string]any{"type": "text", "text": code}}
|
||||
}
|
||||
return cb
|
||||
}
|
||||
|
||||
// inlineChildren flattens an inline subtree into ADF text nodes, carrying the
|
||||
// active marks (strong/em/code/strike/link) down the tree.
|
||||
func inlineChildren(parent ast.Node, src []byte, marks []any) []any {
|
||||
var out []any
|
||||
for c := parent.FirstChild(); c != nil; c = c.NextSibling() {
|
||||
switch node := c.(type) {
|
||||
case *ast.Text:
|
||||
if t := string(node.Segment.Value(src)); t != "" {
|
||||
out = append(out, textNode(t, marks))
|
||||
}
|
||||
if node.HardLineBreak() {
|
||||
out = append(out, map[string]any{"type": "hardBreak"})
|
||||
} else if node.SoftLineBreak() {
|
||||
out = append(out, textNode(" ", marks))
|
||||
}
|
||||
case *ast.String:
|
||||
if len(node.Value) > 0 {
|
||||
out = append(out, textNode(string(node.Value), marks))
|
||||
}
|
||||
case *ast.CodeSpan:
|
||||
if t := rawText(node, src); t != "" {
|
||||
out = append(out, textNode(t, withMark(marks, mark("code"))))
|
||||
}
|
||||
case *ast.Emphasis:
|
||||
m := "em"
|
||||
if node.Level == 2 {
|
||||
m = "strong"
|
||||
}
|
||||
out = append(out, inlineChildren(node, src, withMark(marks, mark(m)))...)
|
||||
case *extast.Strikethrough:
|
||||
out = append(out, inlineChildren(node, src, withMark(marks, mark("strike")))...)
|
||||
case *ast.Link:
|
||||
out = append(out, inlineChildren(node, src, withMark(marks, linkMark(string(node.Destination))))...)
|
||||
case *ast.AutoLink:
|
||||
if u := string(node.URL(src)); u != "" {
|
||||
out = append(out, textNode(u, withMark(marks, linkMark(u))))
|
||||
}
|
||||
default:
|
||||
out = append(out, inlineChildren(c, src, marks)...)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func textNode(s string, marks []any) map[string]any {
|
||||
tn := map[string]any{"type": "text", "text": s}
|
||||
if len(marks) > 0 {
|
||||
tn["marks"] = marks
|
||||
}
|
||||
return tn
|
||||
}
|
||||
|
||||
func mark(typ string) any { return map[string]any{"type": typ} }
|
||||
|
||||
func linkMark(href string) any {
|
||||
return map[string]any{"type": "link", "attrs": map[string]any{"href": href}}
|
||||
}
|
||||
|
||||
func withMark(marks []any, m any) []any {
|
||||
out := make([]any, 0, len(marks)+1)
|
||||
out = append(out, marks...)
|
||||
return append(out, m)
|
||||
}
|
||||
|
||||
func rawText(n ast.Node, src []byte) string {
|
||||
var b strings.Builder
|
||||
for c := n.FirstChild(); c != nil; c = c.NextSibling() {
|
||||
switch t := c.(type) {
|
||||
case *ast.Text:
|
||||
b.Write(t.Segment.Value(src))
|
||||
case *ast.String:
|
||||
b.Write(t.Value)
|
||||
default:
|
||||
b.WriteString(rawText(c, src))
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func codeText(n ast.Node, src []byte) string {
|
||||
var b strings.Builder
|
||||
lines := n.Lines()
|
||||
for i := 0; i < lines.Len(); i++ {
|
||||
seg := lines.At(i)
|
||||
b.Write(seg.Value(src))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
85
pkg/templating/markdownrenderer/adf/adf_test.go
Normal file
85
pkg/templating/markdownrenderer/adf/adf_test.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package adf
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func toJSON(t *testing.T, v any) string {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(v)
|
||||
require.NoError(t, err)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func TestRenderInlineMarks(t *testing.T) {
|
||||
js := toJSON(t, Render("**bold** and *em* and `code` and [txt](https://x.io)"))
|
||||
assert.Contains(t, js, `"type":"strong"`)
|
||||
assert.Contains(t, js, `"type":"em"`)
|
||||
assert.Contains(t, js, `"type":"code"`)
|
||||
assert.Contains(t, js, `"type":"link"`)
|
||||
assert.Contains(t, js, `"href":"https://x.io"`)
|
||||
assert.Contains(t, js, `"text":"bold"`)
|
||||
}
|
||||
|
||||
func TestRenderHeadingAndList(t *testing.T) {
|
||||
js := toJSON(t, Render("# Title\n\n- a\n- b"))
|
||||
assert.Contains(t, js, `"type":"heading"`)
|
||||
assert.Contains(t, js, `"level":1`)
|
||||
assert.Contains(t, js, `"type":"bulletList"`)
|
||||
assert.Contains(t, js, `"type":"listItem"`)
|
||||
}
|
||||
|
||||
func TestRenderOrderedList(t *testing.T) {
|
||||
js := toJSON(t, Render("1. one\n2. two"))
|
||||
assert.Contains(t, js, `"type":"orderedList"`)
|
||||
}
|
||||
|
||||
func TestRenderCodeBlock(t *testing.T) {
|
||||
js := toJSON(t, Render("```go\nx := 1\n```"))
|
||||
assert.Contains(t, js, `"type":"codeBlock"`)
|
||||
assert.Contains(t, js, `"language":"go"`)
|
||||
assert.Contains(t, js, `x := 1`)
|
||||
}
|
||||
|
||||
func TestRenderStrikethrough(t *testing.T) {
|
||||
js := toJSON(t, Render("~~gone~~"))
|
||||
assert.Contains(t, js, `"type":"strike"`)
|
||||
assert.Contains(t, js, `"text":"gone"`)
|
||||
}
|
||||
|
||||
func TestRenderBlockquote(t *testing.T) {
|
||||
js := toJSON(t, Render("> quoted"))
|
||||
assert.Contains(t, js, `"type":"blockquote"`)
|
||||
assert.Contains(t, js, `"text":"quoted"`)
|
||||
}
|
||||
|
||||
func TestRenderAutoLink(t *testing.T) {
|
||||
js := toJSON(t, Render("see <https://signoz.io>"))
|
||||
assert.Contains(t, js, `"type":"link"`)
|
||||
assert.Contains(t, js, `"href":"https://signoz.io"`)
|
||||
assert.Contains(t, js, `"text":"https://signoz.io"`)
|
||||
}
|
||||
|
||||
func TestRenderLineBreaks(t *testing.T) {
|
||||
js := toJSON(t, Render("one \ntwo"))
|
||||
assert.Contains(t, js, `"type":"hardBreak"`)
|
||||
|
||||
// a soft break renders as a space, keeping the paragraph intact
|
||||
js = toJSON(t, Render("one\ntwo"))
|
||||
assert.NotContains(t, js, `"type":"hardBreak"`)
|
||||
assert.Contains(t, js, `"text":" "`)
|
||||
}
|
||||
|
||||
func TestRenderPlainText(t *testing.T) {
|
||||
js := toJSON(t, Render("just text"))
|
||||
assert.Contains(t, js, `"type":"paragraph"`)
|
||||
assert.Contains(t, js, `"text":"just text"`)
|
||||
}
|
||||
|
||||
func TestRenderEmptyIsEmpty(t *testing.T) {
|
||||
assert.Empty(t, Render(""))
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/templating/markdownrenderer/blockkit"
|
||||
"github.com/SigNoz/signoz/pkg/templating/markdownrenderer/mrkdwn"
|
||||
"github.com/SigNoz/signoz/pkg/templating/markdownrenderer/plaintext"
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
)
|
||||
@@ -32,6 +33,11 @@ var (
|
||||
return goldmark.New(goldmark.WithExtensions(mrkdwn.Extender))
|
||||
},
|
||||
}
|
||||
plaintextPool = sync.Pool{
|
||||
New: func() any {
|
||||
return goldmark.New(goldmark.WithExtensions(plaintext.Extender))
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// RenderHTML converts markdown to HTML.
|
||||
@@ -53,6 +59,14 @@ func RenderSlackMrkdwn(markdown string) (string, error) {
|
||||
return render(md, markdown, "Slack mrkdwn")
|
||||
}
|
||||
|
||||
// RenderPlainText converts markdown to plain text: no markers, links flattened
|
||||
// to "text (url)".
|
||||
func RenderPlainText(markdown string) (string, error) {
|
||||
md := plaintextPool.Get().(goldmark.Markdown)
|
||||
defer plaintextPool.Put(md)
|
||||
return render(md, markdown, "plain text")
|
||||
}
|
||||
|
||||
func render(md goldmark.Markdown, markdown string, format string) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
if err := md.Convert([]byte(markdown), &buf); err != nil {
|
||||
|
||||
301
pkg/templating/markdownrenderer/plaintext/plaintext.go
Normal file
301
pkg/templating/markdownrenderer/plaintext/plaintext.go
Normal file
@@ -0,0 +1,301 @@
|
||||
// Package plaintext provides a goldmark node renderer that emits plain text:
|
||||
// no markdown or HTML markers, and links flattened to "text (url)". It is used
|
||||
// for JSM Ops timeline notes, which render neither HTML nor markdown.
|
||||
package plaintext
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/ast"
|
||||
"github.com/yuin/goldmark/extension"
|
||||
extensionast "github.com/yuin/goldmark/extension/ast"
|
||||
"github.com/yuin/goldmark/renderer"
|
||||
"github.com/yuin/goldmark/util"
|
||||
)
|
||||
|
||||
// Extender registers the plain-text node renderer plus the GFM extensions it
|
||||
// handles (tables, strikethrough).
|
||||
var Extender goldmark.Extender = &extender{}
|
||||
|
||||
type extender struct{}
|
||||
|
||||
func (e *extender) Extend(m goldmark.Markdown) {
|
||||
extension.Table.Extend(m)
|
||||
extension.Strikethrough.Extend(m)
|
||||
m.Renderer().AddOptions(
|
||||
renderer.WithNodeRenderers(util.Prioritized(newRenderer(), 1)),
|
||||
)
|
||||
}
|
||||
|
||||
// nodeRenderer holds per-document nesting prefixes, so it is not safe for
|
||||
// concurrent Convert calls; callers pool one instance per goroutine.
|
||||
type nodeRenderer struct {
|
||||
prefixes []string
|
||||
}
|
||||
|
||||
func newRenderer() renderer.NodeRenderer {
|
||||
return &nodeRenderer{}
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
|
||||
// Blocks
|
||||
reg.Register(ast.KindDocument, r.renderDocument)
|
||||
reg.Register(ast.KindHeading, r.renderBlock)
|
||||
reg.Register(ast.KindBlockquote, r.renderBlock)
|
||||
reg.Register(ast.KindCodeBlock, r.renderCodeBlock)
|
||||
reg.Register(ast.KindFencedCodeBlock, r.renderCodeBlock)
|
||||
reg.Register(ast.KindHTMLBlock, r.renderHTMLBlock)
|
||||
reg.Register(ast.KindList, r.renderList)
|
||||
reg.Register(ast.KindListItem, r.renderListItem)
|
||||
reg.Register(ast.KindParagraph, r.renderBlock)
|
||||
reg.Register(ast.KindTextBlock, r.renderTextBlock)
|
||||
reg.Register(ast.KindThematicBreak, r.renderThematicBreak)
|
||||
|
||||
// Inlines
|
||||
reg.Register(ast.KindAutoLink, r.renderAutoLink)
|
||||
reg.Register(ast.KindCodeSpan, r.renderCodeSpan)
|
||||
reg.Register(ast.KindEmphasis, r.renderPassthrough)
|
||||
reg.Register(ast.KindImage, r.renderLink)
|
||||
reg.Register(ast.KindLink, r.renderLink)
|
||||
reg.Register(ast.KindText, r.renderText)
|
||||
reg.Register(ast.KindString, r.renderString)
|
||||
reg.Register(ast.KindRawHTML, r.renderRawHTML)
|
||||
|
||||
// Extensions
|
||||
reg.Register(extensionast.KindStrikethrough, r.renderPassthrough)
|
||||
reg.Register(extensionast.KindTable, r.renderTable)
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) writePrefix(w util.BufWriter) {
|
||||
for _, p := range r.prefixes {
|
||||
_, _ = w.WriteString(p)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) writeLineSeparator(w util.BufWriter) {
|
||||
_ = w.WriteByte('\n')
|
||||
r.writePrefix(w)
|
||||
}
|
||||
|
||||
// writeBlockSeparator writes a blank line between block-level elements.
|
||||
func (r *nodeRenderer) writeBlockSeparator(w util.BufWriter) {
|
||||
r.writeLineSeparator(w)
|
||||
r.writeLineSeparator(w)
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) separateFromPrevious(w util.BufWriter, n ast.Node) {
|
||||
if n.PreviousSibling() != nil {
|
||||
r.writeBlockSeparator(w)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderDocument(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
// The renderer is pooled; wipe any prefix stack left over from a prior
|
||||
// document (e.g. one that errored mid-walk) before starting fresh.
|
||||
r.prefixes = r.prefixes[:0]
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// renderBlock separates block-level nodes (paragraph, heading, blockquote) from
|
||||
// their previous sibling with a blank line, emitting no markers of their own.
|
||||
func (r *nodeRenderer) renderBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
r.separateFromPrevious(w, node)
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderCodeBlock(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
r.separateFromPrevious(w, n)
|
||||
l := n.Lines().Len()
|
||||
for i := 0; i < l; i++ {
|
||||
line := n.Lines().At(i)
|
||||
_, _ = w.Write(line.Value(source))
|
||||
}
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderList(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering && node.PreviousSibling() != nil {
|
||||
r.writeLineSeparator(w)
|
||||
if node.Parent() == nil || node.Parent().Kind() != ast.KindListItem {
|
||||
r.writeLineSeparator(w)
|
||||
}
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderListItem(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
if n.PreviousSibling() != nil {
|
||||
r.writeLineSeparator(w)
|
||||
}
|
||||
parent := n.Parent().(*ast.List)
|
||||
var prefixStr string
|
||||
if parent.IsOrdered() {
|
||||
index := parent.Start
|
||||
for c := parent.FirstChild(); c != nil && c != n; c = c.NextSibling() {
|
||||
index++
|
||||
}
|
||||
prefixStr = fmt.Sprintf("%d. ", index)
|
||||
} else {
|
||||
prefixStr = "- "
|
||||
}
|
||||
_, _ = w.WriteString(prefixStr)
|
||||
r.prefixes = append(r.prefixes, " ") // indent wrapped/nested lines
|
||||
} else {
|
||||
r.prefixes = r.prefixes[:len(r.prefixes)-1]
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderTextBlock(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering && n.PreviousSibling() != nil {
|
||||
r.writeLineSeparator(w)
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderThematicBreak(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
r.separateFromPrevious(w, n)
|
||||
_, _ = w.WriteString("---")
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderAutoLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
n := node.(*ast.AutoLink)
|
||||
url := string(n.URL(source))
|
||||
if n.AutoLinkType == ast.AutoLinkEmail && !strings.HasPrefix(strings.ToLower(url), "mailto:") {
|
||||
url = "mailto:" + url
|
||||
}
|
||||
_, _ = w.WriteString(url)
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderCodeSpan(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
for c := n.FirstChild(); c != nil; c = c.NextSibling() {
|
||||
segment := c.(*ast.Text).Segment
|
||||
value := segment.Value(source)
|
||||
if bytes.HasSuffix(value, []byte("\n")) {
|
||||
_, _ = w.Write(value[:len(value)-1])
|
||||
_ = w.WriteByte(' ')
|
||||
} else {
|
||||
_, _ = w.Write(value)
|
||||
}
|
||||
}
|
||||
return ast.WalkSkipChildren, nil
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// renderPassthrough emits no markers; the node's children render as plain text
|
||||
// (used for emphasis/strong and strikethrough).
|
||||
func (r *nodeRenderer) renderPassthrough(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// renderLink flattens links and images to "text (url)": children render the
|
||||
// label, then the destination is appended in parentheses on exit.
|
||||
func (r *nodeRenderer) renderLink(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
var dest []byte
|
||||
switch n := node.(type) {
|
||||
case *ast.Link:
|
||||
dest = n.Destination
|
||||
case *ast.Image:
|
||||
dest = n.Destination
|
||||
}
|
||||
if !entering && len(dest) > 0 {
|
||||
_, _ = fmt.Fprintf(w, " (%s)", dest)
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderText(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
n := node.(*ast.Text)
|
||||
_, _ = w.Write(n.Segment.Value(source))
|
||||
if n.HardLineBreak() || n.SoftLineBreak() {
|
||||
r.writeLineSeparator(w)
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderString(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if entering {
|
||||
_, _ = w.Write(node.(*ast.String).Value)
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderRawHTML(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
// Drop inline raw HTML tags; a plain-text note should never carry markup.
|
||||
return ast.WalkSkipChildren, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderHTMLBlock(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
// Drop block-level raw HTML for the same reason as inline raw HTML.
|
||||
return ast.WalkSkipChildren, nil
|
||||
}
|
||||
|
||||
func (r *nodeRenderer) renderTable(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
r.separateFromPrevious(w, node)
|
||||
|
||||
first := true
|
||||
for c := node.FirstChild(); c != nil; c = c.NextSibling() {
|
||||
if c.Kind() != extensionast.KindTableHeader && c.Kind() != extensionast.KindTableRow {
|
||||
continue
|
||||
}
|
||||
if !first {
|
||||
r.writeLineSeparator(w)
|
||||
}
|
||||
first = false
|
||||
cellFirst := true
|
||||
for cc := c.FirstChild(); cc != nil; cc = cc.NextSibling() {
|
||||
if cc.Kind() != extensionast.KindTableCell {
|
||||
continue
|
||||
}
|
||||
if !cellFirst {
|
||||
_, _ = w.WriteString(" | ")
|
||||
}
|
||||
cellFirst = false
|
||||
_, _ = w.WriteString(extractPlainText(cc, source))
|
||||
}
|
||||
}
|
||||
return ast.WalkSkipChildren, nil
|
||||
}
|
||||
|
||||
// extractPlainText collects the text content of a node.
|
||||
func extractPlainText(n ast.Node, source []byte) string {
|
||||
var buf bytes.Buffer
|
||||
_ = ast.Walk(n, func(node ast.Node, entering bool) (ast.WalkStatus, error) {
|
||||
if !entering {
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
switch t := node.(type) {
|
||||
case *ast.Text:
|
||||
buf.Write(t.Segment.Value(source))
|
||||
case *ast.String:
|
||||
buf.Write(t.Value)
|
||||
}
|
||||
return ast.WalkContinue, nil
|
||||
})
|
||||
return strings.TrimSpace(buf.String())
|
||||
}
|
||||
55
pkg/templating/markdownrenderer/plaintext/plaintext_test.go
Normal file
55
pkg/templating/markdownrenderer/plaintext/plaintext_test.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package plaintext
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/yuin/goldmark"
|
||||
)
|
||||
|
||||
func render(t *testing.T, md string) string {
|
||||
t.Helper()
|
||||
var b []byte
|
||||
buf := bytesBuffer{&b}
|
||||
g := goldmark.New(goldmark.WithExtensions(Extender))
|
||||
require.NoError(t, g.Convert([]byte(md), &buf))
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// bytesBuffer is a tiny io.Writer so the test needs no extra imports.
|
||||
type bytesBuffer struct{ b *[]byte }
|
||||
|
||||
func (w bytesBuffer) Write(p []byte) (int, error) {
|
||||
*w.b = append(*w.b, p...)
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func TestPlainText(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{"strips bold and italic", "**bold** and *italic*", "bold and italic"},
|
||||
{"link becomes text (url)", "[View in SigNoz](https://signoz.io/alert)", "View in SigNoz (https://signoz.io/alert)"},
|
||||
{"bold label kept, marker dropped", "**Alert:** name (critical)", "Alert: name (critical)"},
|
||||
{"strikethrough stripped", "~~gone~~", "gone"},
|
||||
{"inline code unwrapped", "run `foo bar`", "run foo bar"},
|
||||
{"paragraphs separated by blank line", "one\n\ntwo", "one\n\ntwo"},
|
||||
{"unordered list", "- a\n- b", "- a\n- b"},
|
||||
{"ordered list keeps numbering", "1. a\n2. b", "1. a\n2. b"},
|
||||
{"nested list indents under parent", "- a\n - b", "- a\n - b"},
|
||||
{"fenced code block unwrapped", "```go\nx := 1\n```", "x := 1\n"},
|
||||
{"table flattens to pipe-separated rows", "| h1 | h2 |\n|---|---|\n| a | b |\n| c | d |", "h1 | h2\na | b\nc | d"},
|
||||
{"autolink kept as bare url", "see <https://signoz.io>", "see https://signoz.io"},
|
||||
{"inline raw html dropped", "a <b>bold</b> word", "a bold word"},
|
||||
{"html block dropped", "before\n\n<div>markup</div>\n\nafter", "before\n\nafter"},
|
||||
{"hard break becomes newline", "one \ntwo", "one\ntwo"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
assert.Equal(t, c.want, render(t, c.in))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -216,13 +216,20 @@ func (PostableChannel) JSONSchema() (jsonschema.Schema, error) {
|
||||
schema.WithRequired("name")
|
||||
|
||||
var oneOf []jsonschema.SchemaOrBool
|
||||
// Walk both halves: native fields on Receiver, upstream on the embed.
|
||||
seen := map[string]struct{}{}
|
||||
// Walk both halves: native fields on Receiver, upstream on the embed. A native
|
||||
// field can shadow an upstream one with the same tag (e.g. jira_configs), so
|
||||
// dedupe to avoid emitting two identical oneOf branches.
|
||||
collect := func(t reflect.Type) {
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
jsonTag := strings.Split(t.Field(i).Tag.Get("json"), ",")[0]
|
||||
if !strings.HasSuffix(jsonTag, "_configs") {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[jsonTag]; ok {
|
||||
continue
|
||||
}
|
||||
seen[jsonTag] = struct{}{}
|
||||
branch := (&jsonschema.Schema{}).WithRequired(jsonTag)
|
||||
oneOf = append(oneOf, branch.ToSchemaOrBool())
|
||||
}
|
||||
|
||||
@@ -70,15 +70,19 @@ type Config struct {
|
||||
// on Receiver, and extensions to customConfigsOf + isEmpty.
|
||||
type customReceiverConfigs struct {
|
||||
GoogleChat []*GoogleChatReceiverConfig
|
||||
Jira []*JiraReceiverConfig
|
||||
JSMOps []*JSMOpsReceiverConfig
|
||||
}
|
||||
|
||||
func (c customReceiverConfigs) isEmpty() bool {
|
||||
return len(c.GoogleChat) == 0
|
||||
return len(c.GoogleChat) == 0 && len(c.Jira) == 0 && len(c.JSMOps) == 0
|
||||
}
|
||||
|
||||
func customConfigsOf(receiver *Receiver) customReceiverConfigs {
|
||||
return customReceiverConfigs{
|
||||
GoogleChat: receiver.GoogleChatConfigs,
|
||||
Jira: receiver.JiraConfigs,
|
||||
JSMOps: receiver.JSMOpsConfigs,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,6 +191,8 @@ func extendedReceivers(c *config.Config, customConfigs map[string]customReceiver
|
||||
receivers[i] = &Receiver{
|
||||
Receiver: &base,
|
||||
GoogleChatConfigs: custom.GoogleChat,
|
||||
JiraConfigs: custom.Jira,
|
||||
JSMOpsConfigs: custom.JSMOps,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,6 +368,8 @@ func (c *Config) GetReceiver(name string) (*Receiver, error) {
|
||||
return &Receiver{
|
||||
Receiver: &base,
|
||||
GoogleChatConfigs: custom.GoogleChat,
|
||||
JiraConfigs: custom.Jira,
|
||||
JSMOpsConfigs: custom.JSMOps,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
@@ -441,6 +449,16 @@ func (c *Config) applyNativeDefaults() {
|
||||
gc.HTTPConfig = httpDefault
|
||||
}
|
||||
}
|
||||
for _, jc := range custom.Jira {
|
||||
if jc.HTTPConfig == nil {
|
||||
jc.HTTPConfig = httpDefault
|
||||
}
|
||||
}
|
||||
for _, jc := range custom.JSMOps {
|
||||
if jc.HTTPConfig == nil {
|
||||
jc.HTTPConfig = httpDefault
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
119
pkg/types/alertmanagertypes/jira.go
Normal file
119
pkg/types/alertmanagertypes/jira.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package alertmanagertypes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
"github.com/prometheus/common/model"
|
||||
)
|
||||
|
||||
const defaultJiraReopenDuration = model.Duration(3 * 24 * time.Hour)
|
||||
|
||||
// Service accounts authenticate against the api.atlassian.com gateway (keyed by
|
||||
// cloud id) instead of the site host; they are identified by their email domain.
|
||||
const (
|
||||
jiraCloudHostSuffix = ".atlassian.net"
|
||||
jiraServiceAccountEmailDomain = "@serviceaccount.atlassian.com"
|
||||
jiraGatewayBaseURL = "https://api.atlassian.com/ex/jira/"
|
||||
)
|
||||
|
||||
// Default templates for the issue title and body. The body is rendered to
|
||||
// markdown and then wrapped in the ADF status panel + deep-links by the notifier.
|
||||
const (
|
||||
DefaultJiraSummaryTemplate = `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}`
|
||||
|
||||
DefaultJiraDescriptionTemplate = `{{ range .Alerts -}}
|
||||
**Alert:** {{ .Labels.alertname }}{{ if .Labels.severity }} ({{ .Labels.severity }}){{ end }}
|
||||
{{ if .Annotations.summary }}
|
||||
**Summary:** {{ .Annotations.summary }}
|
||||
{{ end }}{{ if .Annotations.description }}
|
||||
**Description:** {{ .Annotations.description }}
|
||||
{{ end }}
|
||||
{{ end }}`
|
||||
)
|
||||
|
||||
// JiraReceiverConfig is the SigNoz Jira receiver. Fields are declared explicitly
|
||||
// instead of embedding upstream config.JiraConfig because that type's own
|
||||
// UnmarshalYAML would reset our defaults and drop sibling fields on the yaml
|
||||
// round-trip. Only Jira Cloud (v3/ADF) is supported, so api_url is derived from Site.
|
||||
type JiraReceiverConfig struct {
|
||||
config.NotifierConfig `yaml:",inline"`
|
||||
|
||||
Site string `json:"site,omitempty" yaml:"site,omitempty"`
|
||||
Project string `json:"project,omitempty" yaml:"project,omitempty"`
|
||||
IssueType string `json:"issue_type,omitempty" yaml:"issue_type,omitempty"`
|
||||
Summary string `json:"summary,omitempty" yaml:"summary,omitempty"`
|
||||
Description string `json:"description,omitempty" yaml:"description,omitempty"`
|
||||
Priority string `json:"priority,omitempty" yaml:"priority,omitempty"`
|
||||
Labels []string `json:"labels,omitempty" yaml:"labels,omitempty"`
|
||||
ResolveTransition string `json:"resolve_transition,omitempty" yaml:"resolve_transition,omitempty"`
|
||||
ReopenTransition string `json:"reopen_transition,omitempty" yaml:"reopen_transition,omitempty"`
|
||||
ReopenDuration model.Duration `json:"reopen_duration" yaml:"reopen_duration"`
|
||||
WontFixResolution string `json:"wont_fix_resolution,omitempty" yaml:"wont_fix_resolution,omitempty"`
|
||||
CustomFields map[string]any `json:"custom_fields,omitempty" yaml:"custom_fields,omitempty"`
|
||||
HTTPConfig *commoncfg.HTTPClientConfig `json:"http_config,omitempty" yaml:"http_config,omitempty"`
|
||||
}
|
||||
|
||||
func (c *JiraReceiverConfig) UnmarshalYAML(unmarshal func(any) error) error {
|
||||
type plain JiraReceiverConfig
|
||||
if err := unmarshal((*plain)(c)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if c.ReopenDuration <= 0 {
|
||||
c.ReopenDuration = defaultJiraReopenDuration
|
||||
}
|
||||
// sub-minute windows truncate to 0 in the reopen JQL and silently disable
|
||||
// reopening, so reject them.
|
||||
if c.ReopenDuration < model.Duration(time.Minute) {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "jira reopen_duration must be at least 1m")
|
||||
}
|
||||
if c.Summary == "" {
|
||||
c.Summary = DefaultJiraSummaryTemplate
|
||||
}
|
||||
if c.Description == "" {
|
||||
c.Description = DefaultJiraDescriptionTemplate
|
||||
}
|
||||
|
||||
site := strings.TrimRight(strings.TrimSpace(c.Site), "/")
|
||||
u, err := url.Parse(site)
|
||||
if site == "" || err != nil || u.Scheme != "https" || !strings.HasSuffix(strings.ToLower(u.Hostname()), jiraCloudHostSuffix) {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, fmt.Sprintf("jira site must be a Jira Cloud URL (https://<site>%s)", jiraCloudHostSuffix))
|
||||
}
|
||||
c.Site = site
|
||||
|
||||
if c.Project == "" {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "jira project is required")
|
||||
}
|
||||
if c.IssueType == "" {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "jira issue_type is required")
|
||||
}
|
||||
if c.HTTPConfig == nil || c.HTTPConfig.BasicAuth == nil {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "jira requires basic auth (email + API token)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsServiceAccount reports whether the basic-auth user is an Atlassian service
|
||||
// account, identified by its email domain. Service accounts must go through the
|
||||
// api.atlassian.com gateway; personal API tokens use the site host directly.
|
||||
func (c *JiraReceiverConfig) IsServiceAccount() bool {
|
||||
if c.HTTPConfig == nil || c.HTTPConfig.BasicAuth == nil {
|
||||
return false
|
||||
}
|
||||
return strings.HasSuffix(strings.ToLower(c.HTTPConfig.BasicAuth.Username), jiraServiceAccountEmailDomain)
|
||||
}
|
||||
|
||||
// APIBaseURL returns the Jira Cloud REST v3 base URL: the api.atlassian.com
|
||||
// gateway when a cloud id is given (service accounts), else the site host.
|
||||
func (c *JiraReceiverConfig) APIBaseURL(cloudID string) string {
|
||||
if cloudID != "" {
|
||||
return fmt.Sprintf("%s%s/rest/api/3", jiraGatewayBaseURL, cloudID)
|
||||
}
|
||||
return fmt.Sprintf("%s/rest/api/3", strings.TrimRight(c.Site, "/"))
|
||||
}
|
||||
119
pkg/types/alertmanagertypes/jira_test.go
Normal file
119
pkg/types/alertmanagertypes/jira_test.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package alertmanagertypes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
"github.com/prometheus/common/model"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func jiraReceiverJSON(site, project, issueType string, withAuth bool) string {
|
||||
auth := ""
|
||||
if withAuth {
|
||||
auth = `,"http_config":{"basic_auth":{"username":"me@acme.com","password":"token"}}`
|
||||
}
|
||||
return fmt.Sprintf(
|
||||
`{"name":"jira","jira_configs":[{"site":%q,"project":%q,"issue_type":%q%s}]}`,
|
||||
site, project, issueType, auth,
|
||||
)
|
||||
}
|
||||
|
||||
func TestJiraReceiverConfigDefaults(t *testing.T) {
|
||||
r, err := NewReceiver(jiraReceiverJSON("https://acme.atlassian.net", "KAN", "Task", true))
|
||||
require.NoError(t, err)
|
||||
require.Len(t, r.JiraConfigs, 1)
|
||||
|
||||
jc := r.JiraConfigs[0]
|
||||
assert.Equal(t, "https://acme.atlassian.net", jc.Site)
|
||||
assert.Equal(t, "https://acme.atlassian.net/rest/api/3", jc.APIBaseURL(""))
|
||||
assert.False(t, jc.SendResolved()) // default off when omitted, like other channels
|
||||
assert.Equal(t, defaultJiraReopenDuration, jc.ReopenDuration)
|
||||
assert.Equal(t, DefaultJiraSummaryTemplate, jc.Summary)
|
||||
assert.Equal(t, DefaultJiraDescriptionTemplate, jc.Description)
|
||||
|
||||
ch, err := NewChannelFromReceiver(r, "org-1")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "jira", ch.Type)
|
||||
}
|
||||
|
||||
func TestJiraReceiverConfigSendResolved(t *testing.T) {
|
||||
withSendResolved := func(v bool) string {
|
||||
return fmt.Sprintf(
|
||||
`{"name":"j","jira_configs":[{"site":"https://acme.atlassian.net","project":"KAN","issue_type":"Task","send_resolved":%t,"http_config":{"basic_auth":{"username":"e","password":"t"}}}]}`,
|
||||
v,
|
||||
)
|
||||
}
|
||||
on, err := NewReceiver(withSendResolved(true))
|
||||
require.NoError(t, err)
|
||||
assert.True(t, on.JiraConfigs[0].SendResolved())
|
||||
|
||||
off, err := NewReceiver(withSendResolved(false))
|
||||
require.NoError(t, err)
|
||||
assert.False(t, off.JiraConfigs[0].SendResolved())
|
||||
}
|
||||
|
||||
func TestJiraReceiverConfigReopenDurationMinimum(t *testing.T) {
|
||||
withReopen := func(v string) string {
|
||||
return fmt.Sprintf(
|
||||
`{"name":"j","jira_configs":[{"site":"https://acme.atlassian.net","project":"KAN","issue_type":"Task","reopen_duration":%q,"http_config":{"basic_auth":{"username":"e","password":"t"}}}]}`,
|
||||
v,
|
||||
)
|
||||
}
|
||||
r, err := NewReceiver(withReopen("1m"))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, model.Duration(time.Minute), r.JiraConfigs[0].ReopenDuration)
|
||||
|
||||
_, err = NewReceiver(withReopen("30s"))
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestJiraAPIBaseURL(t *testing.T) {
|
||||
c := &JiraReceiverConfig{Site: "https://acme.atlassian.net"}
|
||||
assert.Equal(t, "https://acme.atlassian.net/rest/api/3", c.APIBaseURL(""))
|
||||
assert.Equal(t, "https://api.atlassian.com/ex/jira/09851b38-1a40-4c01-a36a-0a9336293200/rest/api/3", c.APIBaseURL("09851b38-1a40-4c01-a36a-0a9336293200"))
|
||||
}
|
||||
|
||||
func TestJiraIsServiceAccount(t *testing.T) {
|
||||
withUser := func(username string) *JiraReceiverConfig {
|
||||
return &JiraReceiverConfig{HTTPConfig: &commoncfg.HTTPClientConfig{BasicAuth: &commoncfg.BasicAuth{Username: username}}}
|
||||
}
|
||||
assert.True(t, withUser("bot@serviceaccount.atlassian.com").IsServiceAccount())
|
||||
assert.True(t, withUser("Bot@ServiceAccount.Atlassian.Com").IsServiceAccount())
|
||||
assert.False(t, withUser("temp@signoz.io").IsServiceAccount())
|
||||
assert.False(t, (&JiraReceiverConfig{}).IsServiceAccount())
|
||||
}
|
||||
|
||||
func TestJiraReceiverConfigTrailingSlashSite(t *testing.T) {
|
||||
r, err := NewReceiver(jiraReceiverJSON("https://acme.atlassian.net/", "KAN", "Task", true))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "https://acme.atlassian.net", r.JiraConfigs[0].Site)
|
||||
assert.Equal(t, "https://acme.atlassian.net/rest/api/3", r.JiraConfigs[0].APIBaseURL(""))
|
||||
}
|
||||
|
||||
func TestJiraReceiverConfigValidation(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
json string
|
||||
}{
|
||||
{"missing site", `{"name":"j","jira_configs":[{"project":"KAN","issue_type":"Task","http_config":{"basic_auth":{"username":"e","password":"t"}}}]}`},
|
||||
{"http site", jiraReceiverJSON("http://acme.atlassian.net", "KAN", "Task", true)},
|
||||
{"non-cloud host", jiraReceiverJSON("https://jira.acme.com", "KAN", "Task", true)},
|
||||
{"lookalike host suffix", jiraReceiverJSON("https://www.iamnotatlassian.net", "KAN", "Task", true)},
|
||||
{"bare atlassian.net", jiraReceiverJSON("https://atlassian.net", "KAN", "Task", true)},
|
||||
{"missing project", jiraReceiverJSON("https://acme.atlassian.net", "", "Task", true)},
|
||||
{"missing issue_type", jiraReceiverJSON("https://acme.atlassian.net", "KAN", "", true)},
|
||||
{"missing basic auth", jiraReceiverJSON("https://acme.atlassian.net", "KAN", "Task", false)},
|
||||
{"invalid reopen_duration format", `{"name":"j","jira_configs":[{"site":"https://acme.atlassian.net","project":"KAN","issue_type":"Task","reopen_duration":"3days","http_config":{"basic_auth":{"username":"e","password":"t"}}}]}`},
|
||||
{"sub-minute reopen_duration", `{"name":"j","jira_configs":[{"site":"https://acme.atlassian.net","project":"KAN","issue_type":"Task","reopen_duration":"30s","http_config":{"basic_auth":{"username":"e","password":"t"}}}]}`},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
_, err := NewReceiver(c.json)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
75
pkg/types/alertmanagertypes/jsmops.go
Normal file
75
pkg/types/alertmanagertypes/jsmops.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package alertmanagertypes
|
||||
|
||||
import (
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/prometheus/alertmanager/config"
|
||||
commoncfg "github.com/prometheus/common/config"
|
||||
)
|
||||
|
||||
// JSMOpsAPIBaseURL is the native JSM Ops integration-events gateway. It is a
|
||||
// single global host keyed by the integration API key (no region/cloud id in
|
||||
// the path). The trailing slash is required: the Opsgenie notifier appends
|
||||
// "v2/alerts..." to APIURL.Path with no separator.
|
||||
const JSMOpsAPIBaseURL = "https://api.atlassian.com/jsm/ops/integration/"
|
||||
|
||||
// JSM Ops speaks the Opsgenie alert API, so a JSM alert description takes the
|
||||
// same HTML subset and 15,000-char limit; message caps at 130. The templates
|
||||
// mirror Google Chat / Jira for a consistent default across channels.
|
||||
const (
|
||||
DefaultJSMOpsMessageTemplate = `[{{ .Status | toUpper }}{{ if eq .Status "firing" }}:{{ .Alerts.Firing | len }}{{ end }}] {{ .CommonLabels.alertname }}`
|
||||
|
||||
DefaultJSMOpsDescriptionTemplate = `{{ range .Alerts -}}
|
||||
**Alert:** {{ .Labels.alertname }}{{ if .Labels.severity }} ({{ .Labels.severity }}){{ end }}
|
||||
|
||||
{{ if .Annotations.summary }}**Summary:** {{ .Annotations.summary }}
|
||||
|
||||
{{ end }}{{ if .Annotations.description }}**Description:** {{ .Annotations.description }}
|
||||
|
||||
{{ end }}{{ if .GeneratorURL }}[View in SigNoz]({{ .GeneratorURL }})
|
||||
|
||||
{{ end }}{{ if .Annotations.related_logs }}[View related logs]({{ .Annotations.related_logs }})
|
||||
|
||||
{{ end }}{{ if .Annotations.related_traces }}[View related traces]({{ .Annotations.related_traces }})
|
||||
|
||||
{{ end }}{{ end }}`
|
||||
)
|
||||
|
||||
// JSMOpsReceiverConfig is the SigNoz Jira Service Management Ops receiver. It is
|
||||
// delivered by reusing the Opsgenie notifier (JSM Ops is the ex-Opsgenie alert
|
||||
// API): the notifier package maps these fields onto config.OpsGenieConfig with
|
||||
// APIURL pinned to JSMOpsAPIBaseURL.
|
||||
type JSMOpsReceiverConfig struct {
|
||||
config.NotifierConfig `yaml:",inline" json:",inline"`
|
||||
|
||||
HTTPConfig *commoncfg.HTTPClientConfig `yaml:"http_config,omitempty" json:"http_config,omitempty"`
|
||||
|
||||
APIKey config.Secret `yaml:"api_key,omitempty" json:"api_key,omitempty"`
|
||||
Message string `yaml:"message,omitempty" json:"message,omitempty"`
|
||||
Description string `yaml:"description,omitempty" json:"description,omitempty"`
|
||||
Priority string `yaml:"priority,omitempty" json:"priority,omitempty"`
|
||||
Tags string `yaml:"tags,omitempty" json:"tags,omitempty"`
|
||||
}
|
||||
|
||||
// send_resolved has no omitempty upstream, so a var default here is overwritten
|
||||
// by the yaml round-trip to the request value (false when omitted); the UI sends
|
||||
// it explicitly, defaulted on, so JSM alerts close on resolve.
|
||||
var DefaultJSMOpsReceiverConfig = JSMOpsReceiverConfig{
|
||||
NotifierConfig: config.NotifierConfig{
|
||||
VSendResolved: false,
|
||||
},
|
||||
Message: DefaultJSMOpsMessageTemplate,
|
||||
Description: DefaultJSMOpsDescriptionTemplate,
|
||||
Tags: "signoz",
|
||||
}
|
||||
|
||||
func (c *JSMOpsReceiverConfig) UnmarshalYAML(unmarshal func(any) error) error {
|
||||
*c = DefaultJSMOpsReceiverConfig
|
||||
type plain JSMOpsReceiverConfig
|
||||
if err := unmarshal((*plain)(c)); err != nil {
|
||||
return err
|
||||
}
|
||||
if c.APIKey == "" {
|
||||
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, "jsm ops api_key is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
70
pkg/types/alertmanagertypes/jsmops_test.go
Normal file
70
pkg/types/alertmanagertypes/jsmops_test.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package alertmanagertypes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestJSMOpsReceiverConfigDefaults(t *testing.T) {
|
||||
r, err := NewReceiver(`{"name":"jsm","jsmops_configs":[{"api_key":"key-123"}]}`)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, r.JSMOpsConfigs, 1)
|
||||
|
||||
c := r.JSMOpsConfigs[0]
|
||||
assert.Equal(t, "key-123", string(c.APIKey))
|
||||
assert.Equal(t, DefaultJSMOpsMessageTemplate, c.Message)
|
||||
assert.Equal(t, DefaultJSMOpsDescriptionTemplate, c.Description)
|
||||
assert.Equal(t, "signoz", c.Tags)
|
||||
assert.False(t, c.SendResolved()) // default off when omitted, like other channels
|
||||
|
||||
ch, err := NewChannelFromReceiver(r, "org-1")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "jsmops", ch.Type)
|
||||
}
|
||||
|
||||
func TestJSMOpsReceiverConfigOverrides(t *testing.T) {
|
||||
r, err := NewReceiver(`{"name":"jsm","jsmops_configs":[{"api_key":"k","message":"m","description":"d","priority":"P1","tags":"a,b","send_resolved":true}]}`)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, r.JSMOpsConfigs, 1)
|
||||
|
||||
c := r.JSMOpsConfigs[0]
|
||||
assert.Equal(t, "m", c.Message)
|
||||
assert.Equal(t, "d", c.Description)
|
||||
assert.Equal(t, "P1", c.Priority)
|
||||
assert.Equal(t, "a,b", c.Tags)
|
||||
assert.True(t, c.SendResolved())
|
||||
}
|
||||
|
||||
func TestJSMOpsReceiverConfigSendResolved(t *testing.T) {
|
||||
withSendResolved := func(v bool) string {
|
||||
return fmt.Sprintf(`{"name":"jsm","jsmops_configs":[{"api_key":"k","send_resolved":%t}]}`, v)
|
||||
}
|
||||
on, err := NewReceiver(withSendResolved(true))
|
||||
require.NoError(t, err)
|
||||
require.Len(t, on.JSMOpsConfigs, 1)
|
||||
assert.True(t, on.JSMOpsConfigs[0].SendResolved())
|
||||
|
||||
off, err := NewReceiver(withSendResolved(false))
|
||||
require.NoError(t, err)
|
||||
require.Len(t, off.JSMOpsConfigs, 1)
|
||||
assert.False(t, off.JSMOpsConfigs[0].SendResolved())
|
||||
}
|
||||
|
||||
func TestJSMOpsReceiverConfigValidation(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
json string
|
||||
}{
|
||||
{"missing api_key", `{"name":"jsm","jsmops_configs":[{"message":"m"}]}`},
|
||||
{"empty api_key", `{"name":"jsm","jsmops_configs":[{"api_key":""}]}`},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
_, err := NewReceiver(c.json)
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,11 @@ import (
|
||||
type Receiver struct {
|
||||
*config.Receiver
|
||||
GoogleChatConfigs []*GoogleChatReceiverConfig `json:"googlechat_configs,omitempty" yaml:"googlechat_configs,omitempty"`
|
||||
// Shadows upstream's jira_configs so our custom notifier (rich ADF, deep-links,
|
||||
// lifecycle comments) handles it instead of upstream's plain Jira notifier.
|
||||
JiraConfigs []*JiraReceiverConfig `json:"jira_configs,omitempty" yaml:"jira_configs,omitempty"`
|
||||
// JSM Ops (ex-Opsgenie alert API); delivered by reusing the Opsgenie notifier.
|
||||
JSMOpsConfigs []*JSMOpsReceiverConfig `json:"jsmops_configs,omitempty" yaml:"jsmops_configs,omitempty"`
|
||||
}
|
||||
|
||||
// NewReceiver builds a Receiver from its JSON input, applying each notifier
|
||||
@@ -51,6 +56,22 @@ func NewReceiver(input string) (*Receiver, error) {
|
||||
receiver.GoogleChatConfigs[i] = defaulted
|
||||
}
|
||||
|
||||
for i, jc := range receiver.JiraConfigs {
|
||||
defaulted, err := defaultedNotifierConfig(jc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
receiver.JiraConfigs[i] = defaulted
|
||||
}
|
||||
|
||||
for i, jc := range receiver.JSMOpsConfigs {
|
||||
defaulted, err := defaultedNotifierConfig(jc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
receiver.JSMOpsConfigs[i] = defaulted
|
||||
}
|
||||
|
||||
return receiver, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ var (
|
||||
ResourceMetaResourcePipeline = NewResourceMetaResource(KindPipeline)
|
||||
ResourceMetaResourceUserPreference = NewResourceMetaResource(KindUserPreference)
|
||||
ResourceMetaResourceOrgPreference = NewResourceMetaResource(KindOrgPreference)
|
||||
ResourceMetaResourceQuickFilter = NewResourceMetaResource(KindQuickFilter)
|
||||
ResourceMetaResourceQuickFilter = NewResourceMetaResource(KindQuickFilter, VerbList, VerbRead, VerbUpdate)
|
||||
ResourceMetaResourceTTLSetting = NewResourceMetaResource(KindTTLSetting)
|
||||
ResourceMetaResourceRule = NewResourceMetaResource(KindRule)
|
||||
ResourceMetaResourcePlannedMaintenance = NewResourceMetaResource(KindPlannedMaintenance)
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,51 +12,51 @@ import (
|
||||
"github.com/uptrace/bun"
|
||||
)
|
||||
|
||||
type Signal struct {
|
||||
type Source struct {
|
||||
valuer.String
|
||||
}
|
||||
|
||||
func (enum *Signal) UnmarshalJSON(data []byte) error {
|
||||
func (enum *Source) UnmarshalJSON(data []byte) error {
|
||||
var str string
|
||||
if err := json.Unmarshal(data, &str); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
signal, err := NewSignal(str)
|
||||
source, err := NewSource(str)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*enum = signal
|
||||
*enum = source
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
SignalTraces = Signal{valuer.NewString("traces")}
|
||||
SignalLogs = Signal{valuer.NewString("logs")}
|
||||
SignalApiMonitoring = Signal{valuer.NewString("api_monitoring")}
|
||||
SignalExceptions = Signal{valuer.NewString("exceptions")}
|
||||
SignalMeter = Signal{valuer.NewString("meter")}
|
||||
SignalAiObservability = Signal{valuer.NewString("ai_observability")}
|
||||
SourceTraces = Source{valuer.NewString("traces")}
|
||||
SourceLogs = Source{valuer.NewString("logs")}
|
||||
SourceApiMonitoring = Source{valuer.NewString("api_monitoring")}
|
||||
SourceExceptions = Source{valuer.NewString("exceptions")}
|
||||
SourceMeter = Source{valuer.NewString("meter")}
|
||||
SourceAiObservability = Source{valuer.NewString("ai_observability")}
|
||||
)
|
||||
|
||||
// NewSignal creates a Signal from a string.
|
||||
func NewSignal(s string) (Signal, error) {
|
||||
// NewSource creates a Source from a string.
|
||||
func NewSource(s string) (Source, error) {
|
||||
switch s {
|
||||
case "traces":
|
||||
return SignalTraces, nil
|
||||
return SourceTraces, nil
|
||||
case "logs":
|
||||
return SignalLogs, nil
|
||||
return SourceLogs, nil
|
||||
case "api_monitoring":
|
||||
return SignalApiMonitoring, nil
|
||||
return SourceApiMonitoring, nil
|
||||
case "exceptions":
|
||||
return SignalExceptions, nil
|
||||
return SourceExceptions, nil
|
||||
case "meter":
|
||||
return SignalMeter, nil
|
||||
return SourceMeter, nil
|
||||
case "ai_observability":
|
||||
return SignalAiObservability, nil
|
||||
return SourceAiObservability, nil
|
||||
default:
|
||||
return Signal{}, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid signal: %s", s)
|
||||
return Source{}, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "invalid source: %s", s)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,36 +65,30 @@ type StorableQuickFilter struct {
|
||||
types.Identifiable
|
||||
OrgID valuer.UUID `bun:"org_id,type:text,notnull"`
|
||||
Filter string `bun:"filter,type:text,notnull"`
|
||||
Signal Signal `bun:"signal,type:text,notnull"`
|
||||
Source Source `bun:"source,type:text,notnull"`
|
||||
types.TimeAuditable
|
||||
}
|
||||
|
||||
type SignalFilters struct {
|
||||
Signal Signal `json:"signal"`
|
||||
Filters []telemetrytypes.TelemetryFieldKey `json:"filters"`
|
||||
type SourceFilters struct {
|
||||
types.Identifiable
|
||||
types.TimeAuditable
|
||||
|
||||
OrgID valuer.UUID `json:"orgId"`
|
||||
Source Source `json:"source"`
|
||||
Filters []telemetrytypes.TelemetryFieldKey `json:"filters" required:"true" nullable:"false"`
|
||||
}
|
||||
|
||||
type UpdatableQuickFilters struct {
|
||||
Signal Signal `json:"signal"`
|
||||
Filters []telemetrytypes.TelemetryFieldKey `json:"filters"`
|
||||
}
|
||||
|
||||
func validateFilters(filters []telemetrytypes.TelemetryFieldKey) error {
|
||||
for _, filter := range filters {
|
||||
if filter.Name == "" {
|
||||
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "filter name is required")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
Filters []telemetrytypes.TelemetryFieldKey `json:"filters" required:"true" nullable:"false"`
|
||||
}
|
||||
|
||||
// NewStorableQuickFilter creates a new StorableQuickFilter after validation.
|
||||
func NewStorableQuickFilter(orgID valuer.UUID, signal Signal, filters []telemetrytypes.TelemetryFieldKey) (*StorableQuickFilter, error) {
|
||||
func NewStorableQuickFilter(orgID valuer.UUID, source Source, filters []telemetrytypes.TelemetryFieldKey) (*StorableQuickFilter, error) {
|
||||
if orgID.IsZero() {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "orgID is required")
|
||||
}
|
||||
|
||||
if _, err := NewSignal(signal.StringValue()); err != nil {
|
||||
if _, err := NewSource(source.StringValue()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -102,6 +96,12 @@ func NewStorableQuickFilter(orgID valuer.UUID, signal Signal, filters []telemetr
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// A nil slice marshals to the JSON literal "null"; store an empty array so
|
||||
// reads never have to render a null filter list.
|
||||
if filters == nil {
|
||||
filters = []telemetrytypes.TelemetryFieldKey{}
|
||||
}
|
||||
|
||||
filterJSON, err := json.Marshal(filters)
|
||||
if err != nil {
|
||||
return nil, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "error marshalling filters")
|
||||
@@ -113,7 +113,7 @@ func NewStorableQuickFilter(orgID valuer.UUID, signal Signal, filters []telemetr
|
||||
ID: valuer.GenerateUUID(),
|
||||
},
|
||||
OrgID: orgID,
|
||||
Signal: signal,
|
||||
Source: source,
|
||||
Filter: string(filterJSON),
|
||||
TimeAuditable: types.TimeAuditable{
|
||||
CreatedAt: now,
|
||||
@@ -122,21 +122,21 @@ func NewStorableQuickFilter(orgID valuer.UUID, signal Signal, filters []telemetr
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewSignalFiltersFromSignal creates a SignalFilters with no filters for a signal.
|
||||
func NewSignalFiltersFromSignal(signal Signal) *SignalFilters {
|
||||
return &SignalFilters{
|
||||
Signal: signal,
|
||||
// NewSourceFiltersFromSource creates a SourceFilters with no filters for a source.
|
||||
func NewSourceFiltersFromSource(source Source) *SourceFilters {
|
||||
return &SourceFilters{
|
||||
Source: source,
|
||||
Filters: []telemetrytypes.TelemetryFieldKey{},
|
||||
}
|
||||
}
|
||||
|
||||
// NewSignalFilterFromStorableQuickFilter converts a StorableQuickFilter to a SignalFilters object.
|
||||
func NewSignalFilterFromStorableQuickFilter(storableQuickFilter *StorableQuickFilter) (*SignalFilters, error) {
|
||||
// NewSourceFilterFromStorableQuickFilter converts a StorableQuickFilter to a SourceFilters object.
|
||||
func NewSourceFilterFromStorableQuickFilter(storableQuickFilter *StorableQuickFilter) (*SourceFilters, error) {
|
||||
if storableQuickFilter == nil {
|
||||
return nil, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "storableQuickFilter cannot be nil")
|
||||
}
|
||||
|
||||
var filters []telemetrytypes.TelemetryFieldKey
|
||||
filters := []telemetrytypes.TelemetryFieldKey{}
|
||||
if storableQuickFilter.Filter != "" {
|
||||
err := json.Unmarshal([]byte(storableQuickFilter.Filter), &filters)
|
||||
if err != nil {
|
||||
@@ -144,13 +144,22 @@ func NewSignalFilterFromStorableQuickFilter(storableQuickFilter *StorableQuickFi
|
||||
}
|
||||
}
|
||||
|
||||
return &SignalFilters{
|
||||
Signal: storableQuickFilter.Signal,
|
||||
Filters: filters,
|
||||
// Stored filter JSON can be the literal "null" (a nil slice was upserted),
|
||||
// which unmarshals to nil; the API contract requires a non-null array.
|
||||
if filters == nil {
|
||||
filters = []telemetrytypes.TelemetryFieldKey{}
|
||||
}
|
||||
|
||||
return &SourceFilters{
|
||||
Identifiable: storableQuickFilter.Identifiable,
|
||||
OrgID: storableQuickFilter.OrgID,
|
||||
Source: storableQuickFilter.Source,
|
||||
Filters: filters,
|
||||
TimeAuditable: storableQuickFilter.TimeAuditable,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewDefaultQuickFilter generates default filters for all supported signals.
|
||||
// NewDefaultQuickFilter generates default filters for all supported sources.
|
||||
func NewDefaultQuickFilter(orgID valuer.UUID) ([]*StorableQuickFilter, error) {
|
||||
tracesFilters := []telemetrytypes.TelemetryFieldKey{
|
||||
{Name: "duration_nano", FieldContext: telemetrytypes.FieldContextAttribute, FieldDataType: telemetrytypes.FieldDataTypeNumber},
|
||||
@@ -215,20 +224,20 @@ func NewDefaultQuickFilter(orgID valuer.UUID) ([]*StorableQuickFilter, error) {
|
||||
}
|
||||
|
||||
defaults := []struct {
|
||||
signal Signal
|
||||
source Source
|
||||
filters []telemetrytypes.TelemetryFieldKey
|
||||
}{
|
||||
{SignalTraces, tracesFilters},
|
||||
{SignalLogs, logsFilters},
|
||||
{SignalApiMonitoring, apiMonitoringFilters},
|
||||
{SignalExceptions, exceptionsFilters},
|
||||
{SignalMeter, meterFilters},
|
||||
{SignalAiObservability, aiObservabilityFilters},
|
||||
{SourceTraces, tracesFilters},
|
||||
{SourceLogs, logsFilters},
|
||||
{SourceApiMonitoring, apiMonitoringFilters},
|
||||
{SourceExceptions, exceptionsFilters},
|
||||
{SourceMeter, meterFilters},
|
||||
{SourceAiObservability, aiObservabilityFilters},
|
||||
}
|
||||
|
||||
storableQuickFilters := make([]*StorableQuickFilter, 0, len(defaults))
|
||||
for _, def := range defaults {
|
||||
storableQuickFilter, err := NewStorableQuickFilter(orgID, def.signal, def.filters)
|
||||
storableQuickFilter, err := NewStorableQuickFilter(orgID, def.source, def.filters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -237,3 +246,12 @@ func NewDefaultQuickFilter(orgID valuer.UUID) ([]*StorableQuickFilter, error) {
|
||||
|
||||
return storableQuickFilters, nil
|
||||
}
|
||||
|
||||
func validateFilters(filters []telemetrytypes.TelemetryFieldKey) error {
|
||||
for _, filter := range filters {
|
||||
if filter.Name == "" {
|
||||
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "filter name is required")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -10,10 +10,10 @@ type QuickFilterStore interface {
|
||||
// Get retrieves all filters for an organization
|
||||
Get(ctx context.Context, orgID valuer.UUID) ([]*StorableQuickFilter, error)
|
||||
|
||||
// GetBySignal retrieves filters for a specific signal in an organization
|
||||
GetBySignal(ctx context.Context, orgID valuer.UUID, signal string) (*StorableQuickFilter, error)
|
||||
// GetBySource retrieves filters for a specific source in an organization
|
||||
GetBySource(ctx context.Context, orgID valuer.UUID, source string) (*StorableQuickFilter, error)
|
||||
|
||||
// Upsert inserts or updates filters for an organization and signal
|
||||
// Upsert inserts or updates filters for an organization and source
|
||||
Upsert(ctx context.Context, filter *StorableQuickFilter) error
|
||||
Create(ctx context.Context, filter []*StorableQuickFilter) error
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
10
tests/fixtures/alerts.py
vendored
10
tests/fixtures/alerts.py
vendored
@@ -143,6 +143,16 @@ def wait_for_firing_timeline_entry(signoz: types.SigNoz, token: str, rule_id: st
|
||||
raise AssertionError(f"No firing entry recorded in rule state history within {wait_seconds}s, items: {items}")
|
||||
|
||||
|
||||
def get_rule(signoz: types.SigNoz, token: str, rule_id: str) -> dict:
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v2/rules/{rule_id}"),
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=5,
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK, f"Failed to get rule, api returned {response.status_code} with response: {response.text}"
|
||||
return response.json()["data"]
|
||||
|
||||
|
||||
def get_rule_history_top_contributors(signoz: types.SigNoz, token: str, rule_id: str, start_ms: int, end_ms: int) -> list[dict]:
|
||||
response = requests.get(
|
||||
signoz.self.host_configs["8080"].get(f"/api/v2/rules/{rule_id}/history/top_contributors"),
|
||||
|
||||
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
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user