Skip to content
Closed
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
26 changes: 26 additions & 0 deletions .chloggen/eng-7860-add-to-cli.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Use this changelog template to create an entry for release notes.

# One of 'breaking', 'deprecation', 'new_component', 'enhancement', 'bug_fix'
change_type: new_component

# The name of the component, or a single word describing the area of concern (e.g. dashboards, config, apply)
component: recording-rules

# A brief description of the change. Surround your text with quotes ("") if it needs to start with a backtick (`).
note: Add `recording-rules` command to manage Dash0 recording rules

# Mandatory: One or more tracking issues related to the change. You can use the PR number here if no issue exists.
issues: [7860]

# (Optional) One or more lines of additional information to render under the primary note.
# These lines will be padded with 2 spaces and then inserted directly into the document.
# Use pipe (|) for multiline entries.
subtext:

# If your change doesn't affect end users or the exported elements of any package,
# you should instead start your pull request title with "chore" or use the "Skip Changelog" label.
# Optional: The change log or logs in which this entry should be included.
# e.g. '[user]' or '[user, api]'
# Include 'user' if the change is relevant to end users.
# Default: '[user]'
change_logs: []
15 changes: 14 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ See the [agent mode specification](docs/commands.md#agent-mode) for the full pri

Apply asset definitions from a file, directory, or stdin.
The input may contain multiple YAML documents separated by `---`.
Supported asset types: `Dashboard`, `PersesDashboard`, `CheckRule`, `SyntheticCheck`, and `View`.
Supported asset types: `Dashboard`, `PersesDashboard`, `CheckRule`, `SyntheticCheck`, `View`, and `Dash0RecordingRuleGroup`.

```bash
dash0 apply -f assets.yaml
Expand Down Expand Up @@ -252,6 +252,19 @@ dash0 views update [id] -f view.yaml
dash0 views delete <id> [--force]
```

### Recording rules

```bash
dash0 recording-rules list
dash0 recording-rules get <id>
dash0 recording-rules get <id> -o yaml
dash0 recording-rules create -f recording-rule.yaml
dash0 recording-rules update [id] -f recording-rule.yaml
dash0 recording-rules delete <id> [--force]
```

Alias: `rr` (e.g. `dash0 rr list`)

### Logging

#### Sending logs to Dash0
Expand Down
2 changes: 2 additions & 0 deletions cmd/dash0/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"github.com/dash0hq/dash0-cli/internal/logging"
"github.com/dash0hq/dash0-cli/internal/members"
"github.com/dash0hq/dash0-cli/internal/metrics"
"github.com/dash0hq/dash0-cli/internal/recordingrulegroups"
"github.com/dash0hq/dash0-cli/internal/syntheticchecks"
"github.com/dash0hq/dash0-cli/internal/teams"
"github.com/dash0hq/dash0-cli/internal/tracing"
Expand Down Expand Up @@ -66,6 +67,7 @@ func init() {
rootCmd.AddCommand(logging.NewLogsCmd())
rootCmd.AddCommand(members.NewMembersCmd())
rootCmd.AddCommand(metrics.NewMetricsCmd())
rootCmd.AddCommand(recordingrulegroups.NewRecordingRuleGroupsCmd())
rootCmd.AddCommand(syntheticchecks.NewSyntheticChecksCmd())
rootCmd.AddCommand(teams.NewTeamsCmd())
rootCmd.AddCommand(tracing.NewSpansCmd())
Expand Down
23 changes: 12 additions & 11 deletions docs/cli-naming-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,12 @@ The reason for this is that the word "resource" is overloaded in OpenTelemetry,
Use the word "asset" consistently where appropriate.

## Top-level Asset Commands
- Use **plural form**: `dashboards`, `views`, `check-rules`, `synthetic-checks`
- Use **kebab-case** for multi-word names: `check-rules`, `synthetic-checks`
- Use **plural form**: `dashboards`, `views`, `check-rules`, `synthetic-checks`, `recording-rules`
- Use **kebab-case** for multi-word names: `check-rules`, `synthetic-checks`, `recording-rules`
- Group related functionality: `config profiles` for profile management

## Standard CRUD Subcommands for Assets
All asset commands (`dashboards`, `check-rules`, `views`, `synthetic-checks`) use these subcommands:
All asset commands (`dashboards`, `check-rules`, `views`, `synthetic-checks`, `recording-rules`) use these subcommands:

| Subcommand | Alias | Description |
|------------|----------|--------------------------------------|
Expand Down Expand Up @@ -64,14 +64,15 @@ The `members` command manages organization membership:
## Asset Kind Display Names
In user-facing output (success messages, dry-run listings, error messages), use human-readable names for asset kinds — **not** PascalCase identifiers:

| Kind identifier | Display name |
|--------------------|-----------------|
| `Dashboard` | Dashboard |
| `CheckRule` | Check rule |
| `SyntheticCheck` | Synthetic check |
| `View` | View |
| `PrometheusRule` | PrometheusRule |
| `PersesDashboard` | PersesDashboard |
| Kind identifier | Display name |
|------------------------------|----------------------|
| `Dashboard` | Dashboard |
| `CheckRule` | Check rule |
| `SyntheticCheck` | Synthetic check |
| `View` | View |
| `PrometheusRule` | PrometheusRule |
| `PersesDashboard` | PersesDashboard |
| `Dash0RecordingRuleGroup` | Recording rule |

For example: `Check rule "High Error Rate" created`, not `CheckRule "High Error Rate" created`.

Expand Down
32 changes: 30 additions & 2 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ Auth Token: ...ULSzVkM

Dash0 calls dashboards, views, synthetic checks, and check rules "assets" (not "resources", which is an overloaded term in OpenTelemetry).

All four asset types (`dashboards`, `check-rules`, `synthetic-checks`, `views`) share the same CRUD subcommands.
All five asset types (`dashboards`, `check-rules`, `recording-rules`, `synthetic-checks`, `views`) share the same CRUD subcommands.
The examples below use `dashboards`, but the same patterns apply to every asset type.

### `list`
Expand Down Expand Up @@ -381,6 +381,7 @@ Aliases: `remove`
|------------|---------|-------|
| Dashboards | `dash0 dashboards <subcommand>` | `create` also accepts PersesDashboard CRD files |
| Check rules | `dash0 check-rules <subcommand>` | `create` also accepts PrometheusRule CRD files |
| Recording rules | `dash0 recording-rules <subcommand>` | Alias: `rr` |
| Synthetic checks | `dash0 synthetic-checks <subcommand>` | |
| Views | `dash0 views <subcommand>` | |

Expand All @@ -406,7 +407,7 @@ Hidden files and directories (starting with `.`) are skipped.
All documents are validated before any are applied.
If any document fails validation, no changes are made.

Supported `kind` values: `Dashboard`, `PersesDashboard`, `CheckRule`, `PrometheusRule`, `SyntheticCheck`, `View`.
Supported `kind` values: `Dashboard`, `PersesDashboard`, `CheckRule`, `PrometheusRule`, `SyntheticCheck`, `View`, `Dash0RecordingRuleGroup`.
A single file may contain multiple documents separated by `---`.

> [!NOTE]
Expand Down Expand Up @@ -499,6 +500,33 @@ spec:
interval: 60s
```

Recording rule:

```yaml
kind: Dash0RecordingRuleGroup
metadata:
name: http_metrics
annotations:
dash0.com/folder-path: /infrastructure/http
dash0.com/sharing: "team:team_01abc,user:alice@example.com"
spec:
display:
name: HTTP Metrics
enabled: true
interval: 1m
rules:
- record: http_requests_total:rate5m
expression: rate(http_requests_total[5m])
labels:
env: production
- record: http_errors_total:rate5m
expression: rate(http_requests_total{status=~"5.."}[5m])
labels:
env: production
- record: http_request_duration_seconds:p99
expression: histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket[5m])) by (le, service_name))
```

Multi-document file (separated by `---`):

```yaml
Expand Down
2 changes: 1 addition & 1 deletion docs/project-structure.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
- `/internal/agentmode`: Agent mode detection and structured error output for AI coding agents
- `/internal/apply`: The `apply` command — orchestration only, delegates asset-specific logic to `internal/asset`
- `/internal/asset`: Shared asset logic (types, import functions, display helpers) used by both `apply` and the per-asset CRUD commands
- `/internal/checkrules`, `/internal/dashboards`, `/internal/syntheticchecks`, `/internal/views`: Per-asset CRUD commands — delegate asset-specific logic to `internal/asset`
- `/internal/checkrules`, `/internal/dashboards`, `/internal/recordingrulegroups`, `/internal/syntheticchecks`, `/internal/views`: Per-asset CRUD commands — delegate asset-specific logic to `internal/asset`
- `/internal/client`: API client factory and error handling
- `/internal/color`: Severity-aware color formatting for terminal output
- `/internal/confirmation`: Confirmation prompt for destructive operations (respects `--force` and agent mode)
Expand Down
2 changes: 1 addition & 1 deletion docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ The OpenAPI specification of the Dash0 API is available at `https://api-docs.das

## Fixture Location
- Fixtures are stored in `internal/testutil/fixtures/`
- Organized by asset type: `dashboards/`, `checkrules/`, `views/`, `syntheticchecks/`
- Organized by asset type: `dashboards/`, `checkrules/`, `views/`, `syntheticchecks/`, `recordingrulegroups/`
- Common fixture patterns: `list_success.json`, `list_empty.json`, `get_success.json`, `error_not_found.json`, `error_unauthorized.json`

## Generating Fixtures
Expand Down
27 changes: 25 additions & 2 deletions internal/apply/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ Supported asset types:
- CheckRule (or PrometheusRule CRD)
- SyntheticCheck
- View
- Dash0RecordingRuleGroup

If an asset exists, it will be updated. If it doesn't exist, it will be created.` + internal.CONFIG_HINT,
Example: ` # Apply a single asset
Expand Down Expand Up @@ -167,7 +168,7 @@ func runApply(ctx context.Context, flags *applyFlags) error {
if doc.kind == "" {
validationErrors = append(validationErrors, fmt.Sprintf("%s: missing 'kind' field", doc.location()))
} else if !isValidKind(doc.kind) {
validationErrors = append(validationErrors, fmt.Sprintf("%s: unsupported kind %q (supported: Dashboard, PersesDashboard, CheckRule, PrometheusRule, SyntheticCheck, View)", doc.location(), doc.kind))
validationErrors = append(validationErrors, fmt.Sprintf("%s: unsupported kind %q (supported: Dashboard, PersesDashboard, CheckRule, PrometheusRule, SyntheticCheck, View, Dash0RecordingRuleGroup)", doc.location(), doc.kind))
}
}
if len(validationErrors) > 0 {
Expand Down Expand Up @@ -327,6 +328,14 @@ func parseDocumentHeader(data []byte) (kind, name, id string, err error) {
name = dash0api.GetSyntheticCheckName(&check)
id = dash0api.GetSyntheticCheckID(&check)

case "recordingrulegroup":
var group dash0api.RecordingRuleGroupDefinition
if err := sigsyaml.Unmarshal(data, &group); err != nil {
return "", "", "", fmt.Errorf("failed to decode document: %w", err)
}
name = asset.ExtractRecordingRuleGroupName(&group)
id = asset.ExtractRecordingRuleGroupID(&group)

case "prometheusrule":
// We only need metadata (name + ID) here; the Metadata struct has no
// time.Duration fields, so a partial unmarshal via sigsyaml is safe.
Expand Down Expand Up @@ -536,7 +545,7 @@ func readDirectory(dirPath string) ([]assetDocument, error) {

func isValidKind(kind string) bool {
switch normalizeKind(kind) {
case "dashboard", "checkrule", "syntheticcheck", "view", "prometheusrule", "persesdashboard":
case "dashboard", "checkrule", "syntheticcheck", "view", "prometheusrule", "persesdashboard", "recordingrulegroup":
return true
default:
return false
Expand Down Expand Up @@ -620,6 +629,20 @@ func applyDocument(ctx context.Context, apiClient dash0api.Client, doc assetDocu
}
return []applyResult{{kind: doc.kind, name: result.Name, id: result.ID, action: applyAction(result.Action), before: result.Before, after: result.After}}, nil

case "recordingrulegroup":
var group dash0api.RecordingRuleGroupDefinition
if err := sigsyaml.Unmarshal(doc.raw, &group); err != nil {
return nil, fmt.Errorf("failed to parse Dash0RecordingRuleGroup: %w", err)
}
result, err := asset.ImportRecordingRuleGroup(ctx, apiClient, &group, dataset)
if err != nil {
return nil, client.HandleAPIError(err, client.ErrorContext{
AssetType: "recording rule",
AssetName: group.Metadata.Name,
})
}
return []applyResult{{kind: doc.kind, name: result.Name, id: result.ID, action: applyAction(result.Action), before: result.Before, after: result.After}}, nil

default:
return nil, fmt.Errorf("unsupported kind: %s", doc.kind)
}
Expand Down
7 changes: 7 additions & 0 deletions internal/asset/diff.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,13 @@ func marshalForDiff(asset any) (string, error) {
}
dash0api.StripSyntheticCheckServerFields(&c)
stripped = &c
case *dash0api.RecordingRuleGroupDefinition:
var g dash0api.RecordingRuleGroupDefinition
if err := sigsyaml.Unmarshal(jsonBytes, &g); err != nil {
return "", fmt.Errorf("failed to unmarshal recording rule: %w", err)
}
StripRecordingRuleGroupServerFields(&g)
stripped = &g
default:
stripped = asset
}
Expand Down
2 changes: 2 additions & 0 deletions internal/asset/kind.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ func KindDisplayName(kind string) string {
return "PrometheusRule"
case "persesdashboard":
return "PersesDashboard"
case "recordingrulegroup":
return "Recording rule"
default:
return kind
}
Expand Down
121 changes: 121 additions & 0 deletions internal/asset/recordingrulegroup.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package asset

import (
"context"

dash0api "github.com/dash0hq/dash0-api-client-go"
)

// StripRecordingRuleGroupServerFields removes server-generated fields from a
// recording rule definition. Used by both Import (to avoid sending
// rejected fields to the API) and diff rendering (to suppress noise).
//
// Fields NOT stripped (user-specified, round-trippable):
// - dash0.com/origin — external identifier set by Terraform/operator/user
// - dash0.com/dataset — dataset routing, user-supplied
// - annotations.dash0.com/folder-path — optional UI folder path
// - annotations.dash0.com/sharing — optional sharing config
func StripRecordingRuleGroupServerFields(g *dash0api.RecordingRuleGroupDefinition) {
if g.Metadata.Labels != nil {
g.Metadata.Labels.Dash0Comid = nil
g.Metadata.Labels.Dash0Comversion = nil
g.Metadata.Labels.Dash0Comsource = nil
}
if g.Metadata.Annotations != nil {
g.Metadata.Annotations.Dash0ComcreatedAt = nil
g.Metadata.Annotations.Dash0ComdeletedAt = nil
g.Metadata.Annotations.Dash0ComupdatedAt = nil
}
g.Spec.Permissions = nil
g.Spec.PermittedActions = nil
}

// InjectRecordingRuleGroupDataset injects the dataset into metadata.labels, which
// is how Create and Update receive the dataset (no query param for these endpoints).
// If dataset is nil or empty, the existing value in the file is left unchanged.
func InjectRecordingRuleGroupDataset(group *dash0api.RecordingRuleGroupDefinition, dataset *string) {
if dataset == nil || *dataset == "" {
return
}
if group.Metadata.Labels == nil {
group.Metadata.Labels = &dash0api.RecordingRuleGroupLabels{}
}
group.Metadata.Labels.Dash0Comdataset = dataset
}

// InjectRecordingRuleGroupVersion copies the dash0.com/version label from source
// into group before an update call, to satisfy the API's optimistic concurrency check.
func InjectRecordingRuleGroupVersion(group, source *dash0api.RecordingRuleGroupDefinition) {
if source.Metadata.Labels == nil || source.Metadata.Labels.Dash0Comversion == nil {
return
}
if group.Metadata.Labels == nil {
group.Metadata.Labels = &dash0api.RecordingRuleGroupLabels{}
}
group.Metadata.Labels.Dash0Comversion = source.Metadata.Labels.Dash0Comversion
}

// ImportRecordingRuleGroup checks existence by origin or ID, strips
// server-generated fields, injects the dataset into the body, and creates or
// updates the recording rule via the standard CRUD APIs.
func ImportRecordingRuleGroup(ctx context.Context, apiClient dash0api.Client, group *dash0api.RecordingRuleGroupDefinition, dataset *string) (ImportResult, error) {
StripRecordingRuleGroupServerFields(group)

// Override dataset in body if --dataset flag was provided.
InjectRecordingRuleGroupDataset(group, dataset)

action := ActionCreated
var before any
id := ExtractRecordingRuleGroupID(group)
if id != "" {
existing, err := apiClient.GetRecordingRuleGroup(ctx, id, dataset)
if err == nil {
action = ActionUpdated
before = existing
// Inject the current version for optimistic concurrency control.
InjectRecordingRuleGroupVersion(group, existing)
} else {
// Asset not found — clear the origin so the API creates a fresh asset.
group.Metadata.Labels.Dash0Comorigin = nil
id = ""
}
}

var result *dash0api.RecordingRuleGroupDefinition
var err error
if action == ActionUpdated {
result, err = apiClient.UpdateRecordingRuleGroup(ctx, id, group)
} else {
result, err = apiClient.CreateRecordingRuleGroup(ctx, group)
}
if err != nil {
return ImportResult{}, err
}

resultID := ExtractRecordingRuleGroupID(result)
return ImportResult{Name: ExtractRecordingRuleGroupName(result), ID: resultID, Action: action, Before: before, After: result}, nil
}

// ExtractRecordingRuleGroupID extracts the external-facing ID from a recording
// rule definition. The origin label (set by Terraform/operator) is
// preferred; the internal UUID is used as a fallback.
func ExtractRecordingRuleGroupID(group *dash0api.RecordingRuleGroupDefinition) string {
if group.Metadata.Labels != nil {
if group.Metadata.Labels.Dash0Comorigin != nil && *group.Metadata.Labels.Dash0Comorigin != "" {
return *group.Metadata.Labels.Dash0Comorigin
}
if group.Metadata.Labels.Dash0Comid != nil && *group.Metadata.Labels.Dash0Comid != "" {
return *group.Metadata.Labels.Dash0Comid
}
}
return ""
}

// ExtractRecordingRuleGroupName extracts the display name from a recording
// rule definition, falling back to metadata.name if no display name is set.
func ExtractRecordingRuleGroupName(group *dash0api.RecordingRuleGroupDefinition) string {
if group.Spec.Display.Name != "" {
return group.Spec.Display.Name
}
return group.Metadata.Name
}
Loading
Loading