Repository: honeycombio/terraform-provider-honeycombio
Pull request #667 (2025-04-30 15:55:24)

feat(d/slo,r/slo): add 'tags' support
Our first tag-supported resource: SLOs!

This introduces the tags shape to the provider client and adds tags support to `r/slo` and `d/slo`.


Patch:
diff --git a/client/slo.go b/client/slo.go
index 28fdfe7..d2ba2f4 100644
--- a/client/slo.go
+++ b/client/slo.go
@@ -46,6 +46,7 @@ type SLO struct {
 	TimePeriodDays   int       `json:"time_period_days"`
 	TargetPerMillion int       `json:"target_per_million"`
 	DatasetSlugs     []string  `json:"dataset_slugs,omitempty"`
+	Tags             []Tag     `json:"tags"`
 	SLI              SLIRef    `json:"sli"`
 	CreatedAt        time.Time `json:"created_at,omitempty"`
 	UpdatedAt        time.Time `json:"updated_at,omitempty"`
diff --git a/client/tags.go b/client/tags.go
new file mode 100644
index 0000000..09cfd60
--- /dev/null
+++ b/client/tags.go
@@ -0,0 +1,38 @@
+package client
+
+import (
+	"fmt"
+	"regexp"
+)
+
+// MaxTagsPerResource is the maximum number of tags that can be associated with a resource.
+const MaxTagsPerResource = 10
+
+var (
+	// TagKeyValidationRegex is the regex used to validate tag keys.
+	// It must be a string of lowercase letters, between 1 and 32 characters long.
+	TagKeyValidationRegex = regexp.MustCompile(`^[a-z]{1,32}$`)
+
+	// TagValueValidationRegex is the regex used to validate tag values.
+	// It must begin with a lowercase letter, be between 1 and 32 characters long,
+	// and only contain alphanumeric characters, -, or /.
+	TagValueValidationRegex = regexp.MustCompile(`^[a-z][a-z0-9\/-]{1,32}$`)
+)
+
+// Tag represents a key-value pair used for tagging resources.
+type Tag struct {
+	Key   string `json:"key"`
+	Value string `json:"value"`
+}
+
+// Validate checks if the tag key and value are valid according to the defined regex patterns.
+func (t *Tag) Validate() error {
+	if !TagKeyValidationRegex.MatchString(t.Key) {
+		return fmt.Errorf("tag key %q is invalid: must be 1-32 lowercase letters", t.Key)
+	}
+	if !TagValueValidationRegex.MatchString(t.Value) {
+		return fmt.Errorf("tag value %q is invalid: must begin with a lowercase letter,"+
+			" be 1-32 characters long, and only contain lowercase alphanumeric characters, -, or /", t.Value)
+	}
+	return nil
+}
diff --git a/docs/data-sources/slo.md b/docs/data-sources/slo.md
index 22feb59..7104197 100644
--- a/docs/data-sources/slo.md
+++ b/docs/data-sources/slo.md
@@ -27,5 +27,6 @@ In addition to all arguments above, the following attributes are exported:
 * `description` - the SLO's description.
 * `sli` - the alias of the Derived Column used as the SLO's SLI.
 * `datasets` - A list of dataset slugs the SLO is evaluated on.
+* `tags` - A map of the tags assigned to the resource.
 * `target_percentage` - the percentage of qualified events expected to succeed during the `time_period`.
-* `time_period` - The time period, in days, over which the SLO is evaluated.
\ No newline at end of file
+* `time_period` - The time period, in days, over which the SLO is evaluated.
diff --git a/docs/resources/slo.md b/docs/resources/slo.md
index d0d6d59..3c9f5bf 100644
--- a/docs/resources/slo.md
+++ b/docs/resources/slo.md
@@ -27,6 +27,11 @@ resource "honeycombio_slo" "slo" {
   sli               = honeycombio_derived_column.request_latency_sli.alias
   target_percentage = 99.9
   time_period       = 30
+
+  tags = {
+    team    = "blue"
+    project = "secret"
+  }
 }
 ```
 
@@ -52,6 +57,11 @@ resource "honeycombio_slo" "slo" {
   sli               = honeycombio_derived_column.request_latency_sli.alias
   target_percentage = 99.9
   time_period       = 30
+
+  tags = {
+    team         = "red"
+    experimental = "true"
+  }
 }
 ```
 
@@ -69,6 +79,7 @@ The following arguments are supported:
 -   `sli` - (Required) 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.
+-   `tags` - (Optional) Map of tags to assign to the resource.
 -   `target_percentage` - (Required) The percentage of qualified events that you expect to succeed during the `time_period`.
 -   `time_period` - (Required) The time period, in days, over which your SLO will be evaluated.
 
diff --git a/example/slo_with_burn_alert/main.tf b/example/slo_with_burn_alert/main.tf
index 4b9eab9..9ea237e 100644
--- a/example/slo_with_burn_alert/main.tf
+++ b/example/slo_with_burn_alert/main.tf
@@ -17,6 +17,12 @@ resource "honeycombio_derived_column" "request_latency_sli" {
 
   # heredoc also works
   expression = file("../sli/sli.request_latency.honeycomb")
+
+  lifecycle {
+    # in order to avoid potential conflicts with renaming the derived column
+    # while in use by the SLO, we set create_before_destroy to true
+    create_before_destroy = true
+  }
 }
 
 resource "honeycombio_slo" "slo" {
@@ -26,6 +32,10 @@ resource "honeycombio_slo" "slo" {
   sli               = honeycombio_derived_column.request_latency_sli.alias
   target_percentage = 99.9
   time_period       = 30
+
+  tags = {
+    team = "web"
+  }
 }
 
 resource "honeycombio_burn_alert" "burn_alert" {
@@ -37,4 +47,4 @@ resource "honeycombio_burn_alert" "burn_alert" {
     type   = "slack"
     target = "#example-channel"
   }
-}
\ No newline at end of file
+}
diff --git a/honeycombio/resource_slo.go b/honeycombio/resource_slo.go
index f9819d9..5c959ca 100644
--- a/honeycombio/resource_slo.go
+++ b/honeycombio/resource_slo.go
@@ -3,8 +3,10 @@ package honeycombio
 import (
 	"context"
 	"errors"
+	"reflect"
 	"strings"
 
+	"github.com/hashicorp/go-cty/cty"
 	"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"
@@ -42,7 +44,7 @@ func newSLO() *schema.Resource {
 				Optional:    true,
 				ForceNew:    true,
 				Description: "The dataset this SLO is created in. Will be deprecated in a future release. Must be the same dataset as the SLI unless the SLI Derived Column is Environment-wide.",
-				DiffSuppressFunc: func(k, oldValue, newValue string, d *schema.ResourceData) bool {
+				DiffSuppressFunc: func(_, oldValue, newValue string, _ *schema.ResourceData) bool {
 					// not using the shared 'SuppressEquivEnvWideDataset' as SLOs aren't using
 					// `__all__` directly and need an explicit list of datasets (below)
 					if oldValue == newValue {
@@ -86,6 +88,36 @@ the column evaluation should consistently return nil, true, or false, as these a
 				Description:  "The time period, in days, over which your SLO will be evaluated.",
 				ValidateFunc: validation.IntAtLeast(1),
 			},
+			"tags": {
+				Type:        schema.TypeMap,
+				Optional:    true,
+				Description: "A map of tags to assign to the resource.",
+				DiffSuppressFunc: func(_, _, _ string, d *schema.ResourceData) bool {
+					// suppress the diff if the tag maps are equivalent
+					oldMap, newMap := d.GetChange("tags")
+					return reflect.DeepEqual(oldMap.(map[string]any), newMap.(map[string]any))
+				},
+				Elem: &schema.Schema{
+					Type: schema.TypeString,
+				},
+				ValidateDiagFunc: validation.AllDiag(
+					validation.MapKeyMatch(
+						honeycombio.TagKeyValidationRegex,
+						"must only contain lowercase letters, and be 1-32 characters long",
+					),
+					validation.MapValueMatch(
+						honeycombio.TagValueValidationRegex,
+						"must begin with a lowercase letter, be between 1-32 characters long, and only contain lowercase alphanumeric characters, -, or /",
+					),
+					func(v any, path cty.Path) diag.Diagnostics {
+						// ensure the number of tags is within the resource limit
+						if len(v.(map[string]any)) > honeycombio.MaxTagsPerResource {
+							return diag.Errorf("Max %d tags per resource", honeycombio.MaxTagsPerResource)
+						}
+						return nil
+					},
+				),
+			},
 		},
 	}
 }
@@ -152,6 +184,12 @@ func resourceSLORead(ctx context.Context, d *schema.ResourceData, meta interface
 	d.Set("time_period", s.TimePeriodDays)
 	d.Set("datasets", s.DatasetSlugs)
 
+	tags := make(map[string]string, len(s.Tags))
+	for _, tag := range s.Tags {
+		tags[tag.Key] = tag.Value
+	}
+	d.Set("tags", tags)
+
 	return nil
 }
 
@@ -188,13 +226,29 @@ func resourceSLODelete(ctx context.Context, d *schema.ResourceData, meta interfa
 }
 
 func expandSLO(d *schema.ResourceData) *honeycombio.SLO {
-	var datasets []string
+	datasets := make([]string, len(d.Get("datasets").(*schema.Set).List()))
 	if v, ok := d.GetOk("datasets"); ok {
-		for _, v := range v.(*schema.Set).List() {
-			datasets = append(datasets, v.(string))
+		for i, v := range v.(*schema.Set).List() {
+			datasets[i] = v.(string)
 		}
 	}
 
+	var tags []honeycombio.Tag
+	rawTags, _ := d.GetRawConfigAt(cty.GetAttrPath("tags"))
+	// if 'tags' is present in the config, build the tags slice
+	if !rawTags.IsNull() && rawTags.LengthInt() > 0 {
+		tags = make([]honeycombio.Tag, 0, len(d.Get("tags").(map[string]any)))
+		if v, ok := d.GetOk("tags"); ok {
+			for k, v := range v.(map[string]any) {
+				tags = append(tags, honeycombio.Tag{Key: k, Value: v.(string)})
+			}
+		}
+	} else {
+		// if 'tags' is not present in the config, set to empty slice
+		// to clear the tags
+		tags = make([]honeycombio.Tag, 0)
+	}
+
 	return &honeycombio.SLO{
 		ID:               d.Id(),
 		Name:             d.Get("name").(string),
@@ -203,5 +257,6 @@ func expandSLO(d *schema.ResourceData) *honeycombio.SLO {
 		TargetPerMillion: helper.FloatToPPM(d.Get("target_percentage").(float64)),
 		SLI:              honeycombio.SLIRef{Alias: d.Get("sli").(string)},
 		DatasetSlugs:     datasets,
+		Tags:             tags,
 	}
 }
diff --git a/internal/models/slo.go b/internal/models/slo.go
index 764f4ec..14c226f 100644
--- a/internal/models/slo.go
+++ b/internal/models/slo.go
@@ -18,6 +18,7 @@ type SLODataSourceModel struct {
 	Name             types.String   `tfsdk:"name"`
 	Description      types.String   `tfsdk:"description"`
 	SLI              types.String   `tfsdk:"sli"`
+	Tags             types.Map      `tfsdk:"tags"`
 	TargetPercentage types.Float64  `tfsdk:"target_percentage"`
 	TimePeriod       types.Int64    `tfsdk:"time_period"`
 }
diff --git a/internal/provider/slo_data_source.go b/internal/provider/slo_data_source.go
index 4f3b3fb..838f927 100644
--- a/internal/provider/slo_data_source.go
+++ b/internal/provider/slo_data_source.go
@@ -72,6 +72,13 @@ func (d *sloDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, re
 				Optional:    false,
 				Required:    false,
 			},
+			"tags": schema.MapAttribute{
+				ElementType: types.StringType,
+				Description: "A map of tags associated with the resource.",
+				Computed:    true,
+				Optional:    false,
+				Required:    false,
+			},
 			"target_percentage": schema.Float64Attribute{
 				Description: "The percentage of qualified events expected to succeed during the `time_period`.",
 				Computed:    true,
@@ -124,11 +131,19 @@ func (d *sloDataSource) Read(ctx context.Context, req datasource.ReadRequest, re
 	data.SLI = types.StringValue(slo.SLI.Alias)
 	data.TargetPercentage = types.Float64Value(float64(slo.TargetPerMillion) / 10000)
 	data.TimePeriod = types.Int64Value(int64(slo.TimePeriodDays))
+
 	data.Datasets = make([]types.String, len(slo.DatasetSlugs))
 	for i, slug := range slo.DatasetSlugs {
 		data.Datasets[i] = types.StringValue(slug)
 	}
 
-	diags := resp.State.Set(ctx, &data)
+	tags := make(map[string]types.String, len(slo.Tags))
+	for _, tag := range slo.Tags {
+		tags[tag.Key] = types.StringValue(tag.Value)
+	}
+	tagsObj, diags := types.MapValueFrom(ctx, types.StringType, tags)
 	resp.Diagnostics.Append(diags...)
+	data.Tags = tagsObj
+
+	resp.Diagnostics.Append(resp.State.Set(ctx, &data)...)
 }


Test patch:
diff --git a/client/slo_test.go b/client/slo_test.go
index c369c17..74d2858 100644
--- a/client/slo_test.go
+++ b/client/slo_test.go
@@ -2,7 +2,9 @@ package client_test
 
 import (
 	"context"
+	"slices"
 	"testing"
+	"time"
 
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
@@ -28,9 +30,8 @@ func TestSLOs(t *testing.T) {
 		Expression: "BOOL(1)",
 	})
 	require.NoError(t, err, "unable to create SLI")
-
-	// remove SLI DC at end of test run
 	t.Cleanup(func() {
+		// remove SLI DC at end of test run
 		c.DerivedColumns.Delete(ctx, dataset, sli.ID)
 	})
 
@@ -41,41 +42,62 @@ func TestSLOs(t *testing.T) {
 			TimePeriodDays:   30,
 			TargetPerMillion: 995000,
 			SLI:              client.SLIRef{Alias: sli.Alias},
+			Tags: []client.Tag{
+				{Key: "color", Value: "blue"},
+				{Key: "team", Value: "a-team"},
+			},
 		}
 		slo, err = c.SLOs.Create(ctx, dataset, data)
-
 		require.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
-		data.DatasetSlugs = []string{dataset}
 
-		assert.Equal(t, data, slo)
+		assert.NotNil(t, slo.ID, "SLO ID is empty")
+		assert.Equal(t, slo.Name, data.Name)
+		assert.Equal(t, slo.Description, data.Description)
+		assert.Equal(t, slo.TimePeriodDays, data.TimePeriodDays)
+		assert.Equal(t, slo.TargetPerMillion, data.TargetPerMillion)
+		assert.Equal(t, slo.SLI.Alias, data.SLI.Alias)
+		assert.WithinDuration(t, time.Now(), slo.CreatedAt, time.Second*15)
+		assert.WithinDuration(t, time.Now(), slo.UpdatedAt, time.Second*15)
+		assert.Equal(t, []string{dataset}, slo.DatasetSlugs)
+		assert.ElementsMatch(t, slo.Tags, data.Tags, "tags do not match")
 	})
 
 	t.Run("List all slos for a dataset", func(t *testing.T) {
-		results, err := c.SLOs.List(ctx, dataset)
-
-		require.NoError(t, err, "unable to list SLOs")
-		assert.Contains(t, results, *slo, "could not find newly created SLO with List")
+		// this has proven to be a bit racey after the create above, so we'll retry a few times
+		assert.EventuallyWithT(t, func(col *assert.CollectT) {
+			slos, err := c.SLOs.List(ctx, dataset)
+			require.NoError(col, err)
+
+			// not doing an Equal here because the timestamps may be different
+			// and confirming the ID is in the listing is sufficient
+			assert.Condition(col, func() bool {
+				return slices.ContainsFunc(slos, func(s client.SLO) bool {
+					return s.ID == slo.ID
+				})
+			})
+		}, time.Second, 200*time.Millisecond, "could not find newly created SLO in List")
 	})
 
 	t.Run("List all slos in an environment", func(t *testing.T) {
-		results, err := c.SLOs.List(ctx, client.EnvironmentWideSlug)
-
-		require.NoError(t, err, "unable to list SLOs")
-		assert.Contains(t, results, *slo, "could not find newly created SLO with List")
+		// this has proven to be a bit racey after the create above, so we'll retry a few times
+		assert.EventuallyWithT(t, func(col *assert.CollectT) {
+			slos, err := c.SLOs.List(ctx, client.EnvironmentWideSlug)
+			require.NoError(col, err)
+
+			// not doing an Equal here because the timestamps may be different
+			// and confirming the ID is in the listing is sufficient
+			assert.Condition(col, func() bool {
+				return slices.ContainsFunc(slos, func(s client.SLO) bool {
+					return s.ID == slo.ID
+				})
+			})
+		}, time.Second, 200*time.Millisecond, "could not find newly created SLO in List")
 	})
 
 	t.Run("Get", func(t *testing.T) {
-		getSLO, err := c.SLOs.Get(ctx, dataset, slo.ID)
-
+		result, err := c.SLOs.Get(ctx, dataset, slo.ID)
 		require.NoError(t, err, "failed to get SLO by ID")
-		assert.Equal(t, *slo, *getSLO)
+		assert.Equal(t, slo.ID, result.ID)
 	})
 
 	t.Run("Update", func(t *testing.T) {
@@ -83,18 +105,25 @@ func TestSLOs(t *testing.T) {
 		slo.TimePeriodDays = 14
 		slo.Description = "Even sweeter"
 		slo.TargetPerMillion = 990000
+		slo.Tags = append(slo.Tags, client.Tag{Key: "new", Value: "tag"})
 
 		result, err := c.SLOs.Update(ctx, dataset, slo)
-
 		require.NoError(t, err, "failed to update SLO")
-		// copy dynamic field before asserting equality
-		slo.UpdatedAt = result.UpdatedAt
-		assert.Equal(t, result, slo)
+
+		assert.Equal(t, slo.ID, result.ID)
+		assert.Equal(t, slo.Name, result.Name)
+		assert.Equal(t, slo.Description, result.Description)
+		assert.Equal(t, slo.TimePeriodDays, result.TimePeriodDays)
+		assert.Equal(t, slo.TargetPerMillion, result.TargetPerMillion)
+		assert.Equal(t, slo.SLI.Alias, result.SLI.Alias)
+		assert.Equal(t, slo.CreatedAt, result.CreatedAt)
+		assert.WithinDuration(t, time.Now(), slo.UpdatedAt, time.Second*15)
+		assert.Equal(t, []string{dataset}, slo.DatasetSlugs)
+		assert.ElementsMatch(t, slo.Tags, result.Tags, "tags do not match")
 	})
 
 	t.Run("Delete", func(t *testing.T) {
 		err = c.SLOs.Delete(ctx, dataset, slo.ID)
-
 		require.NoError(t, err, "failed to delete SLO")
 	})
 
@@ -121,7 +150,7 @@ func Test_MDSLOs(t *testing.T) {
 	}
 
 	// test setup for MD SLO
-	var mdSLO *client.SLO
+	var slo *client.SLO
 	var mdSLI *client.DerivedColumn
 	var dataset1, dataset2 *client.Dataset
 
@@ -145,7 +174,7 @@ func Test_MDSLOs(t *testing.T) {
 	require.NoError(t, err)
 
 	t.Cleanup(func() {
-		c.SLOs.Delete(ctx, client.EnvironmentWideSlug, mdSLO.ID)
+		c.SLOs.Delete(ctx, client.EnvironmentWideSlug, slo.ID)
 		c.DerivedColumns.Delete(ctx, client.EnvironmentWideSlug, mdSLI.ID)
 
 		c.Datasets.Update(ctx, &client.Dataset{
@@ -170,7 +199,7 @@ func Test_MDSLOs(t *testing.T) {
 			t.Skip("Classic does not support multi-dataset SLOs")
 		}
 
-		mdData := &client.SLO{
+		data := &client.SLO{
 			Name:             test.RandomStringWithPrefix("test.", 10),
 			Description:      "My Super Sweet Test",
 			TimePeriodDays:   30,
@@ -179,19 +208,18 @@ func Test_MDSLOs(t *testing.T) {
 			DatasetSlugs:     []string{dataset1.Slug, dataset2.Slug},
 		}
 
-		mdSLO, err = c.SLOs.Create(ctx, client.EnvironmentWideSlug, mdData)
+		slo, err = c.SLOs.Create(ctx, client.EnvironmentWideSlug, data)
 
-		require.NoError(t, err, "unable to create SLO")
-		assert.NotNil(t, mdSLO.ID, "SLO ID is empty")
-		assert.NotNil(t, mdSLO.CreatedAt, "created at is empty")
-		assert.NotNil(t, mdSLO.UpdatedAt, "updated at is empty")
-		// copy dynamic fields before asserting equality
-		mdData.ID = mdSLO.ID
-		mdData.CreatedAt = mdSLO.CreatedAt
-		mdData.UpdatedAt = mdSLO.UpdatedAt
-		mdData.DatasetSlugs = []string{dataset1.Slug, dataset2.Slug}
-
-		assert.Equal(t, mdData, mdSLO)
+		assert.NotNil(t, slo.ID, "SLO ID is empty")
+		assert.Equal(t, data.Name, slo.Name)
+		assert.Equal(t, data.Description, slo.Description)
+		assert.Equal(t, data.TimePeriodDays, slo.TimePeriodDays)
+		assert.Equal(t, data.TargetPerMillion, slo.TargetPerMillion)
+		assert.Equal(t, data.SLI.Alias, slo.SLI.Alias)
+		assert.WithinDuration(t, time.Now(), slo.CreatedAt, time.Second*15)
+		assert.WithinDuration(t, time.Now(), slo.UpdatedAt, time.Second*15)
+		assert.ElementsMatch(t, data.DatasetSlugs, slo.DatasetSlugs)
+		assert.ElementsMatch(t, data.Tags, slo.Tags, "tags do not match")
 	})
 
 	t.Run("Get MD SLO", func(t *testing.T) {
@@ -199,9 +227,9 @@ func Test_MDSLOs(t *testing.T) {
 			t.Skip("Classic does not support multi-dataset SLOs")
 		}
 
-		getMDSLO, err := c.SLOs.Get(ctx, client.EnvironmentWideSlug, mdSLO.ID)
+		result, err := c.SLOs.Get(ctx, client.EnvironmentWideSlug, slo.ID)
 		require.NoError(t, err, "failed to get MD SLO by ID")
-		assert.Equal(t, *mdSLO, *getMDSLO)
+		assert.Equal(t, slo.ID, result.ID)
 	})
 
 	t.Run("Update MD SLO", func(t *testing.T) {
@@ -209,22 +237,21 @@ func Test_MDSLOs(t *testing.T) {
 			t.Skip("Classic does not support multi-dataset SLOs")
 		}
 
-		mdSLO.Name = test.RandomStringWithPrefix("test.", 10)
-		mdSLO.TimePeriodDays = 14
-		mdSLO.Description = "Even sweeter"
-		mdSLO.TargetPerMillion = 990000
+		slo.Name = test.RandomStringWithPrefix("test.", 10)
+		slo.TimePeriodDays = 14
+		slo.Description = "Even sweeter"
+		slo.TargetPerMillion = 990000
 
-		result, err := c.SLOs.Update(ctx, client.EnvironmentWideSlug, mdSLO)
+		result, err := c.SLOs.Update(ctx, client.EnvironmentWideSlug, slo)
 
 		require.NoError(t, err, "failed to update MD SLO")
 		// copy dynamic field before asserting equality
-		mdSLO.UpdatedAt = result.UpdatedAt
-		assert.Equal(t, result, mdSLO)
+		slo.UpdatedAt = result.UpdatedAt
+		assert.Equal(t, result, slo)
 	})
 
 	t.Run("Delete MD SLO", func(t *testing.T) {
-
-		err = c.SLOs.Delete(ctx, client.EnvironmentWideSlug, mdSLO.ID)
+		err = c.SLOs.Delete(ctx, client.EnvironmentWideSlug, slo.ID)
 		require.NoError(t, err, "failed to delete MD SLO")
 	})
 }
diff --git a/client/tags_test.go b/client/tags_test.go
new file mode 100644
index 0000000..69644f3
--- /dev/null
+++ b/client/tags_test.go
@@ -0,0 +1,75 @@
+package client_test
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+
+	"github.com/honeycombio/terraform-provider-honeycombio/client"
+	"github.com/honeycombio/terraform-provider-honeycombio/internal/helper/test"
+)
+
+func Test_TagValidation(t *testing.T) {
+	t.Parallel()
+
+	testCases := map[string]struct {
+		Tag       client.Tag
+		ExpectErr bool
+	}{
+		"valid tag": {
+			Tag: client.Tag{
+				Key:   "env",
+				Value: "test",
+			},
+		},
+		"zero tag": {
+			Tag:       client.Tag{},
+			ExpectErr: true,
+		},
+		"invalid key": {
+			Tag: client.Tag{
+				Key:   "Invalid-key",
+				Value: "test",
+			},
+			ExpectErr: true,
+		},
+		"key too long": {
+			Tag: client.Tag{
+				Key:   test.RandomString(50),
+				Value: "test",
+			},
+			ExpectErr: true,
+		},
+		"invalid value": {
+			Tag: client.Tag{
+				Key:   "env",
+				Value: "Invalid-value!",
+			},
+			ExpectErr: true,
+		},
+		"value too long": {
+			Tag: client.Tag{
+				Key:   "env",
+				Value: test.RandomString(50),
+			},
+			ExpectErr: true,
+		},
+		"mega invalid tag": {
+			Tag: client.Tag{
+				Key:   "@SA!" + test.RandomString(50),
+				Value: "!-395/" + test.RandomString(50),
+			},
+			ExpectErr: true,
+		},
+	}
+
+	for name, tc := range testCases {
+		t.Run(name, func(t *testing.T) {
+			if tc.ExpectErr {
+				assert.Error(t, tc.Tag.Validate())
+			} else {
+				assert.NoError(t, tc.Tag.Validate())
+			}
+		})
+	}
+}
diff --git a/honeycombio/resource_slo_test.go b/honeycombio/resource_slo_test.go
index dabac02..2febb2c 100644
--- a/honeycombio/resource_slo_test.go
+++ b/honeycombio/resource_slo_test.go
@@ -25,7 +25,20 @@ func TestAccHoneycombioSLO_basic(t *testing.T) {
 		ProtoV5ProviderFactories: testAccProtoV5ProviderFactory,
 		Steps: []resource.TestStep{
 			{
-				Config: testAccConfigSLO_basic(dataset, sliAlias),
+				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
+
+  tags = {
+    env  = "test"
+    team = "blue"
+  }
+}`, dataset, sliAlias),
 				Check: resource.ComposeTestCheckFunc(
 					testAccCheckSLOExists(t, "honeycombio_slo.test", slo),
 					resource.TestCheckResourceAttr("honeycombio_slo.test", "dataset", dataset),
@@ -34,8 +47,38 @@ func TestAccHoneycombioSLO_basic(t *testing.T) {
 					resource.TestCheckResourceAttr("honeycombio_slo.test", "sli", sliAlias),
 					resource.TestCheckResourceAttr("honeycombio_slo.test", "target_percentage", "99.95"),
 					resource.TestCheckResourceAttr("honeycombio_slo.test", "time_period", "30"),
+					resource.TestCheckResourceAttr("honeycombio_slo.test", "tags.%", "2"),
+					resource.TestCheckResourceAttr("honeycombio_slo.test", "tags.env", "test"),
+					resource.TestCheckResourceAttr("honeycombio_slo.test", "tags.team", "blue"),
+				),
+			},
+			{ // update the config to remove the tags and change the description and percentage
+				Config: fmt.Sprintf(`
+resource "honeycombio_slo" "test" {
+  name              = "TestAcc SLO"
+  description       = "updated integration test SLO"
+  dataset           = "%s"
+  sli               = "%s"
+  target_percentage = 99.99
+  time_period       = 30
+}`, dataset, sliAlias),
+				Check: resource.ComposeTestCheckFunc(
+					testAccCheckSLOExists(t, "honeycombio_slo.test", slo),
+					resource.TestCheckResourceAttr("honeycombio_slo.test", "dataset", dataset),
+					resource.TestCheckResourceAttr("honeycombio_slo.test", "name", "TestAcc SLO"),
+					resource.TestCheckResourceAttr("honeycombio_slo.test", "description", "updated integration test SLO"),
+					resource.TestCheckResourceAttr("honeycombio_slo.test", "sli", sliAlias),
+					resource.TestCheckResourceAttr("honeycombio_slo.test", "target_percentage", "99.99"),
+					resource.TestCheckResourceAttr("honeycombio_slo.test", "time_period", "30"),
+					resource.TestCheckResourceAttr("honeycombio_slo.test", "tags.%", "0"),
 				),
 			},
+			{
+				ResourceName:        "honeycombio_slo.test",
+				ImportStateIdPrefix: fmt.Sprintf("%s/", dataset),
+				ImportState:         true,
+				ImportStateVerify:   true,
+			},
 		},
 	})
 }
@@ -120,21 +163,24 @@ func TestHoneycombSLO_MD(t *testing.T) {
 			{
 				Config: testAccConfigSLO_md(dataset1.Slug, dataset2.Slug, mdSLI.Alias),
 				Check: resource.ComposeTestCheckFunc(
-					testAccCheckSLOExists(t, "honeycombio_slo.md_test", mdSLO),
-					resource.TestCheckResourceAttr("honeycombio_slo.md_test", "name", "TestAcc MD SLO"),
-					resource.TestCheckNoResourceAttr("honeycombio_slo.md_test", "dataset"),
-					resource.TestCheckResourceAttr("honeycombio_slo.md_test", "datasets.#", "2"),
-					resource.TestCheckTypeSetElemAttr("honeycombio_slo.md_test", "datasets.*", dataset1.Slug),
-					resource.TestCheckTypeSetElemAttr("honeycombio_slo.md_test", "datasets.*", dataset2.Slug),
-					resource.TestCheckResourceAttr("honeycombio_slo.md_test", "description", "integration test MD SLO"),
-					resource.TestCheckResourceAttr("honeycombio_slo.md_test", "sli", mdSLI.Alias),
-					resource.TestCheckResourceAttr("honeycombio_slo.md_test", "target_percentage", "99.95"),
-					resource.TestCheckResourceAttr("honeycombio_slo.md_test", "time_period", "30"),
+					testAccCheckSLOExists(t, "honeycombio_slo.test", mdSLO),
+					resource.TestCheckResourceAttr("honeycombio_slo.test", "name", "TestAcc MD SLO"),
+					resource.TestCheckNoResourceAttr("honeycombio_slo.test", "dataset"),
+					resource.TestCheckResourceAttr("honeycombio_slo.test", "datasets.#", "2"),
+					resource.TestCheckTypeSetElemAttr("honeycombio_slo.test", "datasets.*", dataset1.Slug),
+					resource.TestCheckTypeSetElemAttr("honeycombio_slo.test", "datasets.*", dataset2.Slug),
+					resource.TestCheckResourceAttr("honeycombio_slo.test", "description", "integration test MD SLO"),
+					resource.TestCheckResourceAttr("honeycombio_slo.test", "sli", mdSLI.Alias),
+					resource.TestCheckResourceAttr("honeycombio_slo.test", "target_percentage", "99.95"),
+					resource.TestCheckResourceAttr("honeycombio_slo.test", "time_period", "30"),
+					resource.TestCheckResourceAttr("honeycombio_slo.test", "tags.%", "2"),
+					resource.TestCheckResourceAttr("honeycombio_slo.test", "tags.env", "test"),
+					resource.TestCheckResourceAttr("honeycombio_slo.test", "tags.team", "red"),
 				),
 			},
 			// tests imports
 			{
-				ResourceName:      "honeycombio_slo.md_test",
+				ResourceName:      "honeycombio_slo.test",
 				ImportState:       true,
 				ImportStateVerify: true,
 				ImportStateId:     mdSLO.ID,
@@ -145,41 +191,53 @@ func TestHoneycombSLO_MD(t *testing.T) {
 
 func testAccConfigSLO_basic(dataset, sliAlias string) string {
 	return 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, sliAlias)
+resource "honeycombio_slo" "test" {
+  name              = "TestAcc SLO"
+  description       = "integration test SLO"
+  dataset           = "%s"
+  sli               = "%s"
+  target_percentage = 99.95
+  time_period       = 30
+
+  tags = {
+    env  = "test"
+    team = "blue"
+  }
+}`, dataset, sliAlias)
 }
 
 func testAccConfigSLO_dataset_deprecation(dataset, sliAlias string) string {
 	return fmt.Sprintf(`
-	resource "honeycombio_slo" "test" {
-		name              = "TestAcc SLO"
-		description       = "integration test SLO"
-		datasets          = ["%s"]
-		sli               = "%s"
-		target_percentage = 99.95
-		time_period       = 30
-	}
-	`, dataset, sliAlias)
+resource "honeycombio_slo" "test" {
+  name              = "TestAcc SLO"
+  description       = "integration test SLO"
+  datasets          = ["%s"]
+  sli               = "%s"
+  target_percentage = 99.95
+  time_period       = 30
+
+  tags = {
+    env  = "test"
+    team = "blue"
+  }
+}`, dataset, sliAlias)
 }
 
 func testAccConfigSLO_md(dataset1Slug, dataset2Slug, sliAlias string) string {
 	return fmt.Sprintf(`
-	resource "honeycombio_slo" "md_test" {
-		name              = "TestAcc MD SLO"
-		description       = "integration test MD SLO"
-		sli               = "%s"
-		target_percentage = 99.95
-		time_period       = 30
-		datasets     	  = ["%s", "%s"]
-	}
-	`, sliAlias, dataset1Slug, dataset2Slug)
+resource "honeycombio_slo" "test" {
+  name              = "TestAcc MD SLO"
+  description       = "integration test MD SLO"
+  sli               = "%s"
+  datasets     	    = ["%s", "%s"]
+  target_percentage = 99.95
+  time_period       = 30
+
+  tags = {
+    env  = "test"
+    team = "red"
+  }
+}`, sliAlias, dataset1Slug, dataset2Slug)
 }
 
 func testAccCheckSLOExists(t *testing.T, name string, slo *honeycombio.SLO) resource.TestCheckFunc {
diff --git a/internal/provider/slo_data_source_test.go b/internal/provider/slo_data_source_test.go
index 5d4b98c..a7acf08 100644
--- a/internal/provider/slo_data_source_test.go
+++ b/internal/provider/slo_data_source_test.go
@@ -32,6 +32,10 @@ func TestAcc_SLODataSource(t *testing.T) {
 		TimePeriodDays:   30,
 		TargetPerMillion: 995000,
 		SLI:              client.SLIRef{Alias: sli.Alias},
+		Tags: []client.Tag{
+			{Key: "env", Value: "test"},
+			{Key: "team", Value: "test"},
+		},
 	})
 	require.NoError(t, err)
 
@@ -57,6 +61,11 @@ data "honeycombio_slo" "test" {
 					resource.TestCheckResourceAttr("data.honeycombio_slo.test", "target_percentage", "99.5"),
 					resource.TestCheckResourceAttr("data.honeycombio_slo.test", "time_period", "30"),
 					resource.TestCheckResourceAttr("data.honeycombio_slo.test", "dataset", dataset),
+					resource.TestCheckResourceAttr("data.honeycombio_slo.test", "datasets.#", "1"),
+					resource.TestCheckResourceAttr("data.honeycombio_slo.test", "datasets.0", dataset),
+					resource.TestCheckResourceAttr("data.honeycombio_slo.test", "tags.%", "2"),
+					resource.TestCheckResourceAttr("data.honeycombio_slo.test", "tags.env", "test"),
+					resource.TestCheckResourceAttr("data.honeycombio_slo.test", "tags.team", "test"),
 				),
 			},
 		},
@@ -96,6 +105,10 @@ func TestAcc_MDSLODataSource(t *testing.T) {
 		TargetPerMillion: 995000,
 		SLI:              client.SLIRef{Alias: sli.Alias},
 		DatasetSlugs:     []string{dataset1.Slug, dataset2.Slug},
+		Tags: []client.Tag{
+			{Key: "env", Value: "test"},
+			{Key: "team", Value: "test"},
+		},
 	})
 	require.NoError(t, err)
 
@@ -139,6 +152,9 @@ data "honeycombio_slo" "test" {
 					resource.TestCheckResourceAttr("data.honeycombio_slo.test", "datasets.#", "2"),
 					resource.TestCheckResourceAttr("data.honeycombio_slo.test", "datasets.0", dataset1.Slug),
 					resource.TestCheckResourceAttr("data.honeycombio_slo.test", "datasets.1", dataset2.Slug),
+					resource.TestCheckResourceAttr("data.honeycombio_slo.test", "tags.%", "2"),
+					resource.TestCheckResourceAttr("data.honeycombio_slo.test", "tags.env", "test"),
+					resource.TestCheckResourceAttr("data.honeycombio_slo.test", "tags.team", "test"),
 				),
 			},
 		},