Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions docs/components/Dash0.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { CardGrid, LinkCard } from "@astrojs/starlight/components";
<CardGrid>
<LinkCard title="List Issues" href="#list-issues" description="Query Dash0 to get a list of all current issues using the metric dash0.issue.status" />
<LinkCard title="Query Prometheus" href="#query-prometheus" description="Execute a PromQL query against Dash0 Prometheus API and return the response data" />
<LinkCard title="Update Check Rule" href="#update-check-rule" description="Update an existing check rule in Dash0 configuration API" />
</CardGrid>

<a id="list-issues"></a>
Expand Down Expand Up @@ -150,3 +151,48 @@ Returns the Prometheus query response including:
}
```

<a id="update-check-rule"></a>

## 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"
}
```

107 changes: 107 additions & 0 deletions pkg/integrations/dash0/check_rule.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package dash0

import (
"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.
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"`
}

// buildCheckRuleSpecification validates and normalizes the check rule payload.
func buildCheckRuleSpecification(config UpsertCheckRuleConfiguration, scope string) (map[string]any, error) {
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)
}

if _, exists := normalized[key]; exists {
return nil, fmt.Errorf("%s: %s[%d].key %q is duplicated", scope, fieldName, index, key)
}

normalized[key] = strings.TrimSpace(pair.Value)
}

return normalized, 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
}
60 changes: 59 additions & 1 deletion pkg/integrations/dash0/client.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package dash0

import (
"bytes"
"encoding/json"
"fmt"
"io"
Expand Down Expand Up @@ -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"`
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
50 changes: 50 additions & 0 deletions pkg/integrations/dash0/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
})
}
1 change: 1 addition & 0 deletions pkg/integrations/dash0/dash0.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ func (d *Dash0) Components() []core.Component {
return []core.Component{
&QueryPrometheus{},
&ListIssues{},
&UpdateCheckRule{},
}
}

Expand Down
11 changes: 11 additions & 0 deletions pkg/integrations/dash0/example.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,21 @@ 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)
}

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)
}
11 changes: 11 additions & 0 deletions pkg/integrations/dash0/example_output_update_check_rule.json
Original file line number Diff line number Diff line change
@@ -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"
}
Loading