From ce5798b6dd297b54c382c509cc3aec9552a166ab Mon Sep 17 00:00:00 2001 From: Fadhili Juma Date: Wed, 11 Feb 2026 02:41:07 +0300 Subject: [PATCH 1/3] feat: add Dash0 Update Check Rule action Signed-off-by: Fadhili Juma --- docs/components/Dash0.mdx | 42 +++- pkg/integrations/dash0/dash0.go | 1 + pkg/integrations/dash0/example.go | 11 ++ .../example_output_update_check_rule.json | 11 ++ pkg/integrations/dash0/update_check_rule.go | 182 ++++++++++++++++++ .../dash0/update_check_rule_test.go | 74 +++++++ .../pages/workflowv2/mappers/dash0/index.ts | 3 + .../mappers/dash0/update_check_rule.ts | 54 ++++++ 8 files changed, 377 insertions(+), 1 deletion(-) create mode 100644 pkg/integrations/dash0/example_output_update_check_rule.json create mode 100644 pkg/integrations/dash0/update_check_rule.go create mode 100644 pkg/integrations/dash0/update_check_rule_test.go create mode 100644 web_src/src/pages/workflowv2/mappers/dash0/update_check_rule.ts diff --git a/docs/components/Dash0.mdx b/docs/components/Dash0.mdx index faacc53df3..103a0878a0 100644 --- a/docs/components/Dash0.mdx +++ b/docs/components/Dash0.mdx @@ -11,8 +11,49 @@ import { CardGrid, LinkCard } from "@astrojs/starlight/components"; + + + +## Update Check Rule + +The Update Check Rule component updates an existing Dash0 check rule. + +### Use Cases + +- **Threshold tuning**: Adjust alert sensitivity as service behavior changes +- **Noise reduction**: Update rules to avoid unnecessary notifications +- **Progressive rollout**: Modify rule thresholds during canary/rollout workflows + +### Configuration + +- **Check Rule**: Existing check rule origin/ID +- **Rule Specification (JSON)**: Updated check rule payload as JSON object. + Accepts Dash0 check rule shape (name + expression) or Prometheus-style + groups/rules shape with exactly one alert rule. + +### Output + +Emits: +- **originOrId**: Check rule identifier used for the API request +- **response**: Raw Dash0 API response + +### Example Output + +```json +{ + "data": { + "originOrId": "superplane-check-rule-ef45gh67", + "response": { + "id": "superplane-check-rule-ef45gh67", + "status": "updated" + } + }, + "timestamp": "2026-02-09T12:00:00Z", + "type": "dash0.check.rule.updated" +} +``` ## List Issues @@ -149,4 +190,3 @@ Returns the Prometheus query response including: "type": "dash0.prometheus.response" } ``` - diff --git a/pkg/integrations/dash0/dash0.go b/pkg/integrations/dash0/dash0.go index a5f96699e3..e3d578fe40 100644 --- a/pkg/integrations/dash0/dash0.go +++ b/pkg/integrations/dash0/dash0.go @@ -69,6 +69,7 @@ func (d *Dash0) Components() []core.Component { return []core.Component{ &QueryPrometheus{}, &ListIssues{}, + &UpdateCheckRule{}, } } diff --git a/pkg/integrations/dash0/example.go b/pkg/integrations/dash0/example.go index a2737e8e53..e69e7c35ba 100644 --- a/pkg/integrations/dash0/example.go +++ b/pkg/integrations/dash0/example.go @@ -19,6 +19,12 @@ var exampleOutputListIssuesBytes []byte var exampleOutputListIssuesOnce sync.Once var exampleOutputListIssues map[string]any +//go:embed example_output_update_check_rule.json +var exampleOutputUpdateCheckRuleBytes []byte + +var exampleOutputUpdateCheckRuleOnce sync.Once +var exampleOutputUpdateCheckRule map[string]any + func (c *QueryPrometheus) ExampleOutput() map[string]any { return utils.UnmarshalEmbeddedJSON(&exampleOutputQueryPrometheusOnce, exampleOutputQueryPrometheusBytes, &exampleOutputQueryPrometheus) } @@ -26,3 +32,8 @@ func (c *QueryPrometheus) ExampleOutput() map[string]any { func (c *ListIssues) ExampleOutput() map[string]any { return utils.UnmarshalEmbeddedJSON(&exampleOutputListIssuesOnce, exampleOutputListIssuesBytes, &exampleOutputListIssues) } + +// ExampleOutput returns sample output data for Update Check Rule. +func (c *UpdateCheckRule) ExampleOutput() map[string]any { + return utils.UnmarshalEmbeddedJSON(&exampleOutputUpdateCheckRuleOnce, exampleOutputUpdateCheckRuleBytes, &exampleOutputUpdateCheckRule) +} diff --git a/pkg/integrations/dash0/example_output_update_check_rule.json b/pkg/integrations/dash0/example_output_update_check_rule.json new file mode 100644 index 0000000000..8daf797b6d --- /dev/null +++ b/pkg/integrations/dash0/example_output_update_check_rule.json @@ -0,0 +1,11 @@ +{ + "data": { + "originOrId": "checkout-errors", + "response": { + "id": "checkout-errors", + "status": "updated" + } + }, + "timestamp": "2026-02-09T12:00:00Z", + "type": "dash0.check.rule.updated" +} diff --git a/pkg/integrations/dash0/update_check_rule.go b/pkg/integrations/dash0/update_check_rule.go new file mode 100644 index 0000000000..34c7b4a9f0 --- /dev/null +++ b/pkg/integrations/dash0/update_check_rule.go @@ -0,0 +1,182 @@ +package dash0 + +import ( + "fmt" + "net/http" + + "github.com/google/uuid" + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" +) + +const UpdateCheckRulePayloadType = "dash0.check.rule.updated" + +// UpdateCheckRule updates existing Dash0 check rules via configuration API. +type UpdateCheckRule struct{} + +// Name returns the stable component identifier. +func (c *UpdateCheckRule) Name() string { + return "dash0.updateCheckRule" +} + +// Label returns the display name used in the workflow builder. +func (c *UpdateCheckRule) Label() string { + return "Update Check Rule" +} + +// Description returns a short summary of component behavior. +func (c *UpdateCheckRule) Description() string { + return "Update an existing check rule in Dash0 configuration API" +} + +// Documentation returns markdown help shown in the component docs panel. +func (c *UpdateCheckRule) Documentation() string { + return `The Update Check Rule component updates an existing Dash0 check rule. + +## Use Cases + +- **Threshold tuning**: Adjust alert sensitivity as service behavior changes +- **Rule maintenance**: Update labels, annotations, and notification routing +- **Operational automation**: Enable or disable rules from workflows + +## Configuration + +- **Check Rule**: Existing check rule origin/ID +- **Rule Specification (JSON)**: Updated check rule payload as JSON object. + Accepts Dash0 check rule shape (name + expression) or Prometheus-style + groups/rules shape with exactly one alert rule. + +## Output + +Emits: +- **originOrId**: Check rule identifier used for the API request +- **response**: Raw Dash0 API response` +} + +// Icon returns the Lucide icon name for this component. +func (c *UpdateCheckRule) Icon() string { + return "refresh-cw" +} + +// Color returns the node color used in the UI. +func (c *UpdateCheckRule) Color() string { + return "blue" +} + +// OutputChannels declares the channel emitted by this action. +func (c *UpdateCheckRule) OutputChannels(configuration any) []core.OutputChannel { + return []core.OutputChannel{core.DefaultOutputChannel} +} + +// Configuration defines fields required to update check rules. +func (c *UpdateCheckRule) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "originOrId", + Label: "Check Rule", + Type: configuration.FieldTypeIntegrationResource, + Required: true, + Togglable: true, + TypeOptions: &configuration.TypeOptions{ + Resource: &configuration.ResourceTypeOptions{ + Type: "check-rule", + }, + }, + Description: "Check rule origin/ID to update", + }, + { + Name: "spec", + Label: "Rule Specification (JSON)", + Type: configuration.FieldTypeText, + Required: true, + Description: "Updated check rule specification as a JSON object", + Placeholder: "{\"name\":\"Checkout errors\",\"expression\":\"sum(rate(http_requests_total{service=\\\"checkout\\\",status=~\\\"5..\\\"}[5m])) > 1\",\"for\":\"10m\",\"labels\":{\"severity\":\"critical\"},\"annotations\":{\"summary\":\"Checkout 5xx errors are critical\"}}", + }, + } +} + +// Setup validates component configuration during save/setup. +func (c *UpdateCheckRule) Setup(ctx core.SetupContext) error { + scope := "dash0.updateCheckRule setup" + config := UpsertCheckRuleConfiguration{} + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return fmt.Errorf("%s: decode configuration: %w", scope, err) + } + + if _, err := requireNonEmptyValue(config.OriginOrID, "originOrId", scope); err != nil { + return err + } + + if _, err := parseCheckRuleSpecification(config.Spec, "spec", scope); err != nil { + return err + } + + return nil +} + +// ProcessQueueItem delegates queue processing to default behavior. +func (c *UpdateCheckRule) ProcessQueueItem(ctx core.ProcessQueueContext) (*uuid.UUID, error) { + return ctx.DefaultProcessing() +} + +// Execute updates a check rule and emits API response payload. +func (c *UpdateCheckRule) Execute(ctx core.ExecutionContext) error { + scope := "dash0.updateCheckRule execute" + config := UpsertCheckRuleConfiguration{} + if err := mapstructure.Decode(ctx.Configuration, &config); err != nil { + return fmt.Errorf("%s: decode configuration: %w", scope, err) + } + + originOrID, err := requireNonEmptyValue(config.OriginOrID, "originOrId", scope) + if err != nil { + return err + } + + specification, err := parseCheckRuleSpecification(config.Spec, "spec", scope) + if err != nil { + return err + } + + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return fmt.Errorf("%s: create client: %w", scope, err) + } + + response, err := client.UpsertCheckRule(originOrID, specification) + if err != nil { + return fmt.Errorf("%s: update check rule %q: %w", scope, originOrID, err) + } + + payload := map[string]any{ + "originOrId": originOrID, + "response": response, + } + + return ctx.ExecutionState.Emit(core.DefaultOutputChannel.Name, UpdateCheckRulePayloadType, []any{payload}) +} + +// Actions returns no manual actions for this component. +func (c *UpdateCheckRule) Actions() []core.Action { + return []core.Action{} +} + +// HandleAction is unused because this component has no actions. +func (c *UpdateCheckRule) HandleAction(ctx core.ActionContext) error { + return nil +} + +// HandleWebhook is unused because this component does not receive webhooks. +func (c *UpdateCheckRule) HandleWebhook(ctx core.WebhookRequestContext) (int, error) { + return http.StatusOK, nil +} + +// Cancel is a no-op because execution is synchronous and short-lived. +func (c *UpdateCheckRule) Cancel(ctx core.ExecutionContext) error { + return nil +} + +// Cleanup is a no-op because no external resources are provisioned. +func (c *UpdateCheckRule) Cleanup(ctx core.SetupContext) error { + return nil +} diff --git a/pkg/integrations/dash0/update_check_rule_test.go b/pkg/integrations/dash0/update_check_rule_test.go new file mode 100644 index 0000000000..a27a17350f --- /dev/null +++ b/pkg/integrations/dash0/update_check_rule_test.go @@ -0,0 +1,74 @@ +package dash0 + +import ( + "encoding/json" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/superplanehq/superplane/pkg/core" + "github.com/superplanehq/superplane/test/support/contexts" +) + +func Test__UpdateCheckRule__Setup(t *testing.T) { + component := UpdateCheckRule{} + + t.Run("origin is required", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{ + "originOrId": "", + "spec": `{"groups":[]}`, + }, + }) + + require.ErrorContains(t, err, "originOrId is required") + }) +} + +func Test__UpdateCheckRule__Execute(t *testing.T) { + component := UpdateCheckRule{} + + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"status":"updated"}`)), + }, + }, + } + + execCtx := &contexts.ExecutionStateContext{} + err := component.Execute(core.ExecutionContext{ + Configuration: map[string]any{ + "originOrId": "checkout-errors", + "spec": `{"name":"CheckoutErrors","expression":"sum(rate(http_requests_total{service=\"checkout\",status=~\"5..\"}[5m])) > 1","for":"10m","labels":{"severity":"critical"},"annotations":{"summary":"Checkout errors are critical"}}`, + }, + HTTP: httpContext, + Integration: &contexts.IntegrationContext{ + Configuration: map[string]any{ + "apiToken": "token123", + "baseURL": "https://api.us-west-2.aws.dash0.com", + }, + }, + ExecutionState: execCtx, + }) + + require.NoError(t, err) + assert.Equal(t, UpdateCheckRulePayloadType, execCtx.Type) + require.Len(t, httpContext.Requests, 1) + assert.Equal(t, http.MethodPut, httpContext.Requests[0].Method) + assert.Contains(t, httpContext.Requests[0].URL.String(), "/api/alerting/check-rules/checkout-errors") + assert.Equal(t, "default", httpContext.Requests[0].URL.Query().Get("dataset")) + + requestBody, readErr := io.ReadAll(httpContext.Requests[0].Body) + require.NoError(t, readErr) + + var payload map[string]any + require.NoError(t, json.Unmarshal(requestBody, &payload)) + assert.Equal(t, "CheckoutErrors", payload["name"]) + assert.Equal(t, "sum(rate(http_requests_total{service=\"checkout\",status=~\"5..\"}[5m])) > 1", payload["expression"]) + assert.Equal(t, "10m", payload["for"]) +} diff --git a/web_src/src/pages/workflowv2/mappers/dash0/index.ts b/web_src/src/pages/workflowv2/mappers/dash0/index.ts index fd9865aafd..79e9ae0e2d 100644 --- a/web_src/src/pages/workflowv2/mappers/dash0/index.ts +++ b/web_src/src/pages/workflowv2/mappers/dash0/index.ts @@ -4,10 +4,12 @@ import { withOrganizationHeader } from "@/utils/withOrganizationHeader"; import { queryPrometheusMapper } from "./query_prometheus"; import { listIssuesMapper, LIST_ISSUES_STATE_REGISTRY } from "./list_issues"; import { buildActionStateRegistry } from "../utils"; +import { updateCheckRuleMapper } from "./update_check_rule"; export const componentMappers: Record = { queryPrometheus: queryPrometheusMapper, listIssues: listIssuesMapper, + updateCheckRule: updateCheckRuleMapper, }; export const triggerRenderers: Record = {}; @@ -15,6 +17,7 @@ export const triggerRenderers: Record = {}; export const eventStateRegistry: Record = { listIssues: LIST_ISSUES_STATE_REGISTRY, queryPrometheus: buildActionStateRegistry("queried"), + updateCheckRule: buildActionStateRegistry("updated"), }; export async function resolveExecutionErrors(canvasId: string, executionIds: string[]) { diff --git a/web_src/src/pages/workflowv2/mappers/dash0/update_check_rule.ts b/web_src/src/pages/workflowv2/mappers/dash0/update_check_rule.ts new file mode 100644 index 0000000000..16c7011f56 --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/dash0/update_check_rule.ts @@ -0,0 +1,54 @@ +import { ComponentBaseProps } from "@/ui/componentBase"; +import { getStateMap } from ".."; +import { + ComponentBaseContext, + ComponentBaseMapper, + ExecutionDetailsContext, + NodeInfo, + SubtitleContext, +} from "../types"; +import dash0Icon from "@/assets/icons/integrations/dash0.svg"; +import { MetadataItem } from "@/ui/metadataList"; +import { formatTimeAgo } from "@/utils/date"; +import { buildDash0EventSections, buildDash0ExecutionDetails } from "./base"; +import { UpsertCheckRuleConfiguration } from "./types"; + +export const updateCheckRuleMapper: ComponentBaseMapper = { + props(context: ComponentBaseContext): ComponentBaseProps { + const lastExecution = context.lastExecutions.length > 0 ? context.lastExecutions[0] : null; + const componentName = context.componentDefinition.name || "unknown"; + + return { + iconSrc: dash0Icon, + collapsedBackground: "bg-white", + collapsed: context.node.isCollapsed, + title: context.node.name || context.componentDefinition.label || "Update Check Rule", + eventSections: lastExecution ? buildDash0EventSections(context.nodes, lastExecution, componentName) : undefined, + metadata: metadataList(context.node), + includeEmptyState: !lastExecution, + eventStateMap: getStateMap(componentName), + }; + }, + + getExecutionDetails(context: ExecutionDetailsContext): Record { + return buildDash0ExecutionDetails(context.execution, "Update Response"); + }, + + subtitle(context: SubtitleContext): string { + return formatTimeAgo(new Date(context.execution.createdAt!)); + }, +}; + +function metadataList(node: NodeInfo): MetadataItem[] { + const metadata: MetadataItem[] = []; + const configuration = node.configuration as UpsertCheckRuleConfiguration; + + if (configuration?.originOrId) { + metadata.push({ + icon: "hash", + label: configuration.originOrId, + }); + } + + return metadata; +} From 5faf3723851f23347a532b549468f5a736eef0ca Mon Sep 17 00:00:00 2001 From: Fadhili Juma Date: Thu, 12 Feb 2026 14:52:20 +0300 Subject: [PATCH 2/3] feat: replace update check rule JSON spec with form fields Signed-off-by: Fadhili Juma --- docs/components/Dash0.mdx | 86 +++--- pkg/integrations/dash0/check_rule.go | 268 ++++++++++++++++++ pkg/integrations/dash0/client.go | 60 +++- pkg/integrations/dash0/client_test.go | 50 ++++ pkg/integrations/dash0/update_check_rule.go | 119 +++++++- .../dash0/update_check_rule_test.go | 39 ++- .../pages/workflowv2/mappers/dash0/types.ts | 17 ++ .../mappers/dash0/update_check_rule.ts | 49 +++- 8 files changed, 631 insertions(+), 57 deletions(-) create mode 100644 pkg/integrations/dash0/check_rule.go diff --git a/docs/components/Dash0.mdx b/docs/components/Dash0.mdx index 103a0878a0..75e7bf8f54 100644 --- a/docs/components/Dash0.mdx +++ b/docs/components/Dash0.mdx @@ -14,46 +14,6 @@ import { CardGrid, LinkCard } from "@astrojs/starlight/components"; - - -## Update Check Rule - -The Update Check Rule component updates an existing Dash0 check rule. - -### Use Cases - -- **Threshold tuning**: Adjust alert sensitivity as service behavior changes -- **Noise reduction**: Update rules to avoid unnecessary notifications -- **Progressive rollout**: Modify rule thresholds during canary/rollout workflows - -### Configuration - -- **Check Rule**: Existing check rule origin/ID -- **Rule Specification (JSON)**: Updated check rule payload as JSON object. - Accepts Dash0 check rule shape (name + expression) or Prometheus-style - groups/rules shape with exactly one alert rule. - -### Output - -Emits: -- **originOrId**: Check rule identifier used for the API request -- **response**: Raw Dash0 API response - -### Example Output - -```json -{ - "data": { - "originOrId": "superplane-check-rule-ef45gh67", - "response": { - "id": "superplane-check-rule-ef45gh67", - "status": "updated" - } - }, - "timestamp": "2026-02-09T12:00:00Z", - "type": "dash0.check.rule.updated" -} -``` ## List Issues @@ -190,3 +150,49 @@ Returns the Prometheus query response including: "type": "dash0.prometheus.response" } ``` + + + +## Update Check Rule + +The Update Check Rule component updates an existing Dash0 check rule. + +### Use Cases + +- **Threshold tuning**: Adjust alert sensitivity as service behavior changes +- **Rule maintenance**: Update labels, annotations, and notification routing +- **Operational automation**: Enable or disable rules from workflows + +### Configuration + +- **Check Rule**: Existing check rule origin/ID +- **Name**: Human-readable rule name +- **Expression**: Prometheus expression used by the rule +- **For (Optional)**: How long expression must remain true before firing +- **Interval (Optional)**: Evaluation interval override +- **Keep Firing For (Optional)**: Additional duration to keep firing after recovery +- **Labels (Optional)**: Label key/value pairs +- **Annotations (Optional)**: Annotation key/value pairs + +### Output + +Emits: +- **originOrId**: Check rule identifier used for the API request +- **response**: Raw Dash0 API response + +### Example Output + +```json +{ + "data": { + "originOrId": "checkout-errors", + "response": { + "id": "checkout-errors", + "status": "updated" + } + }, + "timestamp": "2026-02-09T12:00:00Z", + "type": "dash0.check.rule.updated" +} +``` + diff --git a/pkg/integrations/dash0/check_rule.go b/pkg/integrations/dash0/check_rule.go new file mode 100644 index 0000000000..73b1cceead --- /dev/null +++ b/pkg/integrations/dash0/check_rule.go @@ -0,0 +1,268 @@ +package dash0 + +import ( + "encoding/json" + "fmt" + "strings" +) + +// CheckRuleKeyValue represents a single key/value entry for labels or annotations. +type CheckRuleKeyValue struct { + Key string `json:"key" mapstructure:"key"` + Value string `json:"value" mapstructure:"value"` +} + +// UpsertCheckRuleConfiguration contains user input for the check rule upsert actions. +// +// Spec is kept for backward compatibility with existing saved workflows that still +// provide raw JSON. New flows should use the structured form fields. +type UpsertCheckRuleConfiguration struct { + OriginOrID string `json:"originOrId" mapstructure:"originOrId"` + Name string `json:"name" mapstructure:"name"` + Expression string `json:"expression" mapstructure:"expression"` + For string `json:"for" mapstructure:"for"` + Interval string `json:"interval" mapstructure:"interval"` + KeepFiringFor string `json:"keepFiringFor" mapstructure:"keepFiringFor"` + Labels []CheckRuleKeyValue `json:"labels" mapstructure:"labels"` + Annotations []CheckRuleKeyValue `json:"annotations" mapstructure:"annotations"` + Spec string `json:"spec" mapstructure:"spec"` +} + +// buildCheckRuleSpecification validates and normalizes the check rule payload. +func buildCheckRuleSpecification(config UpsertCheckRuleConfiguration, scope string) (map[string]any, error) { + if strings.TrimSpace(config.Spec) != "" { + return parseCheckRuleSpecification(config.Spec, "spec", scope) + } + + ruleName, err := requireNonEmptyValue(config.Name, "name", scope) + if err != nil { + return nil, err + } + + expression, err := requireNonEmptyValue(config.Expression, "expression", scope) + if err != nil { + return nil, err + } + + specification := map[string]any{ + "name": ruleName, + "expression": expression, + } + + addOptionalStringField(specification, "for", config.For) + addOptionalStringField(specification, "interval", config.Interval) + addOptionalStringField(specification, "keepFiringFor", config.KeepFiringFor) + + labels, err := normalizeKeyValuePairs(config.Labels, "labels", scope) + if err != nil { + return nil, err + } + if len(labels) > 0 { + specification["labels"] = labels + } + + annotations, err := normalizeKeyValuePairs(config.Annotations, "annotations", scope) + if err != nil { + return nil, err + } + if len(annotations) > 0 { + specification["annotations"] = annotations + } + + return specification, nil +} + +// addOptionalStringField adds a field when the provided value is non-empty. +func addOptionalStringField(target map[string]any, fieldName, value string) { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return + } + + target[fieldName] = trimmed +} + +// normalizeKeyValuePairs validates and normalizes list-based key/value entries. +func normalizeKeyValuePairs(pairs []CheckRuleKeyValue, fieldName, scope string) (map[string]string, error) { + if len(pairs) == 0 { + return nil, nil + } + + normalized := make(map[string]string, len(pairs)) + for index, pair := range pairs { + key := strings.TrimSpace(pair.Key) + if key == "" { + return nil, fmt.Errorf("%s: %s[%d].key is required", scope, fieldName, index) + } + + normalized[key] = strings.TrimSpace(pair.Value) + } + + if len(normalized) == 0 { + return nil, nil + } + + return normalized, nil +} + +// parseCheckRuleSpecification parses and validates backward-compatible JSON specs. +func parseCheckRuleSpecification(specification, fieldName, scope string) (map[string]any, error) { + parsed, err := parseSpecification(specification, fieldName, scope) + if err != nil { + return nil, err + } + + return validateDash0CheckRuleSpecification(parsed, fieldName, scope) +} + +// parseSpecification parses a JSON object or a single-item JSON array containing one object. +func parseSpecification(specification, fieldName, scope string) (map[string]any, error) { + trimmed := strings.TrimSpace(specification) + if trimmed == "" { + return nil, fmt.Errorf("%s: %s is required", scope, fieldName) + } + + var payload map[string]any + objectErr := json.Unmarshal([]byte(trimmed), &payload) + if objectErr == nil { + if len(payload) == 0 { + return nil, fmt.Errorf("%s: %s cannot be an empty JSON object", scope, fieldName) + } + + return payload, nil + } + + var payloadArray []map[string]any + if err := json.Unmarshal([]byte(trimmed), &payloadArray); err == nil { + if len(payloadArray) == 0 { + return nil, fmt.Errorf("%s: %s cannot be an empty JSON array", scope, fieldName) + } + if len(payloadArray) > 1 { + return nil, fmt.Errorf("%s: %s must be a JSON object or a single-item JSON array", scope, fieldName) + } + if len(payloadArray[0]) == 0 { + return nil, fmt.Errorf("%s: %s cannot contain an empty JSON object", scope, fieldName) + } + + return payloadArray[0], nil + } + + return nil, fmt.Errorf("%s: parse %s as JSON object: %w", scope, fieldName, objectErr) +} + +// validateDash0CheckRuleSpecification enforces required fields and normalizes aliases. +func validateDash0CheckRuleSpecification(specification map[string]any, fieldName, scope string) (map[string]any, error) { + ruleName, ok := firstNonEmptyMappedString(specification, "name", "alert") + if !ok { + return nil, fmt.Errorf("%s: %s.name is required", scope, fieldName) + } + specification["name"] = ruleName + delete(specification, "alert") + + expression, ok := firstNonEmptyMappedString(specification, "expression", "expr") + if !ok { + return nil, fmt.Errorf("%s: %s.expression is required", scope, fieldName) + } + specification["expression"] = expression + delete(specification, "expr") + + if keepFiringValue, ok := firstNonEmptyMappedString(specification, "keepFiringFor", "keep_firing_for"); ok { + specification["keepFiringFor"] = keepFiringValue + } + delete(specification, "keep_firing_for") + + if intervalValue, ok := firstNonEmptyMappedString(specification, "interval"); ok { + specification["interval"] = intervalValue + } + + if forValue, ok := firstNonEmptyMappedString(specification, "for"); ok { + specification["for"] = forValue + } + + labels, err := extractStringMapField(specification, "labels", fieldName, scope) + if err != nil { + return nil, err + } + if len(labels) > 0 { + specification["labels"] = labels + } + + annotations, err := extractStringMapField(specification, "annotations", fieldName, scope) + if err != nil { + return nil, err + } + if len(annotations) > 0 { + specification["annotations"] = annotations + } + + return specification, nil +} + +// requireNonEmptyValue trims and validates required string values. +func requireNonEmptyValue(value, fieldName, scope string) (string, error) { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "", fmt.Errorf("%s: %s is required", scope, fieldName) + } + + return trimmed, nil +} + +// optionalStringValue returns a trimmed non-empty string value from the provided map key. +func optionalStringValue(values map[string]any, key string) (string, bool) { + rawValue, ok := values[key] + if !ok || rawValue == nil { + return "", false + } + + stringValue, ok := rawValue.(string) + if !ok { + return "", false + } + + trimmed := strings.TrimSpace(stringValue) + if trimmed == "" { + return "", false + } + + return trimmed, true +} + +// firstNonEmptyMappedString returns the first non-empty string value for the provided keys. +func firstNonEmptyMappedString(values map[string]any, keys ...string) (string, bool) { + for _, key := range keys { + if value, ok := optionalStringValue(values, key); ok { + return value, true + } + } + + return "", false +} + +// extractStringMapField validates and normalizes object fields with string values only. +func extractStringMapField(values map[string]any, key, fieldName, scope string) (map[string]string, error) { + rawValue, ok := values[key] + if !ok || rawValue == nil { + return nil, nil + } + + fieldPath := fmt.Sprintf("%s.%s", fieldName, key) + switch typed := rawValue.(type) { + case map[string]string: + return typed, nil + case map[string]any: + normalized := make(map[string]string, len(typed)) + for mapKey, mapValue := range typed { + stringValue, isString := mapValue.(string) + if !isString { + return nil, fmt.Errorf("%s: %s.%s must be a string", scope, fieldPath, mapKey) + } + + normalized[mapKey] = stringValue + } + + return normalized, nil + default: + return nil, fmt.Errorf("%s: %s must be a JSON object of string values", scope, fieldPath) + } +} diff --git a/pkg/integrations/dash0/client.go b/pkg/integrations/dash0/client.go index 7a16eb1baa..5566e6b2ff 100644 --- a/pkg/integrations/dash0/client.go +++ b/pkg/integrations/dash0/client.go @@ -1,6 +1,7 @@ package dash0 import ( + "bytes" "encoding/json" "fmt" "io" @@ -86,6 +87,20 @@ func (c *Client) execRequest(method, url string, body io.Reader, contentType str return responseBody, nil } +// withDefaultDatasetQuery appends dataset=default to Dash0 configuration API requests. +func withDefaultDatasetQuery(requestURL string) (string, error) { + parsedURL, err := url.Parse(requestURL) + if err != nil { + return "", fmt.Errorf("error parsing request URL: %v", err) + } + + query := parsedURL.Query() + query.Set("dataset", "default") + parsedURL.RawQuery = query.Encode() + + return parsedURL.String(), nil +} + type PrometheusResponse struct { Status string `json:"status"` Data PrometheusResponseData `json:"data"` @@ -168,8 +183,12 @@ type CheckRule struct { func (c *Client) ListCheckRules() ([]CheckRule, error) { apiURL := fmt.Sprintf("%s/api/alerting/check-rules", c.BaseURL) + requestURL, err := withDefaultDatasetQuery(apiURL) + if err != nil { + return nil, fmt.Errorf("error setting check rules dataset query: %v", err) + } - responseBody, err := c.execRequest(http.MethodGet, apiURL, nil, "") + responseBody, err := c.execRequest(http.MethodGet, requestURL, nil, "") if err != nil { return nil, err } @@ -207,3 +226,42 @@ func (c *Client) ListCheckRules() ([]CheckRule, error) { return checkRules, nil } + +// UpsertCheckRule creates or updates a Dash0 check rule by origin/ID. +func (c *Client) UpsertCheckRule(originOrID string, specification map[string]any) (map[string]any, error) { + trimmedOriginOrID := strings.TrimSpace(originOrID) + if trimmedOriginOrID == "" { + return nil, fmt.Errorf("check rule originOrId is required") + } + + if len(specification) == 0 { + return nil, fmt.Errorf("check rule specification is required") + } + + requestBody, err := json.Marshal(specification) + if err != nil { + return nil, fmt.Errorf("error serializing check rule specification: %v", err) + } + + apiURL := fmt.Sprintf("%s/api/alerting/check-rules/%s", c.BaseURL, url.PathEscape(trimmedOriginOrID)) + requestURL, err := withDefaultDatasetQuery(apiURL) + if err != nil { + return nil, fmt.Errorf("error setting check rule dataset query: %v", err) + } + + responseBody, err := c.execRequest(http.MethodPut, requestURL, bytes.NewReader(requestBody), "application/json") + if err != nil { + return nil, err + } + + if len(responseBody) == 0 { + return map[string]any{}, nil + } + + var response map[string]any + if err := json.Unmarshal(responseBody, &response); err != nil { + return nil, fmt.Errorf("error parsing check rule upsert response: %v", err) + } + + return response, nil +} diff --git a/pkg/integrations/dash0/client_test.go b/pkg/integrations/dash0/client_test.go index fd33e0c43a..f19908c3ec 100644 --- a/pkg/integrations/dash0/client_test.go +++ b/pkg/integrations/dash0/client_test.go @@ -210,3 +210,53 @@ func Test__Client__ExecutePrometheusRangeQuery(t *testing.T) { assert.Contains(t, httpContext.Requests[0].URL.String(), "/api/prometheus/api/v1/query_range") }) } + +func Test__Client__UpsertCheckRule(t *testing.T) { + t.Run("successful upsert", func(t *testing.T) { + httpContext := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"status":"updated"}`)), + }, + }, + } + + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "apiToken": "token123", + "baseURL": "https://api.us-west-2.aws.dash0.com", + }, + } + + client, err := NewClient(httpContext, integrationCtx) + require.NoError(t, err) + + response, err := client.UpsertCheckRule("checkout-errors", map[string]any{ + "name": "CheckoutErrors", + "expression": "sum(rate(http_requests_total[5m])) > 1", + }) + require.NoError(t, err) + assert.Equal(t, "updated", response["status"]) + require.Len(t, httpContext.Requests, 1) + assert.Equal(t, http.MethodPut, httpContext.Requests[0].Method) + assert.Contains(t, httpContext.Requests[0].URL.String(), "/api/alerting/check-rules/checkout-errors") + assert.Equal(t, "default", httpContext.Requests[0].URL.Query().Get("dataset")) + }) + + t.Run("missing origin id", func(t *testing.T) { + httpContext := &contexts.HTTPContext{} + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "apiToken": "token123", + "baseURL": "https://api.us-west-2.aws.dash0.com", + }, + } + + client, err := NewClient(httpContext, integrationCtx) + require.NoError(t, err) + + _, err = client.UpsertCheckRule(" ", map[string]any{"name": "Rule", "expression": "up > 0"}) + require.ErrorContains(t, err, "originOrId is required") + }) +} diff --git a/pkg/integrations/dash0/update_check_rule.go b/pkg/integrations/dash0/update_check_rule.go index 34c7b4a9f0..0405b5b59a 100644 --- a/pkg/integrations/dash0/update_check_rule.go +++ b/pkg/integrations/dash0/update_check_rule.go @@ -43,9 +43,13 @@ func (c *UpdateCheckRule) Documentation() string { ## Configuration - **Check Rule**: Existing check rule origin/ID -- **Rule Specification (JSON)**: Updated check rule payload as JSON object. - Accepts Dash0 check rule shape (name + expression) or Prometheus-style - groups/rules shape with exactly one alert rule. +- **Name**: Human-readable rule name +- **Expression**: Prometheus expression used by the rule +- **For (Optional)**: How long expression must remain true before firing +- **Interval (Optional)**: Evaluation interval override +- **Keep Firing For (Optional)**: Additional duration to keep firing after recovery +- **Labels (Optional)**: Label key/value pairs +- **Annotations (Optional)**: Annotation key/value pairs ## Output @@ -86,12 +90,109 @@ func (c *UpdateCheckRule) Configuration() []configuration.Field { Description: "Check rule origin/ID to update", }, { - Name: "spec", - Label: "Rule Specification (JSON)", + Name: "name", + Label: "Rule Name", + Type: configuration.FieldTypeString, + Required: true, + Description: "Name of the check rule", + Placeholder: "Checkout errors", + }, + { + Name: "expression", + Label: "Expression", Type: configuration.FieldTypeText, Required: true, - Description: "Updated check rule specification as a JSON object", - Placeholder: "{\"name\":\"Checkout errors\",\"expression\":\"sum(rate(http_requests_total{service=\\\"checkout\\\",status=~\\\"5..\\\"}[5m])) > 1\",\"for\":\"10m\",\"labels\":{\"severity\":\"critical\"},\"annotations\":{\"summary\":\"Checkout 5xx errors are critical\"}}", + Description: "Prometheus expression evaluated by the rule", + Placeholder: "sum(rate(http_requests_total{service=\"checkout\",status=~\"5..\"}[5m])) > 1", + }, + { + Name: "for", + Label: "For", + Type: configuration.FieldTypeString, + Required: false, + Togglable: true, + Description: "Optional firing delay duration (for example: 5m)", + Placeholder: "5m", + }, + { + Name: "interval", + Label: "Interval", + Type: configuration.FieldTypeString, + Required: false, + Togglable: true, + Description: "Optional evaluation interval override (for example: 1m)", + Placeholder: "1m", + }, + { + Name: "keepFiringFor", + Label: "Keep Firing For", + Type: configuration.FieldTypeString, + Required: false, + Togglable: true, + Description: "Optional extra duration to keep alert firing after recovery", + Placeholder: "10m", + }, + { + Name: "labels", + Label: "Labels", + Type: configuration.FieldTypeList, + Required: false, + Togglable: true, + Description: "Optional label key/value pairs", + TypeOptions: &configuration.TypeOptions{ + List: &configuration.ListTypeOptions{ + ItemLabel: "Label", + ItemDefinition: &configuration.ListItemDefinition{ + Type: configuration.FieldTypeObject, + Schema: []configuration.Field{ + { + Name: "key", + Label: "Key", + Type: configuration.FieldTypeString, + Required: true, + DisallowExpression: true, + }, + { + Name: "value", + Label: "Value", + Type: configuration.FieldTypeString, + Required: true, + }, + }, + }, + }, + }, + }, + { + Name: "annotations", + Label: "Annotations", + Type: configuration.FieldTypeList, + Required: false, + Togglable: true, + Description: "Optional annotation key/value pairs", + TypeOptions: &configuration.TypeOptions{ + List: &configuration.ListTypeOptions{ + ItemLabel: "Annotation", + ItemDefinition: &configuration.ListItemDefinition{ + Type: configuration.FieldTypeObject, + Schema: []configuration.Field{ + { + Name: "key", + Label: "Key", + Type: configuration.FieldTypeString, + Required: true, + DisallowExpression: true, + }, + { + Name: "value", + Label: "Value", + Type: configuration.FieldTypeString, + Required: true, + }, + }, + }, + }, + }, }, } } @@ -108,7 +209,7 @@ func (c *UpdateCheckRule) Setup(ctx core.SetupContext) error { return err } - if _, err := parseCheckRuleSpecification(config.Spec, "spec", scope); err != nil { + if _, err := buildCheckRuleSpecification(config, scope); err != nil { return err } @@ -133,7 +234,7 @@ func (c *UpdateCheckRule) Execute(ctx core.ExecutionContext) error { return err } - specification, err := parseCheckRuleSpecification(config.Spec, "spec", scope) + specification, err := buildCheckRuleSpecification(config, scope) if err != nil { return err } diff --git a/pkg/integrations/dash0/update_check_rule_test.go b/pkg/integrations/dash0/update_check_rule_test.go index a27a17350f..269fd11ad6 100644 --- a/pkg/integrations/dash0/update_check_rule_test.go +++ b/pkg/integrations/dash0/update_check_rule_test.go @@ -20,12 +20,35 @@ func Test__UpdateCheckRule__Setup(t *testing.T) { err := component.Setup(core.SetupContext{ Configuration: map[string]any{ "originOrId": "", - "spec": `{"groups":[]}`, + "name": "Checkout errors", + "expression": `sum(rate(http_requests_total{service="checkout",status=~"5.."}[5m])) > 1`, }, }) require.ErrorContains(t, err, "originOrId is required") }) + + t.Run("name is required", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{ + "originOrId": "checkout-errors", + "expression": `sum(rate(http_requests_total{service="checkout",status=~"5.."}[5m])) > 1`, + }, + }) + + require.ErrorContains(t, err, "name is required") + }) + + t.Run("legacy spec remains supported", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{ + "originOrId": "checkout-errors", + "spec": `{"alert":"CheckoutErrors","expr":"sum(rate(http_requests_total{service=\"checkout\",status=~\"5..\"}[5m])) > 1"}`, + }, + }) + + require.NoError(t, err) + }) } func Test__UpdateCheckRule__Execute(t *testing.T) { @@ -44,7 +67,16 @@ func Test__UpdateCheckRule__Execute(t *testing.T) { err := component.Execute(core.ExecutionContext{ Configuration: map[string]any{ "originOrId": "checkout-errors", - "spec": `{"name":"CheckoutErrors","expression":"sum(rate(http_requests_total{service=\"checkout\",status=~\"5..\"}[5m])) > 1","for":"10m","labels":{"severity":"critical"},"annotations":{"summary":"Checkout errors are critical"}}`, + "name": "CheckoutErrors", + "expression": `sum(rate(http_requests_total{service="checkout",status=~"5.."}[5m])) > 1`, + "for": "10m", + "interval": "1m", + "labels": []map[string]any{ + {"key": "severity", "value": "critical"}, + }, + "annotations": []map[string]any{ + {"key": "summary", "value": "Checkout errors are critical"}, + }, }, HTTP: httpContext, Integration: &contexts.IntegrationContext{ @@ -71,4 +103,7 @@ func Test__UpdateCheckRule__Execute(t *testing.T) { assert.Equal(t, "CheckoutErrors", payload["name"]) assert.Equal(t, "sum(rate(http_requests_total{service=\"checkout\",status=~\"5..\"}[5m])) > 1", payload["expression"]) assert.Equal(t, "10m", payload["for"]) + assert.Equal(t, "1m", payload["interval"]) + assert.Equal(t, map[string]any{"severity": "critical"}, payload["labels"]) + assert.Equal(t, map[string]any{"summary": "Checkout errors are critical"}, payload["annotations"]) } diff --git a/web_src/src/pages/workflowv2/mappers/dash0/types.ts b/web_src/src/pages/workflowv2/mappers/dash0/types.ts index f26ca938e8..ebf6ec4ede 100644 --- a/web_src/src/pages/workflowv2/mappers/dash0/types.ts +++ b/web_src/src/pages/workflowv2/mappers/dash0/types.ts @@ -15,6 +15,23 @@ export interface ListIssuesConfiguration { checkRules?: string[]; } +export interface CheckRuleKeyValueConfiguration { + key: string; + value: string; +} + +export interface UpsertCheckRuleConfiguration { + originOrId?: string; + name?: string; + expression?: string; + for?: string; + interval?: string; + keepFiringFor?: string; + labels?: CheckRuleKeyValueConfiguration[]; + annotations?: CheckRuleKeyValueConfiguration[]; + spec?: string; +} + export interface PrometheusResponse { status: string; data: { diff --git a/web_src/src/pages/workflowv2/mappers/dash0/update_check_rule.ts b/web_src/src/pages/workflowv2/mappers/dash0/update_check_rule.ts index 16c7011f56..c5a4cf2296 100644 --- a/web_src/src/pages/workflowv2/mappers/dash0/update_check_rule.ts +++ b/web_src/src/pages/workflowv2/mappers/dash0/update_check_rule.ts @@ -1,16 +1,17 @@ -import { ComponentBaseProps } from "@/ui/componentBase"; -import { getStateMap } from ".."; +import { ComponentBaseProps, EventSection } from "@/ui/componentBase"; +import { getState, getStateMap, getTriggerRenderer } from ".."; import { ComponentBaseContext, ComponentBaseMapper, ExecutionDetailsContext, + ExecutionInfo, NodeInfo, + OutputPayload, SubtitleContext, } from "../types"; import dash0Icon from "@/assets/icons/integrations/dash0.svg"; import { MetadataItem } from "@/ui/metadataList"; import { formatTimeAgo } from "@/utils/date"; -import { buildDash0EventSections, buildDash0ExecutionDetails } from "./base"; import { UpsertCheckRuleConfiguration } from "./types"; export const updateCheckRuleMapper: ComponentBaseMapper = { @@ -23,7 +24,7 @@ export const updateCheckRuleMapper: ComponentBaseMapper = { collapsedBackground: "bg-white", collapsed: context.node.isCollapsed, title: context.node.name || context.componentDefinition.label || "Update Check Rule", - eventSections: lastExecution ? buildDash0EventSections(context.nodes, lastExecution, componentName) : undefined, + eventSections: lastExecution ? baseEventSections(context.nodes, lastExecution, componentName) : undefined, metadata: metadataList(context.node), includeEmptyState: !lastExecution, eventStateMap: getStateMap(componentName), @@ -31,7 +32,29 @@ export const updateCheckRuleMapper: ComponentBaseMapper = { }, getExecutionDetails(context: ExecutionDetailsContext): Record { - return buildDash0ExecutionDetails(context.execution, "Update Response"); + const outputs = context.execution.outputs as { default?: OutputPayload[] } | undefined; + if (!outputs || !outputs.default || outputs.default.length === 0) { + return { Response: "No data returned" }; + } + + const payload = outputs.default[0]; + const responseData = payload?.data as Record | undefined; + if (!responseData) { + return { Response: "No data returned" }; + } + + const details: Record = {}; + if (payload?.timestamp) { + details["Updated At"] = new Date(payload.timestamp).toLocaleString(); + } + + try { + details["Update Response"] = JSON.stringify(responseData, null, 2); + } catch { + details["Update Response"] = String(responseData); + } + + return details; }, subtitle(context: SubtitleContext): string { @@ -52,3 +75,19 @@ function metadataList(node: NodeInfo): MetadataItem[] { return metadata; } + +function baseEventSections(nodes: NodeInfo[], execution: ExecutionInfo, componentName: string): EventSection[] { + const rootTriggerNode = nodes.find((node) => node.id === execution.rootEvent?.nodeId); + const rootTriggerRenderer = getTriggerRenderer(rootTriggerNode?.componentName || ""); + const { title } = rootTriggerRenderer.getTitleAndSubtitle({ event: execution.rootEvent }); + + return [ + { + receivedAt: new Date(execution.createdAt!), + eventTitle: title, + eventSubtitle: formatTimeAgo(new Date(execution.createdAt!)), + eventState: getState(componentName)(execution), + eventId: execution.rootEvent!.id!, + }, + ]; +} From 283ffa8a9fe4cda28b05395375e9501a38f48f4b Mon Sep 17 00:00:00 2001 From: fadhilijuma Date: Thu, 19 Feb 2026 17:55:00 +0300 Subject: [PATCH 3/3] fix(dash0): remove check rule spec fallback Signed-off-by: fadhilijuma --- pkg/integrations/dash0/check_rule.go | 169 +----------------- .../dash0/update_check_rule_test.go | 11 +- 2 files changed, 12 insertions(+), 168 deletions(-) diff --git a/pkg/integrations/dash0/check_rule.go b/pkg/integrations/dash0/check_rule.go index 73b1cceead..ad3720edc9 100644 --- a/pkg/integrations/dash0/check_rule.go +++ b/pkg/integrations/dash0/check_rule.go @@ -1,7 +1,6 @@ package dash0 import ( - "encoding/json" "fmt" "strings" ) @@ -13,9 +12,6 @@ type CheckRuleKeyValue struct { } // UpsertCheckRuleConfiguration contains user input for the check rule upsert actions. -// -// Spec is kept for backward compatibility with existing saved workflows that still -// provide raw JSON. New flows should use the structured form fields. type UpsertCheckRuleConfiguration struct { OriginOrID string `json:"originOrId" mapstructure:"originOrId"` Name string `json:"name" mapstructure:"name"` @@ -25,15 +21,10 @@ type UpsertCheckRuleConfiguration struct { KeepFiringFor string `json:"keepFiringFor" mapstructure:"keepFiringFor"` Labels []CheckRuleKeyValue `json:"labels" mapstructure:"labels"` Annotations []CheckRuleKeyValue `json:"annotations" mapstructure:"annotations"` - Spec string `json:"spec" mapstructure:"spec"` } // buildCheckRuleSpecification validates and normalizes the check rule payload. func buildCheckRuleSpecification(config UpsertCheckRuleConfiguration, scope string) (map[string]any, error) { - if strings.TrimSpace(config.Spec) != "" { - return parseCheckRuleSpecification(config.Spec, "spec", scope) - } - ruleName, err := requireNonEmptyValue(config.Name, "name", scope) if err != nil { return nil, err @@ -95,107 +86,14 @@ func normalizeKeyValuePairs(pairs []CheckRuleKeyValue, fieldName, scope string) return nil, fmt.Errorf("%s: %s[%d].key is required", scope, fieldName, index) } - normalized[key] = strings.TrimSpace(pair.Value) - } - - if len(normalized) == 0 { - return nil, nil - } - - return normalized, nil -} - -// parseCheckRuleSpecification parses and validates backward-compatible JSON specs. -func parseCheckRuleSpecification(specification, fieldName, scope string) (map[string]any, error) { - parsed, err := parseSpecification(specification, fieldName, scope) - if err != nil { - return nil, err - } - - return validateDash0CheckRuleSpecification(parsed, fieldName, scope) -} - -// parseSpecification parses a JSON object or a single-item JSON array containing one object. -func parseSpecification(specification, fieldName, scope string) (map[string]any, error) { - trimmed := strings.TrimSpace(specification) - if trimmed == "" { - return nil, fmt.Errorf("%s: %s is required", scope, fieldName) - } - - var payload map[string]any - objectErr := json.Unmarshal([]byte(trimmed), &payload) - if objectErr == nil { - if len(payload) == 0 { - return nil, fmt.Errorf("%s: %s cannot be an empty JSON object", scope, fieldName) + if _, exists := normalized[key]; exists { + return nil, fmt.Errorf("%s: %s[%d].key %q is duplicated", scope, fieldName, index, key) } - return payload, nil - } - - var payloadArray []map[string]any - if err := json.Unmarshal([]byte(trimmed), &payloadArray); err == nil { - if len(payloadArray) == 0 { - return nil, fmt.Errorf("%s: %s cannot be an empty JSON array", scope, fieldName) - } - if len(payloadArray) > 1 { - return nil, fmt.Errorf("%s: %s must be a JSON object or a single-item JSON array", scope, fieldName) - } - if len(payloadArray[0]) == 0 { - return nil, fmt.Errorf("%s: %s cannot contain an empty JSON object", scope, fieldName) - } - - return payloadArray[0], nil - } - - return nil, fmt.Errorf("%s: parse %s as JSON object: %w", scope, fieldName, objectErr) -} - -// validateDash0CheckRuleSpecification enforces required fields and normalizes aliases. -func validateDash0CheckRuleSpecification(specification map[string]any, fieldName, scope string) (map[string]any, error) { - ruleName, ok := firstNonEmptyMappedString(specification, "name", "alert") - if !ok { - return nil, fmt.Errorf("%s: %s.name is required", scope, fieldName) - } - specification["name"] = ruleName - delete(specification, "alert") - - expression, ok := firstNonEmptyMappedString(specification, "expression", "expr") - if !ok { - return nil, fmt.Errorf("%s: %s.expression is required", scope, fieldName) - } - specification["expression"] = expression - delete(specification, "expr") - - if keepFiringValue, ok := firstNonEmptyMappedString(specification, "keepFiringFor", "keep_firing_for"); ok { - specification["keepFiringFor"] = keepFiringValue - } - delete(specification, "keep_firing_for") - - if intervalValue, ok := firstNonEmptyMappedString(specification, "interval"); ok { - specification["interval"] = intervalValue - } - - if forValue, ok := firstNonEmptyMappedString(specification, "for"); ok { - specification["for"] = forValue - } - - labels, err := extractStringMapField(specification, "labels", fieldName, scope) - if err != nil { - return nil, err - } - if len(labels) > 0 { - specification["labels"] = labels - } - - annotations, err := extractStringMapField(specification, "annotations", fieldName, scope) - if err != nil { - return nil, err - } - if len(annotations) > 0 { - specification["annotations"] = annotations + normalized[key] = strings.TrimSpace(pair.Value) } - return specification, nil + return normalized, nil } // requireNonEmptyValue trims and validates required string values. @@ -207,62 +105,3 @@ func requireNonEmptyValue(value, fieldName, scope string) (string, error) { return trimmed, nil } - -// optionalStringValue returns a trimmed non-empty string value from the provided map key. -func optionalStringValue(values map[string]any, key string) (string, bool) { - rawValue, ok := values[key] - if !ok || rawValue == nil { - return "", false - } - - stringValue, ok := rawValue.(string) - if !ok { - return "", false - } - - trimmed := strings.TrimSpace(stringValue) - if trimmed == "" { - return "", false - } - - return trimmed, true -} - -// firstNonEmptyMappedString returns the first non-empty string value for the provided keys. -func firstNonEmptyMappedString(values map[string]any, keys ...string) (string, bool) { - for _, key := range keys { - if value, ok := optionalStringValue(values, key); ok { - return value, true - } - } - - return "", false -} - -// extractStringMapField validates and normalizes object fields with string values only. -func extractStringMapField(values map[string]any, key, fieldName, scope string) (map[string]string, error) { - rawValue, ok := values[key] - if !ok || rawValue == nil { - return nil, nil - } - - fieldPath := fmt.Sprintf("%s.%s", fieldName, key) - switch typed := rawValue.(type) { - case map[string]string: - return typed, nil - case map[string]any: - normalized := make(map[string]string, len(typed)) - for mapKey, mapValue := range typed { - stringValue, isString := mapValue.(string) - if !isString { - return nil, fmt.Errorf("%s: %s.%s must be a string", scope, fieldPath, mapKey) - } - - normalized[mapKey] = stringValue - } - - return normalized, nil - default: - return nil, fmt.Errorf("%s: %s must be a JSON object of string values", scope, fieldPath) - } -} diff --git a/pkg/integrations/dash0/update_check_rule_test.go b/pkg/integrations/dash0/update_check_rule_test.go index 269fd11ad6..a6d43904d9 100644 --- a/pkg/integrations/dash0/update_check_rule_test.go +++ b/pkg/integrations/dash0/update_check_rule_test.go @@ -39,15 +39,20 @@ func Test__UpdateCheckRule__Setup(t *testing.T) { require.ErrorContains(t, err, "name is required") }) - t.Run("legacy spec remains supported", func(t *testing.T) { + t.Run("duplicate label keys are rejected", func(t *testing.T) { err := component.Setup(core.SetupContext{ Configuration: map[string]any{ "originOrId": "checkout-errors", - "spec": `{"alert":"CheckoutErrors","expr":"sum(rate(http_requests_total{service=\"checkout\",status=~\"5..\"}[5m])) > 1"}`, + "name": "Checkout errors", + "expression": `sum(rate(http_requests_total{service="checkout",status=~"5.."}[5m])) > 1`, + "labels": []map[string]any{ + {"key": "severity", "value": "critical"}, + {"key": "severity", "value": "warning"}, + }, }, }) - require.NoError(t, err) + require.ErrorContains(t, err, `labels[1].key "severity" is duplicated`) }) }