Repository: honeycombio/terraform-provider-honeycombio
Pull request #166 (2022-05-16 21:04:08)

Initial SLO and BurnAlert support

codecov-commenter: # [Codecov](https://codecov.io/gh/honeycombio/terraform-provider-honeycombio/pull/166?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=honeycombio) Report
> Merging [#166](https://codecov.io/gh/honeycombio/terraform-provider-honeycombio/pull/166?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=honeycombio) (f359a95) into [main](https://codecov.io/gh/honeycombio/terraform-provider-honeycombio/commit/909154749a90d3fb9f330cffa162c34bf14ed58e?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=honeycombio) (9091547) will **decrease** coverage by `2.29%`.
> The diff coverage is `69.15%`.

```diff
@@            Coverage Diff             @@
##             main     #166      +/-   ##
==========================================
- Coverage   85.83%   83.53%   -2.30%     
==========================================
  Files          27       31       +4     
  Lines        1963     2266     +303     
==========================================
+ Hits         1685     1893     +208     
- Misses        219      303      +84     
- Partials       59       70      +11     
```


| [Impacted Files](https://codecov.io/gh/honeycombio/terraform-provider-honeycombio/pull/166?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=honeycombio) | Coverage Δ | |
|---|---|---|
| [honeycombio/provider.go](https://codecov.io/gh/honeycombio/terraform-provider-honeycombio/pull/166/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=honeycombio#diff-aG9uZXljb21iaW8vcHJvdmlkZXIuZ28=) | `92.50% <ø> (ø)` | |
| [honeycombio/resource\_burn\_alert.go](https://codecov.io/gh/honeycombio/terraform-provider-honeycombio/pull/166/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=honeycombio#diff-aG9uZXljb21iaW8vcmVzb3VyY2VfYnVybl9hbGVydC5nbw==) | `60.55% <60.55%> (ø)` | |
| [honeycombio/data\_source\_query\_result.go](https://codecov.io/gh/honeycombio/terraform-provider-honeycombio/pull/166/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=honeycombio#diff-aG9uZXljb21iaW8vZGF0YV9zb3VyY2VfcXVlcnlfcmVzdWx0Lmdv) | `81.48% <62.50%> (-8.85%)` | :arrow_down: |
| [honeycombio/resource\_slo.go](https://codecov.io/gh/honeycombio/terraform-provider-honeycombio/pull/166/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=honeycombio#diff-aG9uZXljb21iaW8vcmVzb3VyY2Vfc2xvLmdv) | `67.66% <67.66%> (ø)` | |
| [client/burn\_alert.go](https://codecov.io/gh/honeycombio/terraform-provider-honeycombio/pull/166/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=honeycombio#diff-Y2xpZW50L2J1cm5fYWxlcnQuZ28=) | `100.00% <100.00%> (ø)` | |
| [client/client.go](https://codecov.io/gh/honeycombio/terraform-provider-honeycombio/pull/166/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=honeycombio#diff-Y2xpZW50L2NsaWVudC5nbw==) | `72.97% <100.00%> (+0.49%)` | :arrow_up: |
| [client/slo.go](https://codecov.io/gh/honeycombio/terraform-provider-honeycombio/pull/166/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=honeycombio#diff-Y2xpZW50L3Nsby5nbw==) | `100.00% <100.00%> (ø)` | |
| [honeycombio/type\_helpers.go](https://codecov.io/gh/honeycombio/terraform-provider-honeycombio/pull/166/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=honeycombio#diff-aG9uZXljb21iaW8vdHlwZV9oZWxwZXJzLmdv) | `100.00% <100.00%> (ø)` | |

------

[Continue to review full report at Codecov](https://codecov.io/gh/honeycombio/terraform-provider-honeycombio/pull/166?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=honeycombio).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=honeycombio)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/honeycombio/terraform-provider-honeycombio/pull/166?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=honeycombio). Last update [9091547...f359a95](https://codecov.io/gh/honeycombio/terraform-provider-honeycombio/pull/166?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=honeycombio). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=honeycombio).


Patch:
diff --git a/client/burn_alert.go b/client/burn_alert.go
new file mode 100644
index 0000000..ebde466
--- /dev/null
+++ b/client/burn_alert.go
@@ -0,0 +1,79 @@
+package client
+
+import (
+	"context"
+	"fmt"
+	"time"
+)
+
+// BurnAlerts describe all the BurnAlert-related methods that the Honeycomb API supports.
+//
+// API docs: https://docs.honeycomb.io/api/burn-alerts/
+type BurnAlerts interface {
+	// List all BurnAlerts associated with a SLO. Returns ErrNotFound if there is no SLO with
+	// the given ID in this dataset.
+	ListForSLO(ctx context.Context, dataset string, sloId string) ([]BurnAlert, error)
+
+	// Get a BurnAlert by its ID. Returns ErrNotFound if there is no BurnAlert with
+	// the given ID in this dataset.
+	Get(ctx context.Context, dataset string, id string) (*BurnAlert, error)
+
+	// Create a new BurnAlert on a SLO. When creating a BurnAlert ID may not
+	// be set.
+	Create(ctx context.Context, dataset string, s *BurnAlert) (*BurnAlert, error)
+
+	// Update an existing BurnAlert.
+	Update(ctx context.Context, dataset string, s *BurnAlert) (*BurnAlert, error)
+
+	// Delete a BurnAlert from a dataset.
+	Delete(ctx context.Context, dataset string, id string) error
+}
+
+// burnalerts implements BurnAlerts.
+type burnalerts struct {
+	client *Client
+}
+
+// Compile-time proof of interface implementation by type burnalerts.
+var _ BurnAlerts = (*burnalerts)(nil)
+
+type SLORef struct {
+	ID string `json:"id"`
+}
+
+type BurnAlert struct {
+	ID                string      `json:"id,omitempty"`
+	ExhaustionMinutes int         `json:"exhaustion_minutes"`
+	SLO               SLORef      `json:"slo"`
+	CreatedAt         time.Time   `json:"created_at,omitempty"`
+	UpdatedAt         time.Time   `json:"updated_at,omitempty"`
+	Recipients        []Recipient `json:"recipients,omitempty"`
+}
+
+func (s *burnalerts) ListForSLO(ctx context.Context, dataset string, sloId string) ([]BurnAlert, error) {
+	var b []BurnAlert
+	err := s.client.performRequest(ctx, "GET", fmt.Sprintf("/1/burn_alerts/%s?slo_id=%s", urlEncodeDataset(dataset), sloId), nil, &b)
+	return b, err
+}
+
+func (s *burnalerts) Get(ctx context.Context, dataset string, id string) (*BurnAlert, error) {
+	var b BurnAlert
+	err := s.client.performRequest(ctx, "GET", fmt.Sprintf("/1/burn_alerts/%s/%s", urlEncodeDataset(dataset), id), nil, &b)
+	return &b, err
+}
+
+func (s *burnalerts) Create(ctx context.Context, dataset string, data *BurnAlert) (*BurnAlert, error) {
+	var b BurnAlert
+	err := s.client.performRequest(ctx, "POST", fmt.Sprintf("/1/burn_alerts/%s", urlEncodeDataset(dataset)), data, &b)
+	return &b, err
+}
+
+func (s *burnalerts) Update(ctx context.Context, dataset string, data *BurnAlert) (*BurnAlert, error) {
+	var b BurnAlert
+	err := s.client.performRequest(ctx, "PUT", fmt.Sprintf("/1/burn_alerts/%s/%s", urlEncodeDataset(dataset), data.ID), data, &b)
+	return &b, err
+}
+
+func (s *burnalerts) Delete(ctx context.Context, dataset string, id string) error {
+	return s.client.performRequest(ctx, "DELETE", fmt.Sprintf("/1/burn_alerts/%s/%s", urlEncodeDataset(dataset), id), nil, nil)
+}
diff --git a/client/client.go b/client/client.go
index 316d3e2..120f3b5 100644
--- a/client/client.go
+++ b/client/client.go
@@ -71,6 +71,8 @@ type Client struct {
 	QueryAnnotations QueryAnnotations
 	QueryResults     QueryResults
 	Triggers         Triggers
+	SLOs             SLOs
+	BurnAlerts       BurnAlerts
 }
 
 // NewClient creates a new Honeycomb API client.
@@ -106,6 +108,8 @@ func NewClient(config *Config) (*Client, error) {
 	client.QueryAnnotations = &queryAnnotations{client: client}
 	client.QueryResults = &queryResults{client: client}
 	client.Triggers = &triggers{client: client}
+	client.SLOs = &slos{client: client}
+	client.BurnAlerts = &burnalerts{client: client}
 
 	return client, nil
 }
diff --git a/client/recipient.go b/client/recipient.go
new file mode 100644
index 0000000..7d76802
--- /dev/null
+++ b/client/recipient.go
@@ -0,0 +1,44 @@
+package client
+
+// Recipients creates a Recipient definition for use by BurnAlerts and Triggers in the the Honeycomb API
+type Recipient struct {
+	ID     string        `json:"id,omitempty"`
+	Type   RecipientType `json:"type,omitempty"`
+	Target string        `json:"target"`
+}
+
+// RecipientType holds all the possible recipient types.
+type RecipientType string
+
+// Declaration of recipient types
+const (
+	RecipientTypeEmail     RecipientType = "email"
+	RecipientTypePagerDuty RecipientType = "pagerduty"
+	RecipientTypeSlack     RecipientType = "slack"
+	RecipientTypeWebhook   RecipientType = "webhook"
+	RecipientTypeMarker    RecipientType = "marker"
+	RecipientTypeZenoss    RecipientType = "zenoss"
+)
+
+// TriggerRecipientTypes returns a list of recipient types compatible with Triggers
+func TriggerRecipientTypes() []RecipientType {
+	return []RecipientType{
+		RecipientTypeEmail,
+		RecipientTypePagerDuty,
+		RecipientTypeSlack,
+		RecipientTypeWebhook,
+		RecipientTypeZenoss,
+		RecipientTypeMarker,
+	}
+}
+
+// BurnAlertRecipientTypes returns a list of recipient types compatible with Burn Alerts
+func BurnAlertRecipientTypes() []RecipientType {
+	return []RecipientType{
+		RecipientTypeEmail,
+		RecipientTypePagerDuty,
+		RecipientTypeSlack,
+		RecipientTypeWebhook,
+		RecipientTypeZenoss,
+	}
+}
diff --git a/client/slo.go b/client/slo.go
new file mode 100644
index 0000000..1571dac
--- /dev/null
+++ b/client/slo.go
@@ -0,0 +1,80 @@
+package client
+
+import (
+	"context"
+	"fmt"
+	"time"
+)
+
+// SLOs describe all the SLO-related methods that the Honeycomb API supports.
+//
+// API docs: https://docs.honeycomb.io/api/slos/
+type SLOs interface {
+	// List all SLOs present in this dataset.
+	List(ctx context.Context, dataset string) ([]SLO, error)
+
+	// Get a SLO by its ID. Returns ErrNotFound if there is no SLO with
+	// the given ID in this dataset.
+	Get(ctx context.Context, dataset string, id string) (*SLO, error)
+
+	// Create a new SLO in this dataset. When creating a SLO ID may not
+	// be set.
+	Create(ctx context.Context, dataset string, s *SLO) (*SLO, error)
+
+	// Update an existing SLO.
+	Update(ctx context.Context, dataset string, s *SLO) (*SLO, error)
+
+	// Delete a SLO from the dataset.
+	Delete(ctx context.Context, dataset string, id string) error
+}
+
+// slos implements SLOs.
+type slos struct {
+	client *Client
+}
+
+// Compile-time proof of interface implementation by type slos.
+var _ SLOs = (*slos)(nil)
+
+type SLIRef struct {
+	Alias string `json:"alias"`
+}
+
+type SLO struct {
+	ID               string    `json:"id,omitempty"`
+	Name             string    `json:"name"`
+	Description      string    `json:"description,omitempty"`
+	TimePeriodDays   int       `json:"time_period_days"`
+	TargetPerMillion int       `json:"target_per_million"`
+	SLI              SLIRef    `json:"sli"`
+	CreatedAt        time.Time `json:"created_at,omitempty"`
+	UpdatedAt        time.Time `json:"updated_at,omitempty"`
+}
+
+func (s *slos) List(ctx context.Context, dataset string) ([]SLO, error) {
+	var r []SLO
+	err := s.client.performRequest(ctx, "GET", fmt.Sprintf("/1/slos/%s", urlEncodeDataset(dataset)), nil, &r)
+	return r, err
+}
+
+func (s *slos) Get(ctx context.Context, dataset string, id string) (*SLO, error) {
+	var r SLO
+	err := s.client.performRequest(ctx, "GET", fmt.Sprintf("/1/slos/%s/%s", urlEncodeDataset(dataset), id), nil, &r)
+	return &r, err
+}
+
+func (s *slos) Create(ctx context.Context, dataset string, data *SLO) (*SLO, error) {
+	var r SLO
+	err := s.client.performRequest(ctx, "POST", fmt.Sprintf("/1/slos/%s", urlEncodeDataset(dataset)), data, &r)
+	return &r, err
+}
+
+func (s *slos) Update(ctx context.Context, dataset string, data *SLO) (*SLO, error) {
+	var r SLO
+	err := s.client.performRequest(ctx, "PUT", fmt.Sprintf("/1/slos/%s/%s", urlEncodeDataset(dataset), data.ID), data, &r)
+	return &r, err
+}
+
+func (s *slos) Delete(ctx context.Context, dataset string, id string) error {
+	return s.client.performRequest(ctx, "DELETE", fmt.Sprintf("/1/slos/%s/%s", urlEncodeDataset(dataset), id), nil, nil)
+}
diff --git a/client/trigger.go b/client/trigger.go
index 18cd2aa..8c81a26 100644
--- a/client/trigger.go
+++ b/client/trigger.go
@@ -67,7 +67,7 @@ type Trigger struct {
 	// divisible by 60 and between 60 and 86400 (between 1 minute and 1 day).
 	Frequency int `json:"frequency,omitempty"`
 	// Recipients are notified when the trigger fires.
-	Recipients []TriggerRecipient `json:"recipients,omitempty"`
+	Recipients []Recipient `json:"recipients,omitempty"`
 }
 
 // TriggerThreshold represents the threshold of a trigger.
@@ -103,76 +103,6 @@ const (
 	TriggerAlertTypeValueOnTrue   string = "on_true"
 )
 
-// TriggerRecipient represents a recipient that will receive a notification
-// when the trigger fires.
-//
-// API docs: https://docs.honeycomb.io/api/triggers/#specifying-recipients
-//
-// Notes
-//
-// Recipients of type Slack should be specified by their ID. It is not possible
-// to create a new recipient of type Slack using the API. Instead use the ID of
-// a recipient of type Slack that was manually added to another trigger.
-//
-// Recipients of type webhook can be added by their name. If a webhook with
-// this name does not exist yet (or if the name contains a typo), the Honeycomb
-// API will not complain about this but the webhook will not be valid.
-type TriggerRecipient struct {
-	// ID of the recipient.
-	ID string `json:"id,omitempty"`
-	// Type of the recipient.
-	Type TriggerRecipientType `json:"type"`
-	// Target of the trigger, this has another meaning depending on type:
-	// - email: an email address
-	// - marker: name of the marker
-	// - PagerDuty: N/A
-	// - Slack: name of a channel
-	// - Webhook: name of the webhook
-	Target string `json:"target,omitempty"`
-}
-
-// Custom JSON marhsal to work around https://github.com/honeycombio/terraform-provider-honeycombio/issues/123
-//
-// It seems the Plugin SDK may reuse the value of 'target' for another
-// member of the list if target is unset (null or empty string)
-func (tr *TriggerRecipient) MarshalJSON() ([]byte, error) {
-	// aliased type to avoid stack overflows due to recursion
-	type ARecipient TriggerRecipient
-
-	if tr.Type == TriggerRecipientTypePagerDuty {
-		r := &ARecipient{
-			ID:   tr.ID,
-			Type: tr.Type,
-		}
-		return json.Marshal(&struct{ *ARecipient }{ARecipient: (*ARecipient)(r)})
-	}
-
-	return json.Marshal(&struct{ *ARecipient }{ARecipient: (*ARecipient)(tr)})
-}
-
-// TriggerRecipientType holds all the possible trigger recipient types.
-type TriggerRecipientType string
-
-// Declaration of trigger recipient types
-const (
-	TriggerRecipientTypeEmail     TriggerRecipientType = "email"
-	TriggerRecipientTypeMarker    TriggerRecipientType = "marker"
-	TriggerRecipientTypePagerDuty TriggerRecipientType = "pagerduty"
-	TriggerRecipientTypeSlack     TriggerRecipientType = "slack"
-	TriggerRecipientTypeWebhook   TriggerRecipientType = "webhook"
-)
-
-// TriggerRecipientTypes returns an exhaustive list of trigger recipient types.
-func TriggerRecipientTypes() []TriggerRecipientType {
-	return []TriggerRecipientType{
-		TriggerRecipientTypeEmail,
-		TriggerRecipientTypeMarker,
-		TriggerRecipientTypePagerDuty,
-		TriggerRecipientTypeSlack,
-		TriggerRecipientTypeWebhook,
-	}
-}
-
 func (t *Trigger) MarshalJSON() ([]byte, error) {
 	// aliased type to avoid stack overflows due to recursion
 	type ATrigger Trigger
diff --git a/docs/resources/board.md b/docs/resources/board.md
index 6f19f27..ef2ff3b 100644
--- a/docs/resources/board.md
+++ b/docs/resources/board.md
@@ -4,6 +4,8 @@ Creates a board. For more information about boards, check out [Collaborate with
 
 ## Example Usage
 
+### Simple Board
+
 ```hcl
 data "honeycombio_query_specification" "query" {
   calculation {
@@ -34,6 +36,60 @@ resource "honeycombio_board" "board" {
 }
 ```
 
+### Annotated Board
+
+```hcl
+data "honeycombio_query_specification" "latency_by_userid" {
+  time_range = 86400
+  breakdowns = ["app.user_id"]
+
+  calculation {
+    op     = "HEATMAP"
+    column = "duration_ms"
+  }
+
+  calculation {
+    op     = "P99"
+    column = "duration_ms"
+  }
+
+  filter {
+    column = "trace.parent_id"
+    op     = "does-not-exist"
+  }
+
+  order {
+    column = "duration_ms"
+    op     = "P99"
+    order  = "descending"
+  }
+}
+
+resource "honeycombio_query" "latency_by_userid" {
+  dataset    = var.dataset
+  query_json = data.honeycombio_query_specification.latency_by_userid.json
+}
+
+resource "honeycombio_query_annotation" "latency_by_userid" {
+  dataset     = var.dataset
+  query_id    = honeycombio_query.latency_by_userid.id
+  name        = "Latency by User"
+  description = "A breakdown of trace latency by User over the last 24 hours"
+}
+
+resource "honeycombio_board" "overview" {
+  name        = "Service Overview"
+  style       = "visual"
+
+  query {
+    dataset             = var.dataset
+    caption             = "Latency by User"
+    query_id            = honeycombio_query.latency_by_userid.id
+    query_annotation_id = honeycombio_query_annotation.latency_by_userid.id
+  }
+}
+```
+
 ## Argument Reference
 
 The following arguments are supported:
diff --git a/honeycombio/data_source_recipient.go b/honeycombio/data_source_recipient.go
new file mode 100644
index 0000000..052ef2f
--- /dev/null
+++ b/honeycombio/data_source_recipient.go
@@ -0,0 +1,56 @@
+package honeycombio
+
+import (
+	"context"
+
+	"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
+	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
+	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation"
+	honeycombio "github.com/honeycombio/terraform-provider-honeycombio/client"
+)
+
+func dataSourceHoneycombioRecipient() *schema.Resource {
+	return &schema.Resource{
+		ReadContext: dataSourceHoneycombioRecipientRead,
+
+		Schema: map[string]*schema.Schema{
+			"dataset": {
+				Type:     schema.TypeString,
+				Required: true,
+			},
+			"type": {
+				Type:         schema.TypeString,
+				Required:     true,
+				ValidateFunc: validation.StringInSlice(recipientTypeStrings(honeycombio.TriggerRecipientTypes()), false),
+			},
+			"target": {
+				Type:     schema.TypeString,
+				Optional: true,
+			},
+		},
+	}
+}
+
+func dataSourceHoneycombioRecipientRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
+	client := meta.(*honeycombio.Client)
+	dataset := d.Get("dataset").(string)
+
+	triggers, err := client.Triggers.List(ctx, dataset)
+	if err != nil {
+		return diag.FromErr(err)
+	}
+
+	searchType := honeycombio.RecipientType(d.Get("type").(string))
+	searchTarget := d.Get("target").(string)
+
+	for _, t := range triggers {
+		for _, r := range t.Recipients {
+			if r.Type == searchType && r.Target == searchTarget {
+				d.SetId(r.ID)
+				return nil
+			}
+		}
+	}
+
+	return diag.Errorf("could not find a trigger recipient in \"%s\" with type = \"%s\" and target = \"%s\"", dataset, searchType, searchTarget)
+}
diff --git a/honeycombio/data_source_trigger_recipient.go b/honeycombio/data_source_trigger_recipient.go
index ffdff01..d8026f9 100644
--- a/honeycombio/data_source_trigger_recipient.go
+++ b/honeycombio/data_source_trigger_recipient.go
@@ -21,7 +21,7 @@ func dataSourceHoneycombioSlackRecipient() *schema.Resource {
 			"type": {
 				Type:         schema.TypeString,
 				Required:     true,
-				ValidateFunc: validation.StringInSlice(triggerRecipientTypeStrings(), false),
+				ValidateFunc: validation.StringInSlice(recipientTypeStrings(honeycombio.TriggerRecipientTypes()), false),
 			},
 			"target": {
 				Type:     schema.TypeString,
@@ -40,7 +40,7 @@ func dataSourceHoneycombioSlackRecipientRead(ctx context.Context, d *schema.Reso
 		return diag.FromErr(err)
 	}
 
-	searchType := honeycombio.TriggerRecipientType(d.Get("type").(string))
+	searchType := honeycombio.RecipientType(d.Get("type").(string))
 	searchTarget := d.Get("target").(string)
 
 	for _, t := range triggers {
diff --git a/honeycombio/provider.go b/honeycombio/provider.go
index 96e1764..1298725 100644
--- a/honeycombio/provider.go
+++ b/honeycombio/provider.go
@@ -48,15 +48,18 @@ func Provider() *schema.Provider {
 			"honeycombio_query_result":        dataSourceHoneycombioQueryResult(),
 			"honeycombio_query_specification": dataSourceHoneycombioQuerySpec(),
 			"honeycombio_trigger_recipient":   dataSourceHoneycombioSlackRecipient(),
+			"honeycombio_recipient":           dataSourceHoneycombioRecipient(),
 		},
 		ResourcesMap: map[string]*schema.Resource{
 			"honeycombio_board":            newBoard(),
+			"honeycombio_burn_alert":       newBurnAlert(),
 			"honeycombio_column":           newColumn(),
 			"honeycombio_dataset":          newDataset(),
 			"honeycombio_derived_column":   newDerivedColumn(),
 			"honeycombio_marker":           newMarker(),
 			"honeycombio_query":            newQuery(),
 			"honeycombio_query_annotation": newQueryAnnotation(),
+			"honeycombio_slo":              newSLO(),
 			"honeycombio_trigger":          newTrigger(),
 		},
 	}
diff --git a/honeycombio/resource_burn_alert.go b/honeycombio/resource_burn_alert.go
new file mode 100644
index 0000000..2f18590
--- /dev/null
+++ b/honeycombio/resource_burn_alert.go
@@ -0,0 +1,180 @@
+package honeycombio
+
+import (
+	"context"
+	"fmt"
+	"strings"
+
+	"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
+	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
+	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation"
+	honeycombio "github.com/honeycombio/terraform-provider-honeycombio/client"
+)
+
+func newBurnAlert() *schema.Resource {
+	return &schema.Resource{
+		CreateContext: resourceBurnAlertCreate,
+		ReadContext:   resourceBurnAlertRead,
+		UpdateContext: resourceBurnAlertUpdate,
+		DeleteContext: resourceBurnAlertDelete,
+		Importer: &schema.ResourceImporter{
+			StateContext: resourceBurnAlertImport,
+		},
+		Description: "Burn Alerts are used to notify you when your error budget will be exhausted within a given time period.",
+
+		Schema: map[string]*schema.Schema{
+			"exhaustion_minutes": {
+				Type:         schema.TypeInt,
+				Required:     true,
+				Description:  "The amount of time, in minutes, remaining before the SLO's error budget will be exhausted and the alert will fire.",
+				ValidateFunc: validation.IntAtLeast(0),
+			},
+			"slo_id": {
+				Type:        schema.TypeString,
+				Required:    true,
+				ForceNew:    true,
+				Description: "The ID of the SLO that this Burn Alert is for.",
+			},
+			"dataset": {
+				Type:        schema.TypeString,
+				Required:    true,
+				ForceNew:    true,
+				Description: "The dataset this Burn Alert is added to. This must be the same as the SLO's dataset.",
+			},
+			"recipient": {
+				Type:        schema.TypeList,
+				Required:    true,
+				Description: "A Recipient to notify when an alert fires",
+				Elem: &schema.Resource{
+					// TODO can we validate either id or type+target is set?
+					Schema: map[string]*schema.Schema{
+						"id": {
+							Type:        schema.TypeString,
+							Optional:    true,
+							Computed:    true,
+							Description: "The ID of an already existing recipient. Should not be used in combination with `type` and `target`.",
+						},
+						"type": {
+							Type:         schema.TypeString,
+							Optional:     true,
+							Computed:     true,
+							ValidateFunc: validation.StringInSlice(recipientTypeStrings(honeycombio.BurnAlertRecipientTypes()), false),
+							Description:  "The type of notification. Allowed types are `email`, `pagerduty`, `slack` and `webhook`. Should not be used in combination with `id`.",
+						},
+						"target": {
+							Type:        schema.TypeString,
+							Optional:    true,
+							Computed:    true,
+							Description: "Target of the trigger recipient, this has another meaning depending on the type of recipient. Should not be used in combination with `id`.",
+						},
+					},
+				},
+			},
+		},
+	}
+}
+
+func resourceBurnAlertImport(ctx context.Context, d *schema.ResourceData, i interface{}) ([]*schema.ResourceData, error) {
+	// import ID is of the format <dataset>/<BurnAlert ID>
+	// note that the dataset name can also contain '/'
+	idSegments := strings.Split(d.Id(), "/")
+	if len(idSegments) < 2 {
+		return nil, fmt.Errorf("invalid import ID, supplied ID must be written as <dataset>/<BurnAlert ID>")
+	}
+
+	dataset := strings.Join(idSegments[0:len(idSegments)-1], "/")
+	id := idSegments[len(idSegments)-1]
+
+	d.Set("dataset", dataset)
+	d.SetId(id)
+
+	return []*schema.ResourceData{d}, nil
+}
+
+func resourceBurnAlertCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
+	client := meta.(*honeycombio.Client)
+
+	dataset := d.Get("dataset").(string)
+	b, err := expandBurnAlert(d)
+	if err != nil {
+		return diag.FromErr(err)
+	}
+
+	b, err = client.BurnAlerts.Create(ctx, dataset, b)
+	if err != nil {
+		return diag.FromErr(err)
+	}
+
+	d.SetId(b.ID)
+	return resourceBurnAlertRead(ctx, d, meta)
+}
+
+func resourceBurnAlertRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
+	client := meta.(*honeycombio.Client)
+
+	dataset := d.Get("dataset").(string)
+
+	b, err := client.BurnAlerts.Get(ctx, dataset, d.Id())
+	if err != nil {
+		if err == honeycombio.ErrNotFound {
+			d.SetId("")
+			return nil
+		}
+		return diag.FromErr(err)
+	}
+
+	d.SetId(b.ID)
+	d.Set("exhaustion_minutes", b.ExhaustionMinutes)
+	d.Set("slo_id", b.SLO.ID)
+
+	declaredRecipients, ok := d.Get("recipient").([]interface{})
+	if !ok {
+		return diag.Errorf("failed to parse recipients for Burn Alert %s", b.ID)
+	}
+	err = d.Set("recipient", flattenRecipients(matchRecipientsWithSchema(b.Recipients, declaredRecipients)))
+	if err != nil {
+		return diag.FromErr(err)
+	}
+
+	return nil
+}
+
+func resourceBurnAlertUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
+	client := meta.(*honeycombio.Client)
+
+	dataset := d.Get("dataset").(string)
+	b, err := expandBurnAlert(d)
+	if err != nil {
+		return diag.FromErr(err)
+	}
+
+	b, err = client.BurnAlerts.Update(ctx, dataset, b)
+	if err != nil {
+		return diag.FromErr(err)
+	}
+
+	d.SetId(b.ID)
+	return resourceBurnAlertRead(ctx, d, meta)
+}
+
+func resourceBurnAlertDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
+	client := meta.(*honeycombio.Client)
+
+	dataset := d.Get("dataset").(string)
+
+	err := client.BurnAlerts.Delete(ctx, dataset, d.Id())
+	if err != nil {
+		return diag.FromErr(err)
+	}
+	return nil
+}
+
+func expandBurnAlert(d *schema.ResourceData) (*honeycombio.BurnAlert, error) {
+	b := &honeycombio.BurnAlert{
+		ID:                d.Id(),
+		ExhaustionMinutes: d.Get("exhaustion_minutes").(int),
+		SLO:               honeycombio.SLORef{ID: d.Get("slo_id").(string)},
+		Recipients:        expandRecipients(d.Get("recipient").([]interface{})),
+	}
+	return b, nil
+}
diff --git a/honeycombio/resource_slo.go b/honeycombio/resource_slo.go
new file mode 100644
index 0000000..b41283f
--- /dev/null
+++ b/honeycombio/resource_slo.go
@@ -0,0 +1,166 @@
+package honeycombio
+
+import (
+	"context"
+	"fmt"
+	"strings"
+
+	"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
+	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
+	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation"
+	honeycombio "github.com/honeycombio/terraform-provider-honeycombio/client"
+)
+
+func newSLO() *schema.Resource {
+	return &schema.Resource{
+		CreateContext: resourceSLOCreate,
+		ReadContext:   resourceSLORead,
+		UpdateContext: resourceSLOUpdate,
+		DeleteContext: resourceSLODelete,
+		Importer: &schema.ResourceImporter{
+			StateContext: resourceSLOImport,
+		},
+		Description: "Honeycomb SLOs allows you to define and monitor Service Level Objectives (SLOs) for your organization.",
+
+		Schema: map[string]*schema.Schema{
+			"name": {
+				Type:         schema.TypeString,
+				Required:     true,
+				ValidateFunc: validation.StringLenBetween(1, 120),
+				Description:  "The name of the SLO.",
+			},
+			"description": {
+				Type:         schema.TypeString,
+				Optional:     true,
+				ValidateFunc: validation.StringLenBetween(1, 1023),
+				Description:  "A description of the SLO's intent and context.",
+			},
+			"dataset": {
+				Type:        schema.TypeString,
+				Required:    true,
+				ForceNew:    true,
+				Description: "The dataset this SLO is created in. Must be the same dataset as the SLI.",
+			},
+			"sli": {
+				Type:     schema.TypeString,
+				Required: true,
+				Description: `The alias of the Derived Column that will be used as the SLI to indicate event success.
+The derived column used as the SLI must be in the same dataset as the SLO. Additionally,
+the column evaluation should consistently return nil, true, or false, as these are the only valid values for an SLI.`,
+			},
+			"target_percentage": {
+				Type:         schema.TypeFloat,
+				Required:     true,
+				Description:  "The percentage of qualified events that you expect to succeed during the `time_period`.",
+				ValidateFunc: validation.FloatBetween(1.00000, 99.9999),
+			},
+			"time_period": {
+				Type:         schema.TypeInt,
+				Required:     true,
+				Description:  "The time period, in days, over which your SLO will be evaluated.",
+				ValidateFunc: validation.IntAtLeast(1),
+			},
+		},
+	}
+}
+
+func resourceSLOImport(ctx context.Context, d *schema.ResourceData, i interface{}) ([]*schema.ResourceData, error) {
+	// import ID is of the format <dataset>/<SLO ID>
+	// note that the dataset name can also contain '/'
+	idSegments := strings.Split(d.Id(), "/")
+	if len(idSegments) < 2 {
+		return nil, fmt.Errorf("invalid import ID, supplied ID must be written as <dataset>/<SLO ID>")
+	}
+
+	dataset := strings.Join(idSegments[0:len(idSegments)-1], "/")
+	id := idSegments[len(idSegments)-1]
+
+	d.Set("dataset", dataset)
+	d.SetId(id)
+
+	return []*schema.ResourceData{d}, nil
+}
+
+func resourceSLOCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
+	client := meta.(*honeycombio.Client)
+
+	dataset := d.Get("dataset").(string)
+	s, err := expandSLO(d)
+	if err != nil {
+		return diag.FromErr(err)
+	}
+
+	s, err = client.SLOs.Create(ctx, dataset, s)
+	if err != nil {
+		return diag.FromErr(err)
+	}
+
+	d.SetId(s.ID)
+	return resourceSLORead(ctx, d, meta)
+}
+
+func resourceSLORead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
+	client := meta.(*honeycombio.Client)
+
+	dataset := d.Get("dataset").(string)
+
+	s, err := client.SLOs.Get(ctx, dataset, d.Id())
+	if err != nil {
+		if err == honeycombio.ErrNotFound {
+			d.SetId("")
+			return nil
+		}
+		return diag.FromErr(err)
+	}
+
+	d.SetId(s.ID)
+	d.Set("name", s.Name)
+	d.Set("description", s.Description)
+	d.Set("sli", s.SLI.Alias)
+	d.Set("target_percentage", tpmToFloat(s.TargetPerMillion))
+	d.Set("time_period", s.TimePeriodDays)
+
+	return nil
+}
+
+func resourceSLOUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
+	client := meta.(*honeycombio.Client)
+
+	dataset := d.Get("dataset").(string)
+	s, err := expandSLO(d)
+	if err != nil {
+		return diag.FromErr(err)
+	}
+
+	s, err = client.SLOs.Update(ctx, dataset, s)
+	if err != nil {
+		return diag.FromErr(err)
+	}
+
+	d.SetId(s.ID)
+	return resourceSLORead(ctx, d, meta)
+}
+
+func resourceSLODelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
+	client := meta.(*honeycombio.Client)
+
+	dataset := d.Get("dataset").(string)
+
+	err := client.SLOs.Delete(ctx, dataset, d.Id())
+	if err != nil {
+		return diag.FromErr(err)
+	}
+	return nil
+}
+
+func expandSLO(d *schema.ResourceData) (*honeycombio.SLO, error) {
+	s := &honeycombio.SLO{
+		ID:               d.Id(),
+		Name:             d.Get("name").(string),
+		Description:      d.Get("description").(string),
+		TimePeriodDays:   d.Get("time_period").(int),
+		TargetPerMillion: floatToTPM(d.Get("target_percentage").(float64)),
+		SLI:              honeycombio.SLIRef{Alias: d.Get("sli").(string)},
+	}
+	return s, nil
+}
diff --git a/honeycombio/resource_trigger.go b/honeycombio/resource_trigger.go
index 2c08c6a..a192506 100644
--- a/honeycombio/resource_trigger.go
+++ b/honeycombio/resource_trigger.go
@@ -94,7 +94,7 @@ func newTrigger() *schema.Resource {
 							Type:         schema.TypeString,
 							Optional:     true,
 							Computed:     true,
-							ValidateFunc: validation.StringInSlice(triggerRecipientTypeStrings(), false),
+							ValidateFunc: validation.StringInSlice(recipientTypeStrings(honeycombio.TriggerRecipientTypes()), false),
 						},
 						"target": {
 							Type:     schema.TypeString,
@@ -175,7 +175,7 @@ func resourceTriggerRead(ctx context.Context, d *schema.ResourceData, meta inter
 	if !ok {
 		return diag.Errorf("failed to parse recipients for Trigger %s", t.ID)
 	}
-	err = d.Set("recipient", flattenTriggerRecipients(matchRecipientsWithSchema(t.Recipients, declaredRecipients)))
+	err = d.Set("recipient", flattenRecipients(matchRecipientsWithSchema(t.Recipients, declaredRecipients)))
 	if err != nil {
 		return diag.FromErr(err)
 	}
@@ -223,7 +223,7 @@ func expandTrigger(d *schema.ResourceData) (*honeycombio.Trigger, error) {
 		AlertType:   d.Get("alert_type").(string),
 		Threshold:   expandTriggerThreshold(d.Get("threshold").([]interface{})),
 		Frequency:   d.Get("frequency").(int),
-		Recipients:  expandTriggerRecipients(d.Get("recipient").([]interface{})),
+		Recipients:  expandRecipients(d.Get("recipient").([]interface{})),
 	}
 	return trigger, nil
 }
@@ -237,22 +237,6 @@ func expandTriggerThreshold(s []interface{}) *honeycombio.TriggerThreshold {
 	}
 }
 
-func expandTriggerRecipients(s []interface{}) []honeycombio.TriggerRecipient {
-	triggerRecipients := make([]honeycombio.TriggerRecipient, len(s))
-
-	for i, r := range s {
-		rMap := r.(map[string]interface{})
-
-		triggerRecipients[i] = honeycombio.TriggerRecipient{
-			ID:     rMap["id"].(string),
-			Type:   honeycombio.TriggerRecipientType(rMap["type"].(string)),
-			Target: rMap["target"].(string),
-		}
-	}
-
-	return triggerRecipients
-}
-
 func flattenTriggerThreshold(t *honeycombio.TriggerThreshold) []map[string]interface{} {
 	return []map[string]interface{}{
 		{
@@ -261,67 +245,3 @@ func flattenTriggerThreshold(t *honeycombio.TriggerThreshold) []map[string]inter
 		},
 	}
 }
-
-func flattenTriggerRecipients(rs []honeycombio.TriggerRecipient) []map[string]interface{} {
-	result := make([]map[string]interface{}, len(rs))
-
-	for i, r := range rs {
-		result[i] = map[string]interface{}{
-			"id":     r.ID,
-			"type":   string(r.Type),
-			"target": r.Target,
-		}
-	}
-
-	return result
-}
-
-// Matches read recipients against those declared in HCL and returns
-// the Trigger recipients in a stable order grouped by recipient type.
-//
-// This cannot currently be handled efficiently by a DiffSuppressFunc.
-// See: https://github.com/hashicorp/terraform-plugin-sdk/issues/477
-func matchRecipientsWithSchema(readRecipients []honeycombio.TriggerRecipient, declaredRecipients []interface{}) []honeycombio.TriggerRecipient {
-	result := make([]honeycombio.TriggerRecipient, len(declaredRecipients))
-
-	rMap := make(map[string]honeycombio.TriggerRecipient, len(readRecipients))
-	for _, recipient := range readRecipients {
-		rMap[recipient.ID] = recipient
-	}
-
-	// Build up result, with each readRecipient in the same position as it
-	// appears in declaredRecipients, by looking at each declaredRecipient and
-	// finding its matching readRecipient (via rMap).
-	//
-	// If the declaredRecipient has an ID, this is easy: just look it up and
-	// put it in it's place. Otherwise, try to match it to a readRecipient with
-	// the same type and target. If we can't find it at all, it must be new, so
-	// put it at the end.
-	for i, declaredRcpt := range declaredRecipients {
-		declaredRcpt := declaredRcpt.(map[string]interface{})
-
-		if declaredRcpt["id"] != "" {
-			if v, ok := rMap[declaredRcpt["id"].(string)]; ok {
-				// matched recipient declared by ID
-				result[i] = v
-				delete(rMap, v.ID)
-			}
-		} else {
-			// group result recipients by type
-			for key, rcpt := range rMap {
-				if string(rcpt.Type) == declaredRcpt["type"] && rcpt.Target == declaredRcpt["target"] {
-					result[i] = rcpt
-					delete(rMap, key)
-					break
-				}
-			}
-		}
-	}
-
-	// append unmatched read recipients to the result
-	for _, rcpt := range rMap {
-		result = append(result, rcpt)
-	}
-
-	return result
-}
diff --git a/honeycombio/type_helpers.go b/honeycombio/type_helpers.go
index abde631..6b4d37d 100644
--- a/honeycombio/type_helpers.go
+++ b/honeycombio/type_helpers.go
@@ -97,8 +97,8 @@ func sortOrderStrings() []string {
 	return out
 }
 
-func triggerRecipientTypeStrings() []string {
-	in := honeycombio.TriggerRecipientTypes()
+func recipientTypeStrings(recipientTypes []honeycombio.RecipientType) []string {
+	in := recipientTypes
 	out := make([]string, len(in))
 
 	for i := range in {
@@ -134,3 +134,98 @@ func coerceValueToType(i string) interface{} {
 	// fallthrough to string
 	return i
 }
+
+// The SLO API uses 'Target Per Million' to avoid the problems with floats.
+// In the name of offering a nicer UX with percentages, we handle the conversion
+// back and fourth to allow things like 99.98 to be provided in the HCL and
+// handle the conversion to and from 999800
+
+// converts a floating point percentage to a 'Target Per Million' SLO value
+func floatToTPM(f float64) int {
+	return int(f * 10000)
+}
+
+// converts a SLO 'Target Per Million' value to a floating point percentage
+func tpmToFloat(t int) float64 {
+	return float64(t) / 10000
+}
+
+func flattenRecipients(rs []honeycombio.Recipient) []map[string]interface{} {
+	result := make([]map[string]interface{}, len(rs))
+
+	for i, r := range rs {
+		result[i] = map[string]interface{}{
+			"id":     r.ID,
+			"type":   string(r.Type),
+			"target": r.Target,
+		}
+	}
+
+	return result
+}
+
+func expandRecipients(s []interface{}) []honeycombio.Recipient {
+	recipients := make([]honeycombio.Recipient, len(s))
+
+	for i, r := range s {
+		rMap := r.(map[string]interface{})
+
+		recipients[i] = honeycombio.Recipient{
+			ID:     rMap["id"].(string),
+			Type:   honeycombio.RecipientType(rMap["type"].(string)),
+			Target: rMap["target"].(string),
+		}
+	}
+
+	return recipients
+}
+
+// Matches read recipients against those declared in HCL and returns
+// the Trigger recipients in a stable order grouped by recipient type.
+//
+// This cannot currently be handled efficiently by a DiffSuppressFunc.
+// See: https://github.com/hashicorp/terraform-plugin-sdk/issues/477
+func matchRecipientsWithSchema(readRecipients []honeycombio.Recipient, declaredRecipients []interface{}) []honeycombio.Recipient {
+	result := make([]honeycombio.Recipient, len(declaredRecipients))
+
+	rMap := make(map[string]honeycombio.Recipient, len(readRecipients))
+	for _, recipient := range readRecipients {
+		rMap[recipient.ID] = recipient
+	}
+
+	// Build up result, with each readRecipient in the same position as it
+	// appears in declaredRecipients, by looking at each declaredRecipient and
+	// finding its matching readRecipient (via rMap).
+	//
+	// If the declaredRecipient has an ID, this is easy: just look it up and
+	// put it in it's place. Otherwise, try to match it to a readRecipient with
+	// the same type and target. If we can't find it at all, it must be new, so
+	// put it at the end.
+	for i, declaredRcpt := range declaredRecipients {
+		declaredRcpt := declaredRcpt.(map[string]interface{})
+
+		if declaredRcpt["id"] != "" {
+			if v, ok := rMap[declaredRcpt["id"].(string)]; ok {
+				// matched recipient declared by ID
+				result[i] = v
+				delete(rMap, v.ID)
+			}
+		} else {
+			// group result recipients by type
+			for key, rcpt := range rMap {
+				if string(rcpt.Type) == declaredRcpt["type"] && rcpt.Target == declaredRcpt["target"] {
+					result[i] = rcpt
+					delete(rMap, key)
+					break
+				}
+			}
+		}
+	}
+
+	// append unmatched read recipients to the result
+	for _, rcpt := range rMap {
+		result = append(result, rcpt)
+	}
+
+	return result
+}


Test patch:
diff --git a/client/burn_alert_test.go b/client/burn_alert_test.go
new file mode 100644
index 0000000..98d751b
--- /dev/null
+++ b/client/burn_alert_test.go
@@ -0,0 +1,103 @@
+package client
+
+import (
+	"context"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestBurnAlerts(t *testing.T) {
+	ctx := context.Background()
+
+	var burnAlert *BurnAlert
+	var err error
+
+	c := newTestClient(t)
+	dataset := testDataset(t)
+
+	sli, err := c.DerivedColumns.Create(ctx, dataset, &DerivedColumn{
+		Alias:      "sli.ba_slo_test",
+		Expression: "LT($duration_ms, 1000)",
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+	slo, err := c.SLOs.Create(ctx, dataset, &SLO{
+		Name:             "BurnAlert Test SLO",
+		TimePeriodDays:   7,
+		TargetPerMillion: 999000,
+		SLI:              SLIRef{Alias: sli.Alias},
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+	// remove SLO and SLI at the end of the test run
+	t.Cleanup(func() {
+		c.SLOs.Delete(ctx, dataset, slo.ID)
+		c.DerivedColumns.Delete(ctx, dataset, sli.ID)
+	})
+
+	t.Run("Create", func(t *testing.T) {
+		data := &BurnAlert{
+			ExhaustionMinutes: int(24 * 60), // 24 hours
+			SLO:               SLORef{ID: slo.ID},
+			Recipients: []Recipient{
+				{
+					Type:   "email",
+					Target: "testalert@honeycomb.test",
+				},
+			},
+		}
+
+		burnAlert, err = c.BurnAlerts.Create(ctx, dataset, data)
+
+		assert.NoError(t, err, "failed to create BurnAlert")
+		assert.NotNil(t, burnAlert.ID, "BurnAlert ID is empty")
+		assert.NotNil(t, burnAlert.CreatedAt, "created at is empty")
+		assert.NotNil(t, burnAlert.UpdatedAt, "updated at is empty")
+		// copy dynamic fields before asserting equality
+		data.ID = burnAlert.ID
+		data.CreatedAt = burnAlert.CreatedAt
+		data.UpdatedAt = burnAlert.UpdatedAt
+		data.Recipients[0].ID = burnAlert.Recipients[0].ID
+		assert.Equal(t, data, burnAlert)
+	})
+
+	t.Run("Get", func(t *testing.T) {
+		getBA, err := c.BurnAlerts.Get(ctx, dataset, burnAlert.ID)
+		assert.NoError(t, err, "failed to get BurnAlert by ID")
+		assert.Equal(t, burnAlert, getBA)
+	})
+
+	t.Run("Update", func(t *testing.T) {
+		burnAlert.ExhaustionMinutes = int(4 * 60) // 4 hours
+
+		result, err := c.BurnAlerts.Update(ctx, dataset, burnAlert)
+
+		assert.NoError(t, err, "failed to update BurnAlert")
+		// copy dynamic field before asserting equality
+		burnAlert.UpdatedAt = result.UpdatedAt
+		assert.Equal(t, burnAlert, result)
+	})
+
+	t.Run("ListForSLO", func(t *testing.T) {
+		results, err := c.BurnAlerts.ListForSLO(ctx, dataset, slo.ID)
+		burnAlert.Recipients = []Recipient{}
+		assert.NoError(t, err, "failed to list burn alerts for SLO")
+		assert.NotZero(t, len(results))
+		assert.Equal(t, burnAlert.ID, results[0].ID, "newly created BurnAlert not in list of SLO's burn alerts")
+	})
+
+	t.Run("Delete", func(t *testing.T) {
+		err = c.BurnAlerts.Delete(ctx, dataset, burnAlert.ID)
+
+		assert.NoError(t, err, "failed to delete BurnAlert")
+	})
+
+	t.Run("Get_NotFound", func(t *testing.T) {
+		_, err := c.BurnAlerts.Get(ctx, dataset, burnAlert.ID)
+
+		assert.Equal(t, ErrNotFound, err)
+	})
+}
diff --git a/client/slo_test.go b/client/slo_test.go
new file mode 100644
index 0000000..1429514
--- /dev/null
+++ b/client/slo_test.go
@@ -0,0 +1,92 @@
+package client
+
+import (
+	"context"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestSLOs(t *testing.T) {
+	ctx := context.Background()
+
+	var slo *SLO
+	var err error
+
+	c := newTestClient(t)
+	dataset := testDataset(t)
+
+	sli, err := c.DerivedColumns.Create(ctx, dataset, &DerivedColumn{
+		Alias:      "sli.slo_test",
+		Expression: "LT($duration_ms, 1000)",
+	})
+	if err != nil {
+		t.Fatal(err)
+	}
+	// remove SLI DC at end of test run
+	t.Cleanup(func() {
+		c.DerivedColumns.Delete(ctx, dataset, sli.ID)
+	})
+
+	t.Run("Create", func(t *testing.T) {
+		data := &SLO{
+			Name:             "Testsuite SLO",
+			Description:      "My Super Sweet Test",
+			TimePeriodDays:   30,
+			TargetPerMillion: 995000,
+			SLI:              SLIRef{Alias: sli.Alias},
+		}
+
+		slo, err = c.SLOs.Create(ctx, dataset, data)
+
+		assert.NoError(t, err, "unable to create SLO")
+		assert.NotNil(t, slo.ID, "SLO ID is empty")
+		assert.NotNil(t, slo.CreatedAt, "created at is empty")
+		assert.NotNil(t, slo.UpdatedAt, "updated at is empty")
+		// copy dynamic fields before asserting equality
+		data.ID = slo.ID
+		data.CreatedAt = slo.CreatedAt
+		data.UpdatedAt = slo.UpdatedAt
+		assert.Equal(t, data, slo)
+	})
+
+	t.Run("List", func(t *testing.T) {
+		results, err := c.SLOs.List(ctx, dataset)
+
+		assert.NoError(t, err, "unable to list SLOs")
+		assert.Contains(t, results, *slo, "could not find newly created SLO with List")
+	})
+
+	t.Run("Get", func(t *testing.T) {
+		getSLO, err := c.SLOs.Get(ctx, dataset, slo.ID)
+
+		assert.NoError(t, err, "failed to get SLO by ID")
+		assert.Equal(t, *slo, *getSLO)
+	})
+
+	t.Run("Update", func(t *testing.T) {
+		slo.Name = "Test Sweet SLO"
+		slo.TimePeriodDays = 14
+		slo.Description = "Even sweeter"
+		slo.TargetPerMillion = 990000
+
+		result, err := c.SLOs.Update(ctx, dataset, slo)
+
+		assert.NoError(t, err, "failed to update SLO")
+		// copy dynamic field before asserting equality
+		slo.UpdatedAt = result.UpdatedAt
+		assert.Equal(t, result, slo)
+	})
+
+	t.Run("Delete", func(t *testing.T) {
+		err = c.SLOs.Delete(ctx, dataset, slo.ID)
+
+		assert.NoError(t, err, "failed to delete SLO")
+	})
+
+	t.Run("Get_NotFound", func(t *testing.T) {
+		_, err := c.SLOs.Get(ctx, dataset, slo.ID)
+
+		assert.Equal(t, ErrNotFound, err)
+	})
+}
diff --git a/client/trigger_test.go b/client/trigger_test.go
index 9e52105..0f29414 100644
--- a/client/trigger_test.go
+++ b/client/trigger_test.go
@@ -50,14 +50,13 @@ func TestTriggers(t *testing.T) {
 				Op:    TriggerThresholdOpGreaterThan,
 				Value: 10000,
 			},
-			AlertType: "on_change",
-			Recipients: []TriggerRecipient{
+			Recipients: []Recipient{
 				{
-					Type:   TriggerRecipientTypeEmail,
+					Type:   RecipientTypeEmail,
 					Target: "hello@example.com",
 				},
 				{
-					Type:   TriggerRecipientTypeMarker,
+					Type:   RecipientTypeMarker,
 					Target: "This marker is created by a trigger",
 				},
 			},
diff --git a/honeycombio/data_source_trigger_recipient_test.go b/honeycombio/data_source_trigger_recipient_test.go
index 77d173e..4f821f5 100644
--- a/honeycombio/data_source_trigger_recipient_test.go
+++ b/honeycombio/data_source_trigger_recipient_test.go
@@ -12,8 +12,8 @@ import (
 func TestAccDataSourceHoneycombioTriggerRecipient_basic(t *testing.T) {
 	dataset := testAccDataset()
 
-	_, deleteFn := createTriggerWithRecipient(t, dataset, honeycombio.TriggerRecipient{
-		Type:   honeycombio.TriggerRecipientTypeEmail,
+	_, deleteFn := createTriggerWithRecipient(t, dataset, honeycombio.Recipient{
+		Type:   honeycombio.RecipientTypeEmail,
 		Target: "acctest@example.com",
 	})
 	defer deleteFn()
diff --git a/honeycombio/helpers_test.go b/honeycombio/helpers_test.go
index 8996a6a..b1933e6 100644
--- a/honeycombio/helpers_test.go
+++ b/honeycombio/helpers_test.go
@@ -55,7 +55,7 @@ func testCheckOutputDoesNotContain(name, contains string) resource.TestCheckFunc
 	}
 }
 
-func createTriggerWithRecipient(t *testing.T, dataset string, recipient honeycombio.TriggerRecipient) (trigger *honeycombio.Trigger, deleteFn func()) {
+func createTriggerWithRecipient(t *testing.T, dataset string, recipient honeycombio.Recipient) (trigger *honeycombio.Trigger, deleteFn func()) {
 	ctx := context.Background()
 	c := testAccClient(t)
 
@@ -72,7 +72,7 @@ func createTriggerWithRecipient(t *testing.T, dataset string, recipient honeycom
 			Op:    honeycombio.TriggerThresholdOpGreaterThan,
 			Value: 100,
 		},
-		Recipients: []honeycombio.TriggerRecipient{recipient},
+		Recipients: []honeycombio.Recipient{recipient},
 	}
 	trigger, err := c.Triggers.Create(ctx, dataset, trigger)
 	if err != nil {
diff --git a/honeycombio/resource_burn_alert_test.go b/honeycombio/resource_burn_alert_test.go
new file mode 100644
index 0000000..914fefd
--- /dev/null
+++ b/honeycombio/resource_burn_alert_test.go
@@ -0,0 +1,143 @@
+package honeycombio
+
+import (
+	"context"
+	"fmt"
+	"testing"
+
+	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource"
+	"github.com/hashicorp/terraform-plugin-sdk/v2/terraform"
+	honeycombio "github.com/honeycombio/terraform-provider-honeycombio/client"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestAccHoneycombioBurnAlert_basic(t *testing.T) {
+	ctx := context.Background()
+	c := testAccClient(t)
+	dataset := testAccDataset()
+
+	sli, err := c.DerivedColumns.Create(ctx, dataset, &honeycombio.DerivedColumn{
+		Alias:      "sli.acc_ba_test",
+		Expression: "LT($duration_ms, 1000)",
+	})
+	if err != nil {
+		t.Error(err)
+	}
+	slo, err := c.SLOs.Create(ctx, dataset, &honeycombio.SLO{
+		Name:             "BA TestAcc SLO",
+		TimePeriodDays:   14,
+		TargetPerMillion: 995000,
+		SLI:              honeycombio.SLIRef{Alias: sli.Alias},
+	})
+	require.NoError(t, err)
+	// remove SLO, SLI DC at end of test run
+	t.Cleanup(func() {
+		c.SLOs.Delete(ctx, dataset, slo.ID)
+		c.DerivedColumns.Delete(ctx, dataset, sli.ID)
+	})
+
+	resource.Test(t, resource.TestCase{
+		PreCheck:          testAccPreCheck(t),
+		ProviderFactories: testAccProviderFactories,
+		Steps: []resource.TestStep{
+			{
+				Config: fmt.Sprintf(`
+resource "honeycombio_burn_alert" "test" {
+  dataset            = "%s"
+  slo_id             = "%s"
+  exhaustion_minutes = 240 # 4 hours
+
+  recipient {
+    type   = "slack"
+    target = "#test2"
+  }
+
+}
+`, dataset, slo.ID),
+				Check: resource.ComposeTestCheckFunc(
+					testAccCheckBurnAlertExists(t, dataset, "honeycombio_burn_alert.test"),
+				),
+			},
+		},
+	})
+}
+
+// add a recipient by ID to verify the diff is stable
+func TestAccHoneycombioBurnAlert_RecipientById(t *testing.T) {
+	ctx := context.Background()
+	c := testAccClient(t)
+	dataset := testAccDataset()
+
+	sli, err := c.DerivedColumns.Create(ctx, dataset, &honeycombio.DerivedColumn{
+		Alias:      "sli.acc_ba_test",
+		Expression: "LT($duration_ms, 1000)",
+	})
+	if err != nil {
+		t.Error(err)
+	}
+	slo, err := c.SLOs.Create(ctx, dataset, &honeycombio.SLO{
+		Name:             "BA TestAcc SLO",
+		TimePeriodDays:   14,
+		TargetPerMillion: 995000,
+		SLI:              honeycombio.SLIRef{Alias: sli.Alias},
+	})
+	require.NoError(t, err)
+	// remove SLO, SLI DC at end of test run
+	t.Cleanup(func() {
+		c.SLOs.Delete(ctx, dataset, slo.ID)
+		c.DerivedColumns.Delete(ctx, dataset, sli.ID)
+	})
+
+	trigger, deleteFn := createTriggerWithRecipient(t, dataset, honeycombio.Recipient{
+		Type:   honeycombio.RecipientTypeEmail,
+		Target: "acctest@example.com",
+	})
+	defer deleteFn()
+
+	resource.Test(t, resource.TestCase{
+		PreCheck:          testAccPreCheck(t),
+		ProviderFactories: testAccProviderFactories,
+		Steps: []resource.TestStep{
+			{
+				Config: fmt.Sprintf(`
+				resource "honeycombio_burn_alert" "test" {
+				  dataset            = "%s"
+				  slo_id             = "%s"
+				  exhaustion_minutes = 240 # 4 hours
+				
+				  recipient {
+					id   = "%s"
+				  }
+				
+				}
+				`, dataset, slo.ID, trigger.Recipients[0].ID),
+				Check: resource.ComposeTestCheckFunc(
+					testAccCheckBurnAlertExists(t, dataset, "honeycombio_burn_alert.test"),
+				),
+			},
+		},
+	})
+}
+
+func testAccCheckBurnAlertExists(t *testing.T, dataset string, resourceName string) resource.TestCheckFunc {
+	return func(s *terraform.State) error {
+		resourceState, ok := s.RootModule().Resources[resourceName]
+		if !ok {
+			return fmt.Errorf("not found: %s", resourceName)
+		}
+
+		client := testAccClient(t)
+		createdBA, err := client.BurnAlerts.Get(context.Background(), dataset, resourceState.Primary.ID)
+		if err != nil {
+			return fmt.Errorf("could not find created BurnAlert: %w", err)
+		}
+
+		assert.Equal(t, resourceState.Primary.ID, createdBA.ID)
+		assert.Equal(t, resourceState.Primary.Attributes["slo_id"], createdBA.SLO.ID)
+		assert.Equal(t, resourceState.Primary.Attributes["exhaustion_minutes"], fmt.Sprintf("%v", createdBA.ExhaustionMinutes))
+		assert.NotNil(t, resourceState.Primary.Attributes["recipient"])
+
+		return nil
+	}
+}
diff --git a/honeycombio/resource_slo_test.go b/honeycombio/resource_slo_test.go
new file mode 100644
index 0000000..258ecd0
--- /dev/null
+++ b/honeycombio/resource_slo_test.go
@@ -0,0 +1,76 @@
+package honeycombio
+
+import (
+	"context"
+	"fmt"
+	"testing"
+
+	"github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource"
+	"github.com/hashicorp/terraform-plugin-sdk/v2/terraform"
+	honeycombio "github.com/honeycombio/terraform-provider-honeycombio/client"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestAccHoneycombioSLO_basic(t *testing.T) {
+	ctx := context.Background()
+	c := testAccClient(t)
+	dataset := testAccDataset()
+
+	sli, err := c.DerivedColumns.Create(ctx, dataset, &honeycombio.DerivedColumn{
+		Alias:      "sli.acc_slo_test",
+		Expression: "LT($duration_ms, 1000)",
+	})
+	if err != nil {
+		t.Error(err)
+	}
+	// remove SLI DC at end of test run
+	t.Cleanup(func() {
+		c.DerivedColumns.Delete(ctx, dataset, sli.ID)
+	})
+
+	resource.Test(t, resource.TestCase{
+		PreCheck:          testAccPreCheck(t),
+		ProviderFactories: testAccProviderFactories,
+		Steps: []resource.TestStep{
+			{
+				Config: fmt.Sprintf(`
+resource "honeycombio_slo" "test" {
+  name              = "TestAcc SLO"
+  description       = "integration test SLO"
+  dataset           = "%s"
+  sli               = "%s"
+  target_percentage = 99.95
+  time_period       = 30
+}
+`, dataset, sli.Alias),
+				Check: resource.ComposeTestCheckFunc(
+					testAccCheckSLOExists(t, dataset, "honeycombio_slo.test", "TestAcc SLO"),
+				),
+			},
+		},
+	})
+}
+
+func testAccCheckSLOExists(t *testing.T, dataset string, resourceName string, name string) resource.TestCheckFunc {
+	return func(s *terraform.State) error {
+		resourceState, ok := s.RootModule().Resources[resourceName]
+		if !ok {
+			return fmt.Errorf("not found: %s", resourceName)
+		}
+
+		client := testAccClient(t)
+		createdSLO, err := client.SLOs.Get(context.Background(), dataset, resourceState.Primary.ID)
+		if err != nil {
+			return fmt.Errorf("could not find created SLO: %w", err)
+		}
+
+		assert.Equal(t, resourceState.Primary.ID, createdSLO.ID)
+		assert.Equal(t, name, createdSLO.Name)
+		assert.Equal(t, resourceState.Primary.Attributes["description"], createdSLO.Description)
+		assert.Equal(t, resourceState.Primary.Attributes["sli"], createdSLO.SLI.Alias)
+		assert.Equal(t, resourceState.Primary.Attributes["target_percentage"], fmt.Sprintf("%v", tpmToFloat(createdSLO.TargetPerMillion)))
+		assert.Equal(t, resourceState.Primary.Attributes["time_period"], fmt.Sprintf("%v", createdSLO.TimePeriodDays))
+
+		return nil
+	}
+}
diff --git a/honeycombio/resource_trigger_test.go b/honeycombio/resource_trigger_test.go
index 7ce099c..c8b4eb6 100644
--- a/honeycombio/resource_trigger_test.go
+++ b/honeycombio/resource_trigger_test.go
@@ -93,8 +93,8 @@ func testAccCheckTriggerAttributes(t *honeycombio.Trigger) resource.TestCheckFun
 func TestAccHoneycombioTrigger_triggerRecipientById(t *testing.T) {
 	dataset := testAccDataset()
 
-	trigger, deleteFn := createTriggerWithRecipient(t, dataset, honeycombio.TriggerRecipient{
-		Type:   honeycombio.TriggerRecipientTypeEmail,
+	trigger, deleteFn := createTriggerWithRecipient(t, dataset, honeycombio.Recipient{
+		Type:   honeycombio.RecipientTypeEmail,
 		Target: "acctest@example.com",
 	})
 	defer deleteFn()