Skip to content

Commit 30c6bf8

Browse files
authored
Merge pull request #46 from pydantic/slo-channel-seeding
logfire_slo: seed the generated burn-rate alerts' channels at creation
2 parents 5c53f47 + 3b1321b commit 30c6bf8

6 files changed

Lines changed: 135 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
## Unreleased
22

33
FEATURES:
4+
- Add optional `page_channel_ids` / `ticket_channel_ids` attributes to `logfire_slo` to seed the generated burn-rate alerts' notification channels at creation. Delivery stays alert-owned afterwards; the attributes have no effect on an already-created SLO. Requires a Logfire backend that accepts channel seeding on the public SLO API.
45
- Add an experimental `logfire_slo` resource for managing Service Level Objectives. The backing Logfire API is not yet stable, so the resource schema and behavior may change in backwards-incompatible ways.
56
- Add an optional `environments` attribute (set of strings) to the `logfire_alert` resource to scope the alert query to specific deployment environments. Omitting it (or setting it empty) keeps the current behavior of evaluating against all environments.
67

docs/resources/slo.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,16 @@ resource "logfire_project" "example" {
2020
name = "example-project"
2121
}
2222
23+
resource "logfire_channel" "oncall" {
24+
name = "oncall-webhook"
25+
26+
config {
27+
type = "webhook"
28+
format = "auto"
29+
url = "https://hooks.example.com/oncall"
30+
}
31+
}
32+
2333
resource "logfire_slo" "example" {
2434
project_id = logfire_project.example.id
2535
scope_value = "payments-api"
@@ -30,6 +40,11 @@ resource "logfire_slo" "example" {
3040
target_percent = "99.9"
3141
rolling_window = "30d"
3242
environments = ["prod"]
43+
44+
# Seed the generated burn-rate alerts' delivery channels (create-time only;
45+
# delivery is alert-owned afterwards).
46+
page_channel_ids = [logfire_channel.oncall.id]
47+
ticket_channel_ids = [logfire_channel.oncall.id]
3348
}
3449
```
3550

@@ -51,8 +66,10 @@ resource "logfire_slo" "example" {
5166
- `description` (String) SLO description.
5267
- `environments` (Set of String) Deployment environments the SLO is scoped to. Omit to cover all environments.
5368
- `metric_aggregation` (String) How a `metrics` SLO aggregates its SLI: `additive` (sum of scalar values, for delta-count metrics), `gauge_fraction` (fraction of samples meeting the condition, for gauges), or `counter_rate` (sum of per-series increases, for cumulative counters). Ignored when `source = "records"`. Defaults to `additive`.
69+
- `page_channel_ids` (Set of String) Channel IDs seeded onto the SLO's page-severity burn-rate alerts when the SLO is created. Delivery is alert-owned after creation: changing this attribute later updates only the Terraform state, not the existing alerts (edit the alerts' channels instead).
5470
- `scope_kind` (String) What the SLO is anchored to: a service (`service`) or an LLM provider (`provider`). Defaults to `service`. Changing it forces a new SLO.
5571
- `source` (String) Whether the SLO ratio is computed over span events (`records`) or metric values (`metrics`). Defaults to `records`.
72+
- `ticket_channel_ids` (Set of String) Channel IDs seeded onto the SLO's ticket-severity burn-rate alert when the SLO is created. Delivery is alert-owned after creation: changing this attribute later updates only the Terraform state, not the existing alerts (edit the alerts' channels instead).
5673

5774
### Read-Only
5875

examples/resources/logfire_slo/resource.tf

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@ resource "logfire_project" "example" {
22
name = "example-project"
33
}
44

5+
resource "logfire_channel" "oncall" {
6+
name = "oncall-webhook"
7+
8+
config {
9+
type = "webhook"
10+
format = "auto"
11+
url = "https://hooks.example.com/oncall"
12+
}
13+
}
14+
515
resource "logfire_slo" "example" {
616
project_id = logfire_project.example.id
717
scope_value = "payments-api"
@@ -12,4 +22,9 @@ resource "logfire_slo" "example" {
1222
target_percent = "99.9"
1323
rolling_window = "30d"
1424
environments = ["prod"]
25+
26+
# Seed the generated burn-rate alerts' delivery channels (create-time only;
27+
# delivery is alert-owned afterwards).
28+
page_channel_ids = [logfire_channel.oncall.id]
29+
ticket_channel_ids = [logfire_channel.oncall.id]
1530
}

internal/client/client.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1118,6 +1118,10 @@ type SloCreate struct {
11181118
TargetPercent string `json:"target_percent"`
11191119
RollingWindowSeconds int64 `json:"rolling_window_seconds"`
11201120
Environments []string `json:"environments,omitempty"`
1121+
// Create-time seeds for the generated burn-rate alerts' notification
1122+
// channels. Delivery is alert-owned after creation and never read back.
1123+
PageChannelIDs []string `json:"page_channel_ids,omitempty"`
1124+
TicketChannelIDs []string `json:"ticket_channel_ids,omitempty"`
11211125
}
11221126

11231127
type SloUpdate struct {

internal/provider/slo_mapping_test.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ func TestSloModelToCreate(t *testing.T) {
4848
if create.Description != nil || create.Source != nil || create.MetricAggregation != nil || create.Environments != nil {
4949
t.Fatalf("expected omitted optionals, got %#v", create)
5050
}
51+
if create.PageChannelIDs != nil || create.TicketChannelIDs != nil {
52+
t.Fatalf("expected omitted channel seeds, got %#v", create)
53+
}
5154
})
5255

5356
t.Run("includes optional description, source, metric_aggregation, and environments", func(t *testing.T) {
@@ -81,6 +84,33 @@ func TestSloModelToCreate(t *testing.T) {
8184
}
8285
})
8386

87+
t.Run("includes the burn-rate alert channel seeds", func(t *testing.T) {
88+
t.Parallel()
89+
90+
page, diags := types.SetValueFrom(context.Background(), types.StringType, []string{"chan-page"})
91+
if diags.HasError() {
92+
t.Fatalf("failed to build page channel set: %v", diags)
93+
}
94+
ticket, diags := types.SetValueFrom(context.Background(), types.StringType, []string{"chan-ticket"})
95+
if diags.HasError() {
96+
t.Fatalf("failed to build ticket channel set: %v", diags)
97+
}
98+
m := baseSloModel()
99+
m.PageChannelIDs = page
100+
m.TicketChannelIDs = ticket
101+
102+
create, gotDiags := sloModelToCreate(context.Background(), &m)
103+
if gotDiags.HasError() {
104+
t.Fatalf("unexpected diagnostics: %v", gotDiags)
105+
}
106+
if len(create.PageChannelIDs) != 1 || create.PageChannelIDs[0] != "chan-page" {
107+
t.Fatalf("unexpected page channel seeds: %#v", create.PageChannelIDs)
108+
}
109+
if len(create.TicketChannelIDs) != 1 || create.TicketChannelIDs[0] != "chan-ticket" {
110+
t.Fatalf("unexpected ticket channel seeds: %#v", create.TicketChannelIDs)
111+
}
112+
})
113+
84114
t.Run("rejects out-of-range windows", func(t *testing.T) {
85115
t.Parallel()
86116

@@ -133,6 +163,34 @@ func TestSloReadToModel(t *testing.T) {
133163
if !m.Description.IsNull() {
134164
t.Fatalf("expected null description, got %v", m.Description)
135165
}
166+
// Fresh models (import) get a typed null; the API never returns the seeds.
167+
if !m.PageChannelIDs.IsNull() || m.PageChannelIDs.ElementType(context.Background()) == nil {
168+
t.Fatalf("expected typed-null page channel seeds, got %v", m.PageChannelIDs)
169+
}
170+
if !m.TicketChannelIDs.IsNull() || m.TicketChannelIDs.ElementType(context.Background()) == nil {
171+
t.Fatalf("expected typed-null ticket channel seeds, got %v", m.TicketChannelIDs)
172+
}
173+
})
174+
175+
t.Run("preserves configured channel seeds the API does not return", func(t *testing.T) {
176+
t.Parallel()
177+
178+
page, diags := types.SetValueFrom(context.Background(), types.StringType, []string{"chan-page"})
179+
if diags.HasError() {
180+
t.Fatalf("failed to build page channel set: %v", diags)
181+
}
182+
m := baseSloModel()
183+
m.PageChannelIDs = page
184+
if diags := sloReadToModel(context.Background(), sloRead(), &m); diags != nil && diags.HasError() {
185+
t.Fatalf("unexpected diagnostics: %v", diags)
186+
}
187+
var ids []string
188+
if diags := m.PageChannelIDs.ElementsAs(context.Background(), &ids, false); diags.HasError() {
189+
t.Fatalf("unexpected diagnostics: %v", diags)
190+
}
191+
if len(ids) != 1 || ids[0] != "chan-page" {
192+
t.Fatalf("unexpected page channel seeds: %#v", ids)
193+
}
136194
})
137195

138196
t.Run("replaces values when the API disagrees", func(t *testing.T) {

internal/provider/slo_resource.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ type SloModel struct {
5050
TargetPercent types.String `tfsdk:"target_percent"`
5151
RollingWindow types.String `tfsdk:"rolling_window"`
5252
Environments types.Set `tfsdk:"environments"`
53+
PageChannelIDs types.Set `tfsdk:"page_channel_ids"`
54+
TicketChannelIDs types.Set `tfsdk:"ticket_channel_ids"`
5355
}
5456

5557
func (r *SloResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
@@ -162,6 +164,20 @@ func (r *SloResource) Schema(ctx context.Context, req resource.SchemaRequest, re
162164
Optional: true,
163165
MarkdownDescription: "Deployment environments the SLO is scoped to. Omit to cover all environments.",
164166
},
167+
"page_channel_ids": rschema.SetAttribute{
168+
ElementType: types.StringType,
169+
Optional: true,
170+
MarkdownDescription: "Channel IDs seeded onto the SLO's page-severity burn-rate alerts when the SLO is created. " +
171+
"Delivery is alert-owned after creation: changing this attribute later updates only the Terraform state, " +
172+
"not the existing alerts (edit the alerts' channels instead).",
173+
},
174+
"ticket_channel_ids": rschema.SetAttribute{
175+
ElementType: types.StringType,
176+
Optional: true,
177+
MarkdownDescription: "Channel IDs seeded onto the SLO's ticket-severity burn-rate alert when the SLO is created. " +
178+
"Delivery is alert-owned after creation: changing this attribute later updates only the Terraform state, " +
179+
"not the existing alerts (edit the alerts' channels instead).",
180+
},
165181
},
166182
}
167183
}
@@ -263,6 +279,20 @@ func sloModelToCreate(ctx context.Context, m *SloModel) (logclient.SloCreate, di
263279
}
264280
in.Environments = envs
265281
}
282+
if !m.PageChannelIDs.IsNull() && !m.PageChannelIDs.IsUnknown() {
283+
var ids []string
284+
if diags := m.PageChannelIDs.ElementsAs(ctx, &ids, false); diags.HasError() {
285+
return logclient.SloCreate{}, diags
286+
}
287+
in.PageChannelIDs = ids
288+
}
289+
if !m.TicketChannelIDs.IsNull() && !m.TicketChannelIDs.IsUnknown() {
290+
var ids []string
291+
if diags := m.TicketChannelIDs.ElementsAs(ctx, &ids, false); diags.HasError() {
292+
return logclient.SloCreate{}, diags
293+
}
294+
in.TicketChannelIDs = ids
295+
}
266296
return in, nil
267297
}
268298

@@ -380,6 +410,16 @@ func sloReadToModel(ctx context.Context, s *logclient.SloRead, m *SloModel) diag
380410
}
381411
m.Environments = set
382412
}
413+
414+
// The API never returns the channel seeds (delivery is alert-owned after
415+
// creation), so keep whatever the config/state carries. On fresh models
416+
// (import) the zero value has no element type; pin it to a typed null.
417+
if m.PageChannelIDs.ElementType(ctx) == nil {
418+
m.PageChannelIDs = types.SetNull(types.StringType)
419+
}
420+
if m.TicketChannelIDs.ElementType(ctx) == nil {
421+
m.TicketChannelIDs = types.SetNull(types.StringType)
422+
}
383423
return nil
384424
}
385425

0 commit comments

Comments
 (0)