Skip to content

Commit d7e7329

Browse files
fix(slos): correct the documented upsert key, drop foreign id from POST body
Addresses six pre-merge review findings on #207. 1. The docs taught `dash0.com/id` as the SLO upsert key while `ImportSLO` is origin-first, and SLO ids are server-assigned (`slo_<ulid>`) so the pinned example id could never exist. The identifier table, the SLO YAML example, the `apply` section, and the "notification channels and spam filters are the two exceptions" sentence (in both docs/commands.md and the skill's SKILL.md) now say origin — modeled on the existing `Dash0Team` row, which describes the same routing. 2. `apply --dry-run` printed no identifier for an origin-only SLO document (the recommended form). Added the origin fallback both origin-keyed baselines already have. 3. `slos get <origin>` built the deep link and the id label from the raw argument, producing a URL that does not resolve. Both are now read off the response, mirroring `notificationchannels/get.go`. 4. `StripSLOServerFields` does not clear `dash0.com/id`, so the cross-org POST fallback (id branch, preflight 404) sent the source org's id. `ImportSLO` now calls the previously-unused `dash0api.ClearSLOID` on that path, and the regression test asserts the POST body carries no id. Also deleted the `ImportSLO` doc comment sentence claiming the strip clears the id. 5. There was no `slos` topic in the Agent Skill bundle: added the `ManifestEntry`, the generator `topicSpec`, the SKILL.md topic-index row, and regenerated, so `dash0 skill show slos` resolves. 6. `slos list -o wide`/`-o csv` were missing the `ORIGIN` column the shared format contract promises, and `slos get` printed no `Origin:` line. Since origin is the SLO upsert key, it is the field users script against. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 7ab2c03 commit d7e7329

16 files changed

Lines changed: 285 additions & 24 deletions

File tree

.chloggen/slos.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ issues: [206]
1818
subtext: |
1919
SLO documents use the OpenSLO v1 format (`apiVersion: openslo.com/v1`, `kind: SLO`) and are dataset-scoped via `--dataset`.
2020
The commands support `create`, `list`, `get`, `update`, and `delete`, and SLOs can also be managed through `dash0 apply`.
21+
SLO IDs are assigned by the server, so `metadata.labels["dash0.com/origin"]` is the upsert key: pin it in version control to make `apply` idempotent.
22+
`slos get` accepts an origin or an ID, `slos list -o wide` and `-o csv` expose an `ORIGIN` column, and `slos get` prints `Origin:`.
2123
2224
# If your change doesn't affect end users or the exported elements of any package,
2325
# you should instead start your pull request title with "chore" or use the "Skip Changelog" label.

docs/commands.md

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -730,15 +730,16 @@ The identifier field location varies by asset kind:
730730
| `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 |
731731
| `PrometheusRule` (recording rules) | `metadata.labels["dash0.com/id"]` | |
732732
| `SyntheticCheck` | `metadata.labels["dash0.com/id"]` | |
733-
| `SLO` | `metadata.labels["dash0.com/id"]` | OpenSLO v1 document (`apiVersion: openslo.com/v1`) |
733+
| `SLO` | `metadata.labels["dash0.com/origin"]` (`metadata.labels["dash0.com/id"]` when origin is absent) | OpenSLO v1 document (`apiVersion: openslo.com/v1`). SLO IDs are server-assigned (`slo_<ulid>`), so `dash0.com/origin` is the only client-settable upsert key and the recommended one. `dash0.com/origin` is preferred and upserts by that origin (PUT). When only `dash0.com/id` is present the CLI preflights `GET /api/slos/{id}`: on hit it PUTs (idempotent update — this is what makes reapplying a YAML downloaded from the Dash0 platform UI a no-op), on 404 it falls back to POST so cross-org apply stays idempotent, and other errors surface. A document with neither label creates a new SLO on every apply |
734734
| `View` | `metadata.labels["dash0.com/id"]` | |
735735
| `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 |
736736
| `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 |
737737
| `Dash0Team` | `metadata.labels["dash0.com/origin"]` (`metadata.labels["dash0.com/id"]` when origin is absent) | Organization-level. `dash0.com/origin` is preferred and upserts by that origin (PUT). When only `dash0.com/id` is present the CLI preflights `GET /api/teams/{id}`: on hit it PUTs (idempotent update — this is what makes reapplying a YAML downloaded from the Dash0 platform UI a no-op), on 404 it falls back to POST so cross-org apply stays idempotent, and other errors surface. A document with neither label creates a new team on every apply. `spec.members` accepts email addresses or internal member ids interchangeably |
738738
739739
The `dash0.com/id` label is the user-defined external identifier and is distinct from `dash0.com/origin`, which records the system of record (`dash0-cli`, `terraform`, `ui`).
740740
The CLI strips `dash0.com/origin` from outbound payloads for the asset types where the server treats origin as provenance metadata (dashboards, views, check rules, synthetic checks), so do not use origin as the upsert key for those kinds.
741-
Notification channels and spam filters are the two exceptions: their server APIs key on origin, and the CLI preserves it accordingly.
741+
Notification channels, spam filters, SLOs, and teams are the exceptions: their server APIs key on origin, so the CLI reads the origin label off the document and uses it as the upsert key in the request path (`PUT /api/notification-channels/{origin}`, `PUT /api/spam-filters/{origin}`, `PUT /api/slos/{origin}`, `PUT /api/teams/{origin}`).
742+
For SLOs origin is not merely accepted, it is the recommended key: the server assigns SLO IDs (`slo_<ulid>`), so a hand-written document can only be made idempotent through `dash0.com/origin`.
742743
743744
When `list -o yaml` or `get -o yaml` exports an existing asset, the server-assigned ID is rendered into the correct field, so the export-edit-reapply workflow round-trips through the identifier automatically.
744745
@@ -783,6 +784,13 @@ Exported definitions never carry the field (`get`/`list` omit it from `-o yaml`/
783784
The CLI warns when an applied document carries a non-empty `spec.routing.assets`.
784785
To bind a check rule, set the `dash0.com/notification-channel-ids` annotation on the check rule; to bind a synthetic check, set `spec.notifications.channels` on the synthetic check.
785786
787+
`SLO` documents use the same upsert-key selection as `Dash0Team`, and for SLOs `dash0.com/origin` is the recommended key rather than merely the preferred one.
788+
SLO IDs are assigned by the server (`slo_<ulid>`), so `dash0.com/id` cannot be chosen up front — only `dash0.com/origin` can.
789+
`dash0.com/origin` wins when present and PUTs unconditionally (create-or-replace at that origin).
790+
When only `dash0.com/id` is present the CLI preflights the SLO with a GET — on hit it PUTs (idempotent update, the path that makes a UI-downloaded YAML reapply cleanly), on 404 it falls back to POST so a YAML from one organization applies to another as a fresh create, and other preflight errors surface instead of silently creating a duplicate.
791+
On that POST fallback the foreign `dash0.com/id` is removed from the request body, since the server assigns the ID.
792+
A document with neither label creates a new SLO on every apply.
793+
786794
`Dash0Team` documents are dispatched to the organization-level teams endpoint (also not associated with a dataset).
787795
Upsert key selection: `dash0.com/origin` wins when present and PUTs unconditionally.
788796
When only `dash0.com/id` is present the CLI preflights the team with a GET — on hit it PUTs (idempotent update, the path that makes a UI-downloaded YAML reapply cleanly), on 404 it falls back to POST so a YAML from one organization applies to another as a fresh create, and other preflight errors surface instead of silently creating a duplicate.
@@ -904,15 +912,16 @@ spec:
904912
interval: 60s
905913
```
906914
907-
SLO (OpenSLO v1 format):
915+
SLO (OpenSLO v1 format).
916+
The upsert key is `dash0.com/origin`, not `dash0.com/id` — SLO IDs are server-assigned, so `dash0.com/origin` is the label to pin in version control:
908917
909918
```yaml
910919
apiVersion: openslo.com/v1
911920
kind: SLO
912921
metadata:
913922
name: checkout-availability
914923
labels:
915-
dash0.com/id: f6a7b8c9-0123-45f0-1234-67890abcdef0
924+
dash0.com/origin: checkout-availability
916925
annotations:
917926
dash0.com/display-name: Checkout availability
918927
spec:

internal/apply/apply.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,15 @@ func parseDocumentHeader(data []byte) (kind, name, id string, err error) {
377377
}
378378
name = dash0api.GetSLOName(&slo)
379379
id = dash0api.GetSLOID(&slo)
380+
if id == "" && slo.Metadata.Labels != nil && slo.Metadata.Labels.Dash0Comorigin != nil {
381+
// SLOs use origin as the upsert key — SLO ids are server-assigned
382+
// (`slo_<ulid>`), so origin is the only client-settable key and the
383+
// recommended document form carries origin alone. Surface it as the
384+
// ID in dry-run/listing output, otherwise that form prints a bare
385+
// name with no identifier at all. There is no GetSLOOrigin helper in
386+
// the API client, so the label is read directly (as ImportSLO does).
387+
id = *slo.Metadata.Labels.Dash0Comorigin
388+
}
380389

381390
case "prometheusrule":
382391
// We only need metadata (name + ID) here; the Metadata struct has no

internal/apply/integration_test.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2143,6 +2143,33 @@ spec:
21432143
target: 0.99
21442144
`
21452145

2146+
// TestApply_SLO_DryRunShowsOriginAsIdentifier pins that the dry-run listing
2147+
// shows an identifier for the recommended SLO document form. SLO ids are
2148+
// server-assigned, so an origin-only document is the form users write — and
2149+
// without an origin fallback the dry run printed the name with no identifier at
2150+
// all. Mirrors the notification-channel and team fallbacks.
2151+
func TestApply_SLO_DryRunShowsOriginAsIdentifier(t *testing.T) {
2152+
testutil.SetupTestEnv(t)
2153+
2154+
tmpDir := t.TempDir()
2155+
yamlFile := filepath.Join(tmpDir, "slo.yaml")
2156+
require.NoError(t, os.WriteFile(yamlFile, []byte(sloYAMLWithOrigin), 0644))
2157+
2158+
cmd := NewApplyCmd()
2159+
cmd.SetArgs([]string{"-f", yamlFile, "--dry-run"})
2160+
2161+
var cmdErr error
2162+
output := testutil.CaptureStdout(t, func() {
2163+
cmdErr = cmd.Execute()
2164+
})
2165+
2166+
require.NoError(t, cmdErr)
2167+
assert.Contains(t, output, "Dry run")
2168+
assert.Contains(t, output, "Checkout availability")
2169+
assert.Contains(t, output, "cli-roundtrip-origin",
2170+
"an origin-only SLO must show its origin as the identifier — origin is the upsert key")
2171+
}
2172+
21462173
// TestApply_SLO_SecondApplyReportsNoChanges pins actual idempotency for SLOs,
21472174
// matching the contract every sibling asset already meets (see
21482175
// TestApply_Dashboard_* above): re-applying an unchanged document must report

internal/asset/slo.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,7 @@ import (
2828
//
2929
// PUT is create-or-replace, so upserting on either key is idempotent across
3030
// repeated applies. The origin and id labels are captured before
31-
// StripSLOServerFields runs because that helper clears both dash0.com/origin
32-
// and (indirectly, via the id-bearing labels) the server-managed labels.
31+
// StripSLOServerFields runs because that helper clears dash0.com/origin.
3332
func ImportSLO(ctx context.Context, apiClient dash0api.Client, slo *dash0api.SloDefinition, dataset *string) (ImportResult, error) {
3433
// Capture identifiers before stripping — StripSLOServerFields clears the
3534
// dash0.com/origin label, so origin- and id-based routing must observe the
@@ -75,6 +74,12 @@ func ImportSLO(ctx context.Context, apiClient dash0api.Client, slo *dash0api.Slo
7574
if upsertKey != "" {
7675
result, err = apiClient.UpdateSLO(ctx, upsertKey, slo, dataset)
7776
} else {
77+
// StripSLOServerFields does not touch dash0.com/id, so on the
78+
// preflight-404 fallback the body would still carry the id that belongs
79+
// to the *source* organization. The server assigns SLO ids on create, so
80+
// send the document without one rather than asking it to ignore a
81+
// foreign identifier.
82+
dash0api.ClearSLOID(slo)
7883
result, err = apiClient.CreateSLO(ctx, slo, dataset)
7984
}
8085
if err != nil {

internal/skill/bundle.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ var Manifest = []ManifestEntry{
4343
{"notification-channels", "references/notification-channels.md"},
4444
{"otlp", "references/otlp.md"},
4545
{"recording-rules", "references/recording-rules.md"},
46+
{"slos", "references/slos.md"},
4647
{"spam-filters", "references/spam-filters.md"},
4748
{"spans", "references/spans.md"},
4849
{"synthetic-checks", "references/synthetic-checks.md"},

internal/skill/content/SKILL.md

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
---
22
name: dash0-cli
3-
description: Use when working with Dash0 observability data or configuration via the dash0 CLI (the `dash0` binary) — querying logs, spans, traces, metrics, or failed checks; managing dashboards, views, check rules, synthetic checks, recording rules, notification channels, or spam filters; sending OTLP telemetry or deployment events; or managing teams, members, and profiles. Trigger on "Dash0", "dash0 CLI", or any of these operations.
3+
description: Use when working with Dash0 observability data or configuration via the dash0 CLI (the `dash0` binary) — querying logs, spans, traces, metrics, or failed checks; managing dashboards, views, check rules, synthetic checks, SLOs, recording rules, notification channels, or spam filters; sending OTLP telemetry or deployment events; or managing teams, members, and profiles. Trigger on "Dash0", "dash0 CLI", or any of these operations.
44
---
55

66
<!-- This file is packaged with the dash0-cli distribution. Installed copies are overwritten by `dash0 skill install` / `make skill-bundle`; edit the hand-curated source at internal/skill/content/SKILL.md in the dash0hq/dash0-cli repository instead. -->
77

88
# dash0-cli
99

10-
`dash0` is a command-line interface for the [Dash0](https://www.dash0.com) observability platform. It manages Dash0 assets (dashboards, views, check rules, synthetic checks, recording rules, notification channels, spam filters), queries telemetry (logs, spans, traces, metrics, failed checks), sends telemetry via OTLP, and manages organization entities (teams, members, profiles).
10+
`dash0` is a command-line interface for the [Dash0](https://www.dash0.com) observability platform. It manages Dash0 assets (dashboards, views, check rules, synthetic checks, SLOs, recording rules, notification channels, spam filters), queries telemetry (logs, spans, traces, metrics, failed checks), sends telemetry via OTLP, and manages organization entities (teams, members, profiles).
1111

1212
**Prefer `dash0 --agent-mode <command> --help` over guessing flags.** Every command's exact, always-current flag list, aliases, and examples are available as structured JSON via `--agent-mode <command> --help` (e.g. `dash0 --agent-mode dashboards list --help`). This bundle deliberately does not duplicate flag tables — they'd go stale the moment a flag is added or renamed. Use the topics below for concepts, YAML formats, and workflows that `--help` output can't express; use `--agent-mode <command> --help` for the exact flags to pass.
1313

@@ -17,7 +17,7 @@ description: Use when working with Dash0 observability data or configuration via
1717
|----------|----------|-----------------|
1818
| Authentication | `login`, `logout` | Browser-based OAuth 2.0 + PKCE; per-profile |
1919
| Configuration | `config profiles`, `config show` | Profile management, no API calls |
20-
| Asset CRUD | `dashboards`, `views`, `check-rules`, `synthetic-checks`, `recording-rules`, `notification-channels`, `spam-filters`, `apply` | File-based input, `--dry-run`, five standard subcommands |
20+
| Asset CRUD | `dashboards`, `views`, `check-rules`, `synthetic-checks`, `slos`, `recording-rules`, `notification-channels`, `spam-filters`, `apply` | File-based input, `--dry-run`, five standard subcommands |
2121
| Query | `logs query`, `spans query`, `traces get`, `metrics instant`, `failed-checks query` | Time range, filters |
2222
| Send | `logs send`, `spans send` | OTLP-based, repeatable attribute flags |
2323
| Daemon | `otlp proxy` | Long-running, signal-driven shutdown, experimental |
@@ -39,7 +39,7 @@ Agent mode optimizes the CLI for AI agents: JSON output by default, structured `
3939

4040
## How asset commands work
4141

42-
All seven asset types (`dashboards`, `check-rules`, `synthetic-checks`, `views`, `recording-rules`, `notification-channels`, `spam-filters`) share the same five subcommands: `list`, `get`, `create` (alias `add`), `update`, `delete` (alias `remove`). Output formats are `table`, `wide`, `json`, `yaml`, `csv` (query commands use `table`/`json`/`csv` only). `create`/`update` accept `-f <file>` (or `-f -` for stdin) and `--dry-run`.
42+
All eight asset types (`dashboards`, `check-rules`, `synthetic-checks`, `slos`, `views`, `recording-rules`, `notification-channels`, `spam-filters`) share the same five subcommands: `list`, `get`, `create` (alias `add`), `update`, `delete` (alias `remove`). Output formats are `table`, `wide`, `json`, `yaml`, `csv` (query commands use `table`/`json`/`csv` only). `create`/`update` accept `-f <file>` (or `-f -` for stdin) and `--dry-run`.
4343

4444
`dash0 apply -f <file|directory>` provides create-or-update semantics across all asset types in one command — see the `apply` topic.
4545

@@ -55,10 +55,12 @@ Every asset type accepts a user-defined identifier in its YAML/JSON document. Wh
5555
| `PrometheusRule` (alerting or recording) | `metadata.labels["dash0.com/id"]` |
5656
| `SyntheticCheck` | `metadata.labels["dash0.com/id"]` |
5757
| `View` | `metadata.labels["dash0.com/id"]` |
58+
| `SLO` | `metadata.labels["dash0.com/origin"]` — SLO IDs are server-assigned (`slo_<ulid>`), so origin is the only client-settable key; `metadata.labels["dash0.com/id"]` is used only when origin is absent |
5859
| `Dash0SpamFilter` | `metadata.labels["dash0.com/id"]` (`dash0.com/origin` takes precedence when both are present) |
5960
| `Dash0NotificationChannel` | no ID field — `metadata.labels["dash0.com/origin"]` is the upsert key |
61+
| `Dash0Team` | `metadata.labels["dash0.com/origin"]` (`metadata.labels["dash0.com/id"]` when origin is absent) |
6062

61-
**Origin vs ID — do not conflate them.** *Origin* (`dash0.com/origin` label) identifies which system is the authoritative source of truth for an asset (`dash0-cli`, `terraform`, `ui`) — it's provenance metadata, not a lookup key, and the CLI strips it before sending most asset types to the API so it doesn't claim ownership of assets managed elsewhere. *ID* is the user-defined external identifier used for upsert, described above. Notification channels and spam filters are the two exceptions where origin (not ID) is the upsert key.
63+
**Origin vs ID — do not conflate them.** *Origin* (`dash0.com/origin` label) identifies which system is the authoritative source of truth for an asset (`dash0-cli`, `terraform`, `ui`) — it's provenance metadata, not a lookup key, and the CLI strips it before sending most asset types to the API so it doesn't claim ownership of assets managed elsewhere. *ID* is the user-defined external identifier used for upsert, described above. Notification channels, spam filters, SLOs, and teams are the exceptions where origin (not ID) is the upsert key — for those kinds the CLI reads the origin label off the document and upserts against it (`PUT /api/<kind>/{origin}`). For SLOs origin is not just accepted but recommended: the server assigns SLO IDs, so `dash0.com/origin` is the only way to make a hand-written SLO document idempotent.
6264

6365
When `list -o yaml` or `get -o yaml` exports an existing asset, the server-assigned ID is rendered into the correct field, so an export → edit → `apply` (or `update`) round-trips through the identifier automatically.
6466

@@ -160,6 +162,7 @@ Run `dash0 skill show <topic>` for the reference content below, or read `referen
160162
| `notification-channels` | Notification channel CRUD (organization-level, no dataset) |
161163
| `otlp` | Local OTLP forwarding proxy (`otlp proxy`) |
162164
| `recording-rules` | Recording rule CRUD (PrometheusRule CRD format) |
165+
| `slos` | SLO CRUD (OpenSLO v1 documents; `dash0.com/origin` is the upsert key) |
163166
| `spam-filters` | Spam filter CRUD (v1alpha1 and v1alpha2) |
164167
| `spans` | Query and send spans |
165168
| `synthetic-checks` | Synthetic check CRUD |

internal/skill/content/references/apply.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,13 @@ Exported definitions never carry the field (`get`/`list` omit it from `-o yaml`/
4040
The CLI warns when an applied document carries a non-empty `spec.routing.assets`.
4141
To bind a check rule, set the `dash0.com/notification-channel-ids` annotation on the check rule; to bind a synthetic check, set `spec.notifications.channels` on the synthetic check.
4242

43+
`SLO` documents use the same upsert-key selection as `Dash0Team`, and for SLOs `dash0.com/origin` is the recommended key rather than merely the preferred one.
44+
SLO IDs are assigned by the server (`slo_<ulid>`), so `dash0.com/id` cannot be chosen up front — only `dash0.com/origin` can.
45+
`dash0.com/origin` wins when present and PUTs unconditionally (create-or-replace at that origin).
46+
When only `dash0.com/id` is present the CLI preflights the SLO with a GET — on hit it PUTs (idempotent update, the path that makes a UI-downloaded YAML reapply cleanly), on 404 it falls back to POST so a YAML from one organization applies to another as a fresh create, and other preflight errors surface instead of silently creating a duplicate.
47+
On that POST fallback the foreign `dash0.com/id` is removed from the request body, since the server assigns the ID.
48+
A document with neither label creates a new SLO on every apply.
49+
4350
`Dash0Team` documents are dispatched to the organization-level teams endpoint (also not associated with a dataset).
4451
Upsert key selection: `dash0.com/origin` wins when present and PUTs unconditionally.
4552
When only `dash0.com/id` is present the CLI preflights the team with a GET — on hit it PUTs (idempotent update, the path that makes a UI-downloaded YAML reapply cleanly), on 404 it falls back to POST so a YAML from one organization applies to another as a fresh create, and other preflight errors surface instead of silently creating a duplicate.

0 commit comments

Comments
 (0)