Skip to content
Draft
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
28 changes: 28 additions & 0 deletions .chloggen/slos.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# 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: slos

# A brief description of the change. Surround your text with quotes ("") if it needs to start with a backtick (`).
note: Add `dash0 slos` commands to manage service level objectives (SLOs) as code

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

# (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: |
SLO documents use the OpenSLO v1 format (`apiVersion: openslo.com/v1`, `kind: SLO`) and are dataset-scoped via `--dataset`.
The commands support `create`, `list`, `get`, `update`, and `delete`, and SLOs can also be managed through `dash0 apply`.

# 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: []
2 changes: 2 additions & 0 deletions cmd/dash0/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import (
"github.com/dash0hq/dash0-cli/internal/otlp"
"github.com/dash0hq/dash0-cli/internal/rawapi"
"github.com/dash0hq/dash0-cli/internal/recordingrules"
"github.com/dash0hq/dash0-cli/internal/slos"
"github.com/dash0hq/dash0-cli/internal/spamfilters"
"github.com/dash0hq/dash0-cli/internal/syntheticchecks"
"github.com/dash0hq/dash0-cli/internal/teams"
Expand Down Expand Up @@ -84,6 +85,7 @@ func init() {
rootCmd.AddCommand(notificationchannels.NewNotificationChannelsCmd())
rootCmd.AddCommand(otlp.NewOtlpCmd())
rootCmd.AddCommand(recordingrules.NewRecordingRulesCmd())
rootCmd.AddCommand(slos.NewSlosCmd())
rootCmd.AddCommand(spamfilters.NewSpamFiltersCmd())
rootCmd.AddCommand(syntheticchecks.NewSyntheticChecksCmd())
rootCmd.AddCommand(teams.NewTeamsCmd())
Expand Down
5 changes: 3 additions & 2 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`, `recording-rules`, `notification-channels`, `spam-filters`
- Use **plural form**: `dashboards`, `views`, `check-rules`, `synthetic-checks`, `slos`, `recording-rules`, `notification-channels`, `spam-filters`
- Use **kebab-case** for multi-word names: `check-rules`, `synthetic-checks`, `recording-rules`, `notification-channels`, `spam-filters`
- Group related functionality: `config profiles` for profile management

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

| Subcommand | Alias | Description |
|------------|----------|--------------------------------------|
Expand Down Expand Up @@ -80,6 +80,7 @@ In user-facing output (success messages, dry-run listings, error messages), use
| `Dashboard` | Dashboard |
| `CheckRule` | Check rule |
| `SyntheticCheck` | Synthetic check |
| `SLO` | SLO |
| `View` | View |
| `PrometheusRule` | PrometheusRule |
| `PersesDashboard` | PersesDashboard |
Expand Down
47 changes: 44 additions & 3 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Each category has distinct patterns for flags, output, and behavior.
|----------|----------|-----------------|
| [Authentication](#authentication) | `login`, `logout` | Browser-based OAuth 2.0 + PKCE; per-profile |
| [Configuration](#configuration) | `config profiles`, `config show` | Profile management, no API calls |
| [Asset CRUD](#asset-crud-commands) | `dashboards`, `views`, `check-rules`, `synthetic-checks`, `recording-rules`, `notification-channels`, `spam-filters`, `apply` | File-based input, `--dry-run`, five standard subcommands |
| [Asset CRUD](#asset-crud-commands) | `dashboards`, `views`, `check-rules`, `synthetic-checks`, `slos`, `recording-rules`, `notification-channels`, `spam-filters`, `apply` | File-based input, `--dry-run`, five standard subcommands |
| [Query](#query-commands) | `logs query`, `spans query`, `traces get`, `metrics instant`, `failed-checks query` | Time range, filters |
| [Send](#send-commands) | `logs send`, `spans send` | OTLP-based, repeatable attribute flags |
| [Daemon](#daemon-commands) | `otlp proxy` | Long-running, signal-driven shutdown, experimental |
Expand Down Expand Up @@ -484,7 +484,7 @@ Profile: prod (from DASH0_PROFILE environment variable)
Asset CRUD commands create, list, get, update, and delete Dash0 assets.
Dash0 calls dashboards, views, synthetic checks, and check rules "assets" (not "resources", which is an overloaded term in OpenTelemetry).

All seven asset types (`dashboards`, `check-rules`, `synthetic-checks`, `views`, `recording-rules`, `notification-channels`, `spam-filters`) share the same CRUD subcommands.
All eight asset types (`dashboards`, `check-rules`, `synthetic-checks`, `slos`, `views`, `recording-rules`, `notification-channels`, `spam-filters`) share the same CRUD subcommands.
The examples below use `dashboards`, but the same patterns apply to every asset type.

### `list`
Expand Down Expand Up @@ -679,6 +679,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 |
| Synthetic checks | `dash0 synthetic-checks <subcommand>` | |
| SLOs | `dash0 slos <subcommand>` | Dataset-scoped; documents use the OpenSLO v1 format (`apiVersion: openslo.com/v1`, `kind: SLO`) |
| Views | `dash0 views <subcommand>` | |
| Recording rules | `dash0 recording-rules <subcommand>` | Uses PrometheusRule CRD format |
| Notification channels | `dash0 notification-channels <subcommand>` | Organization-level (no `--dataset`) |
Expand All @@ -702,6 +703,7 @@ The identifier field location varies by asset kind:
| `PrometheusRule` (alerting rules) | `metadata.labels["dash0.com/id"]` | The CRD-level label is applied to every alerting rule converted from the CRD, so a CRD with multiple alerts shares one identifier — pin a unique label per CRD, or split multi-alert CRDs into one CRD per alert |
| `PrometheusRule` (recording rules) | `metadata.labels["dash0.com/id"]` | |
| `SyntheticCheck` | `metadata.labels["dash0.com/id"]` | |
| `SLO` | `metadata.labels["dash0.com/id"]` | OpenSLO v1 document (`apiVersion: openslo.com/v1`) |
| `View` | `metadata.labels["dash0.com/id"]` | |
| `Dash0SpamFilter` (v1alpha1 and v1alpha2) | `metadata.labels["dash0.com/id"]` | `metadata.labels["dash0.com/origin"]` is preferred over the ID when both are present; an ID-only filter is not fully idempotent because the server reassigns the ID on the first PUT |
| `Dash0NotificationChannel` | `metadata.labels["dash0.com/origin"]` | There is no user-settable ID field for notification channels — the origin label is the upsert key. A document without it creates a new channel on every apply |
Expand Down Expand Up @@ -734,7 +736,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`, `Dash0SpamFilter`, `Dash0NotificationChannel`.
Supported `kind` values: `Dashboard`, `PersesDashboard`, `CheckRule`, `PrometheusRule`, `SyntheticCheck`, `SLO`, `View`, `Dash0SpamFilter`, `Dash0NotificationChannel`.
A single file may contain multiple documents separated by `---`.

For `Dash0SpamFilter`, the `apiVersion` field on the document selects the schema (`v1alpha1` or `v1alpha2`); a missing value defaults to `v1alpha1`.
Expand Down Expand Up @@ -857,6 +859,45 @@ spec:
interval: 60s
```

SLO (OpenSLO v1 format):

```yaml
apiVersion: openslo.com/v1
kind: SLO
metadata:
name: checkout-availability
labels:
dash0.com/id: f6a7b8c9-0123-45f0-1234-67890abcdef0
annotations:
dash0.com/display-name: Checkout availability
spec:
description: 99 percent of checkout HTTP requests succeed over a rolling 28-day window.
service: checkout
budgetingMethod: Occurrences
timeWindow:
- duration: 28d
isRolling: true
indicator:
metadata:
name: checkout-success-ratio
spec:
ratioMetric:
counter: true
good:
metricSource:
type: Prometheus
spec:
query: 'http_server_request_duration_seconds_count{service_name="checkout",http_response_status_code!~"5.."}'
total:
metricSource:
type: Prometheus
spec:
query: 'http_server_request_duration_seconds_count{service_name="checkout"}'
objectives:
- displayName: 99% availability
target: 0.99
```

Recording rule (PrometheusRule CRD format):

```yaml
Expand Down
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,5 @@ require (
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
google.golang.org/protobuf v1.36.11 // indirect
)

replace github.com/dash0hq/dash0-api-client-go => ../../../dash0-api-client-go/.iac_maintainer/slos
2 changes: 0 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,6 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL
github.com/cli/browser v1.3.0 h1:LejqCrpWr+1pRqmEPDGnTZOjsMe7sehifLynZJuqJpo=
github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/dash0hq/dash0-api-client-go v1.16.0 h1:xWY81K3QWH2ancLCYMPFUoCZc6HWQ5UnMRdqHbrgPRk=
github.com/dash0hq/dash0-api-client-go v1.16.0/go.mod h1:KNGbcgETrEN4m2QPGAorZ9FybTpVg/oi20UEoWGCGlE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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 with alerting rules)
- PrometheusRule CRD with recording rules
- SyntheticCheck
- SLO
- View
- Dash0SpamFilter
- Dash0NotificationChannel
Expand Down Expand Up @@ -172,7 +173,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, Dash0SpamFilter, Dash0NotificationChannel)", doc.location(), doc.kind))
validationErrors = append(validationErrors, fmt.Sprintf("%s: unsupported kind %q (supported: Dashboard, PersesDashboard, CheckRule, PrometheusRule, SyntheticCheck, SLO, View, Dash0SpamFilter, Dash0NotificationChannel)", doc.location(), doc.kind))
} else if normalizeKind(doc.kind) == "spamfilter" {
// Catch unknown spam filter apiVersions during validation rather
// than after the first PUT, so a partial apply of a multi-doc input
Expand Down Expand Up @@ -347,6 +348,14 @@ func parseDocumentHeader(data []byte) (kind, name, id string, err error) {
name = dash0api.GetSyntheticCheckName(&check)
id = dash0api.GetSyntheticCheckID(&check)

case "slo":
var slo dash0api.SloDefinition
if err := sigsyaml.Unmarshal(data, &slo); err != nil {
return "", "", "", fmt.Errorf("failed to decode document: %w", err)
}
name = dash0api.GetSLOName(&slo)
id = dash0api.GetSLOID(&slo)

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 @@ -582,7 +591,7 @@ func readDirectory(dirPath string) ([]assetDocument, error) {

func isValidKind(kind string) bool {
switch normalizeKind(kind) {
case "dashboard", "checkrule", "syntheticcheck", "view", "prometheusrule", "persesdashboard", "spamfilter", "notificationchannel":
case "dashboard", "checkrule", "syntheticcheck", "slo", "view", "prometheusrule", "persesdashboard", "spamfilter", "notificationchannel":
return true
default:
return false
Expand Down Expand Up @@ -633,6 +642,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 "slo":
var slo dash0api.SloDefinition
if err := sigsyaml.Unmarshal(doc.raw, &slo); err != nil {
return nil, fmt.Errorf("failed to parse SLO: %w", err)
}
result, err := asset.ImportSLO(ctx, apiClient, &slo, dataset)
if err != nil {
return nil, client.HandleAPIError(err, client.ErrorContext{
AssetType: "SLO",
AssetName: dash0api.GetSLOName(&slo),
})
}
return []applyResult{{kind: doc.kind, name: result.Name, id: result.ID, action: applyAction(result.Action), before: result.Before, after: result.After}}, nil

case "view":
var view dash0api.ViewDefinition
if err := sigsyaml.Unmarshal(doc.raw, &view); err != nil {
Expand Down
2 changes: 2 additions & 0 deletions internal/asset/kind.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ func KindDisplayName(kind string) string {
return "Check rule"
case "syntheticcheck":
return "Synthetic check"
case "slo":
return "SLO"
case "view":
return "View"
case "prometheusrule":
Expand Down
43 changes: 43 additions & 0 deletions internal/asset/slo.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package asset

import (
"context"

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

// ImportSLO creates or updates an SLO via the standard CRUD APIs.
// When the input has a user-defined ID, UPDATE is always used — PUT has
// create-or-replace semantics, so this is idempotent regardless of whether the
// SLO already exists.
// When the input has no ID, CREATE is used and the server assigns an ID.
func ImportSLO(ctx context.Context, apiClient dash0api.Client, slo *dash0api.SloDefinition, dataset *string) (ImportResult, error) {
dash0api.StripSLOServerFields(slo)

action := ActionCreated
var before any
id := dash0api.GetSLOID(slo)
if id != "" {
existing, err := apiClient.GetSLO(ctx, id, dataset)
if err == nil {
action = ActionUpdated
before = existing
}
}

var result *dash0api.SloDefinition
var err error
if id != "" {
result, err = apiClient.UpdateSLO(ctx, id, slo, dataset)
} else {
result, err = apiClient.CreateSLO(ctx, slo, dataset)
}
if err != nil {
return ImportResult{}, err
}

if resultID := dash0api.GetSLOID(result); resultID != "" {
id = resultID
}
return ImportResult{Name: dash0api.GetSLOName(result), ID: id, Action: action, Before: before, After: result}, nil
}
66 changes: 66 additions & 0 deletions internal/slos/create.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package slos

import (
"context"
"fmt"
"os"

dash0api "github.com/dash0hq/dash0-api-client-go"
"github.com/dash0hq/dash0-cli/internal"
"github.com/dash0hq/dash0-cli/internal/asset"
"github.com/dash0hq/dash0-cli/internal/client"
"github.com/spf13/cobra"
)

func newCreateCmd() *cobra.Command {
var flags asset.FileInputFlags

cmd := &cobra.Command{
Use: "create -f <file>",
Aliases: []string{"add"},
Short: "Create an SLO from a file",
Long: `Create a new SLO from a YAML or JSON definition file in OpenSLO v1 format. Use '-f -' to read from stdin.` + internal.CONFIG_HINT,
Example: ` # Create from a YAML file
dash0 slos create -f slo.yaml

# Create from stdin
cat slo.yaml | dash0 slos create -f -

# Validate without creating
dash0 slos create -f slo.yaml --dry-run`,
RunE: func(cmd *cobra.Command, args []string) error {
return runCreate(cmd.Context(), &flags)
},
}

asset.RegisterFileInputFlags(cmd, &flags)
return cmd
}

func runCreate(ctx context.Context, flags *asset.FileInputFlags) error {
var slo dash0api.SloDefinition
if err := asset.ReadDefinition(flags.File, &slo, os.Stdin); err != nil {
return fmt.Errorf("failed to read SLO definition: %w", err)
}

if flags.DryRun {
fmt.Println("Dry run: SLO definition is valid")
return nil
}

apiClient, err := client.NewClientFromContext(ctx, flags.ApiUrl, flags.AuthToken)
if err != nil {
return err
}

result, importErr := asset.ImportSLO(ctx, apiClient, &slo, client.ResolveDataset(ctx, flags.Dataset))
if importErr != nil {
return client.HandleAPIError(importErr, client.ErrorContext{
AssetType: "SLO",
AssetName: dash0api.GetSLOName(&slo),
})
}

fmt.Printf("SLO %q %s\n", result.Name, result.Action)
return nil
}
Loading
Loading