Skip to content
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
## Unreleased

FEATURES:
- 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.
- 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.
- 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.

Expand Down
17 changes: 17 additions & 0 deletions docs/resources/slo.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,16 @@ resource "logfire_project" "example" {
name = "example-project"
}

resource "logfire_channel" "oncall" {
name = "oncall-webhook"

config {
type = "webhook"
format = "auto"
url = "https://hooks.example.com/oncall"
}
}

resource "logfire_slo" "example" {
project_id = logfire_project.example.id
scope_value = "payments-api"
Expand All @@ -30,6 +40,11 @@ resource "logfire_slo" "example" {
target_percent = "99.9"
rolling_window = "30d"
environments = ["prod"]

# Seed the generated burn-rate alerts' delivery channels (create-time only;
# delivery is alert-owned afterwards).
page_channel_ids = [logfire_channel.oncall.id]
ticket_channel_ids = [logfire_channel.oncall.id]
}
```

Expand All @@ -51,8 +66,10 @@ resource "logfire_slo" "example" {
- `description` (String) SLO description.
- `environments` (Set of String) Deployment environments the SLO is scoped to. Omit to cover all environments.
- `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`.
- `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).
- `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.
- `source` (String) Whether the SLO ratio is computed over span events (`records`) or metric values (`metrics`). Defaults to `records`.
- `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).

### Read-Only

Expand Down
15 changes: 15 additions & 0 deletions examples/resources/logfire_slo/resource.tf
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@ resource "logfire_project" "example" {
name = "example-project"
}

resource "logfire_channel" "oncall" {
name = "oncall-webhook"

config {
type = "webhook"
format = "auto"
url = "https://hooks.example.com/oncall"
}
}

resource "logfire_slo" "example" {
project_id = logfire_project.example.id
scope_value = "payments-api"
Expand All @@ -12,4 +22,9 @@ resource "logfire_slo" "example" {
target_percent = "99.9"
rolling_window = "30d"
environments = ["prod"]

# Seed the generated burn-rate alerts' delivery channels (create-time only;
# delivery is alert-owned afterwards).
page_channel_ids = [logfire_channel.oncall.id]
ticket_channel_ids = [logfire_channel.oncall.id]
}
4 changes: 4 additions & 0 deletions internal/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -1118,6 +1118,10 @@ type SloCreate struct {
TargetPercent string `json:"target_percent"`
RollingWindowSeconds int64 `json:"rolling_window_seconds"`
Environments []string `json:"environments,omitempty"`
// Create-time seeds for the generated burn-rate alerts' notification
// channels. Delivery is alert-owned after creation and never read back.
PageChannelIDs []string `json:"page_channel_ids,omitempty"`
TicketChannelIDs []string `json:"ticket_channel_ids,omitempty"`
}

type SloUpdate struct {
Expand Down
58 changes: 58 additions & 0 deletions internal/provider/slo_mapping_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ func TestSloModelToCreate(t *testing.T) {
if create.Description != nil || create.Source != nil || create.MetricAggregation != nil || create.Environments != nil {
t.Fatalf("expected omitted optionals, got %#v", create)
}
if create.PageChannelIDs != nil || create.TicketChannelIDs != nil {
t.Fatalf("expected omitted channel seeds, got %#v", create)
}
})

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

t.Run("includes the burn-rate alert channel seeds", func(t *testing.T) {
t.Parallel()

page, diags := types.SetValueFrom(context.Background(), types.StringType, []string{"chan-page"})
if diags.HasError() {
t.Fatalf("failed to build page channel set: %v", diags)
}
ticket, diags := types.SetValueFrom(context.Background(), types.StringType, []string{"chan-ticket"})
if diags.HasError() {
t.Fatalf("failed to build ticket channel set: %v", diags)
}
m := baseSloModel()
m.PageChannelIDs = page
m.TicketChannelIDs = ticket

create, gotDiags := sloModelToCreate(context.Background(), &m)
if gotDiags.HasError() {
t.Fatalf("unexpected diagnostics: %v", gotDiags)
}
if len(create.PageChannelIDs) != 1 || create.PageChannelIDs[0] != "chan-page" {
t.Fatalf("unexpected page channel seeds: %#v", create.PageChannelIDs)
}
if len(create.TicketChannelIDs) != 1 || create.TicketChannelIDs[0] != "chan-ticket" {
t.Fatalf("unexpected ticket channel seeds: %#v", create.TicketChannelIDs)
}
})

t.Run("rejects out-of-range windows", func(t *testing.T) {
t.Parallel()

Expand Down Expand Up @@ -133,6 +163,34 @@ func TestSloReadToModel(t *testing.T) {
if !m.Description.IsNull() {
t.Fatalf("expected null description, got %v", m.Description)
}
// Fresh models (import) get a typed null; the API never returns the seeds.
if !m.PageChannelIDs.IsNull() || m.PageChannelIDs.ElementType(context.Background()) == nil {
t.Fatalf("expected typed-null page channel seeds, got %v", m.PageChannelIDs)
}
if !m.TicketChannelIDs.IsNull() || m.TicketChannelIDs.ElementType(context.Background()) == nil {
t.Fatalf("expected typed-null ticket channel seeds, got %v", m.TicketChannelIDs)
}
})

t.Run("preserves configured channel seeds the API does not return", func(t *testing.T) {
t.Parallel()

page, diags := types.SetValueFrom(context.Background(), types.StringType, []string{"chan-page"})
if diags.HasError() {
t.Fatalf("failed to build page channel set: %v", diags)
}
m := baseSloModel()
m.PageChannelIDs = page
if diags := sloReadToModel(context.Background(), sloRead(), &m); diags != nil && diags.HasError() {
t.Fatalf("unexpected diagnostics: %v", diags)
}
var ids []string
if diags := m.PageChannelIDs.ElementsAs(context.Background(), &ids, false); diags.HasError() {
t.Fatalf("unexpected diagnostics: %v", diags)
}
if len(ids) != 1 || ids[0] != "chan-page" {
t.Fatalf("unexpected page channel seeds: %#v", ids)
}
})

t.Run("replaces values when the API disagrees", func(t *testing.T) {
Expand Down
40 changes: 40 additions & 0 deletions internal/provider/slo_resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ type SloModel struct {
TargetPercent types.String `tfsdk:"target_percent"`
RollingWindow types.String `tfsdk:"rolling_window"`
Environments types.Set `tfsdk:"environments"`
PageChannelIDs types.Set `tfsdk:"page_channel_ids"`
TicketChannelIDs types.Set `tfsdk:"ticket_channel_ids"`
}

func (r *SloResource) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
Expand Down Expand Up @@ -162,6 +164,20 @@ func (r *SloResource) Schema(ctx context.Context, req resource.SchemaRequest, re
Optional: true,
MarkdownDescription: "Deployment environments the SLO is scoped to. Omit to cover all environments.",
},
"page_channel_ids": rschema.SetAttribute{
ElementType: types.StringType,
Optional: true,
MarkdownDescription: "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).",
},
"ticket_channel_ids": rschema.SetAttribute{
ElementType: types.StringType,
Optional: true,
MarkdownDescription: "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).",
},
},
}
}
Expand Down Expand Up @@ -263,6 +279,20 @@ func sloModelToCreate(ctx context.Context, m *SloModel) (logclient.SloCreate, di
}
in.Environments = envs
}
if !m.PageChannelIDs.IsNull() && !m.PageChannelIDs.IsUnknown() {
var ids []string
if diags := m.PageChannelIDs.ElementsAs(ctx, &ids, false); diags.HasError() {
return logclient.SloCreate{}, diags
}
in.PageChannelIDs = ids
}
if !m.TicketChannelIDs.IsNull() && !m.TicketChannelIDs.IsUnknown() {
var ids []string
if diags := m.TicketChannelIDs.ElementsAs(ctx, &ids, false); diags.HasError() {
return logclient.SloCreate{}, diags
}
in.TicketChannelIDs = ids
}
return in, nil
}

Expand Down Expand Up @@ -380,6 +410,16 @@ func sloReadToModel(ctx context.Context, s *logclient.SloRead, m *SloModel) diag
}
m.Environments = set
}

// The API never returns the channel seeds (delivery is alert-owned after
// creation), so keep whatever the config/state carries. On fresh models
// (import) the zero value has no element type; pin it to a typed null.
if m.PageChannelIDs.ElementType(ctx) == nil {
m.PageChannelIDs = types.SetNull(types.StringType)
}
if m.TicketChannelIDs.ElementType(ctx) == nil {
m.TicketChannelIDs = types.SetNull(types.StringType)
}
return nil
}

Expand Down