Repository: honeycombio/terraform-provider-honeycombio
Pull request #658 (2025-04-10 17:52:28)

feat(r/trigger): add baseline_details support
## Which problem is this PR solving?

- https://app.asana.com/1/72322038700732/project/1203970798110426/task/1209772672706729

## Short description of the changes

Adds support for a trigger with a dynamic threshold aka baseline_details. 

## How to verify that this has the expected result

pre-reqs:
- run hound in tilt (you need both poodle and public-api)
- in the root, run `make build`

examples:
- in `examples/trigger`, run `terraform init`,`terraform plan` and `terraform apply`
- confirm that the trigger was made in your local poodle ui (with a static threshold)
- in `examples/trigger_with_baseline_details`, run `terraform init`,`terraform plan` and `terraform apply`
- confirm that the trigger was made in your local poodle ui (with a dynamic threshold)

You can adjust the example to test validations: 
- only include one of the `baseline_details` fields, like `type` but no `offset_minutes`
- give it an invalid `type` (not `value` or `percentage`)
- give it an invalid `offset_minutes` (not 60, 1440, 10080, or 40320)



Patch:
diff --git a/client/trigger.go b/client/trigger.go
index d6480d6..0568d10 100644
--- a/client/trigger.go
+++ b/client/trigger.go
@@ -74,7 +74,13 @@ 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 []NotificationRecipient `json:"recipients,omitempty"`
+	Recipients      []NotificationRecipient `json:"recipients,omitempty"`
+	BaselineDetails *TriggerBaselineDetails `json:"baseline_details,omitempty"`
+}
+
+type TriggerBaselineDetails struct {
+	Type          string `json:"type"`
+	OffsetMinutes int    `json:"offset_minutes"`
 }
 
 // TriggerThreshold represents the threshold of a trigger.
diff --git a/docs/resources/trigger.md b/docs/resources/trigger.md
index cd6530c..34cee6e 100644
--- a/docs/resources/trigger.md
+++ b/docs/resources/trigger.md
@@ -182,6 +182,40 @@ resource "honeycombio_trigger" "example" {
 }
 ```
 
+### Example - Example with Baseline Details
+```hcl
+variable "dataset" {
+    type = string
+}
+
+data "honeycombio_query_specification" "example" {
+    calculation {
+        op     = "AVG"
+        column = "duration_ms"
+    }
+}
+
+resource "honeycombio_trigger" "example" {
+    name        = "Requests are slower than usual"
+    description = "Average duration of all requests for the last 10 minutes."
+
+    query_json = data.honeycombio_query_specification.example.json
+    dataset    = var.dataset
+
+    frequency = 600 // in seconds, 10 minutes
+
+    threshold {
+        op             = ">="
+        value          = 1000
+    }
+    
+    baseline_details {
+        type            = "percentage"
+        offset_minutes  = 1440
+    }
+}
+```
+
 ## Argument Reference
 
 The following arguments are supported:
@@ -203,6 +237,7 @@ Defaults to 900 (15 minutes).
 When the time is within the scheduled window the trigger will be run at the specified frequency.
 Outside of the window, the trigger will not be run.
 If no schedule is specified, the trigger will be run at the specified frequency at all times.
+* `baseline_details` - (Optional) A configuration block (described below) allows you to receive notifications when the delta between values in your data, compared to a previous time period, cross thresholds you configure.
 * `recipient` - (Optional) Zero or more configuration blocks (described below) with the recipients to notify when the trigger fires.
 
 One of `query_id` or `query_json` are required.
@@ -224,6 +259,11 @@ Each trigger configuration may provide an `evaluation_schedule` block, which acc
 * `end_time` - (Required) UTC time to stop evaluating the trigger in HH:mm format (e.g. `13:00`)
 * `days_of_week` - (Required) A list of days of the week (in lowercase) to evaluate the trigger on
 
+Each trigger configuration may provide an `baseline_details` block, which accepts the following arguments:
+
+* `type` - (Required) Either `value` or `percentage` to indicate either an absolute value or percentage delta.
+* `offset_minutes` - (Required) Either `60` (1 hour), `1440` (24 hours), `10080` (7 days), or `40320` (28 days) to indicate the length of the previous time period.
+
 Each trigger configuration may have zero or more `recipient` blocks, which each accept the following arguments. A trigger recipient block can either refer to an existing recipient (a recipient that is already present in another trigger) or a new recipient. When specifying an existing recipient, only `id` may be set. If you pass in a recipient without its ID and only include the type and target, Honeycomb will make a best effort to match to an existing recipient. To retrieve the ID of an existing recipient, refer to the [`honeycombio_recipient`](../data-sources/recipient.md) data source.
 
 * `type` - (Optional) The type of the trigger recipient, allowed types are `email`, `marker`, `msteams`, `pagerduty`, `slack` and `webhook`.
diff --git a/example/trigger_with_baseline_details/main.tf b/example/trigger_with_baseline_details/main.tf
new file mode 100644
index 0000000..29a4303
--- /dev/null
+++ b/example/trigger_with_baseline_details/main.tf
@@ -0,0 +1,58 @@
+terraform {
+  required_providers {
+    honeycombio = {
+      source = "honeycombio/honeycombio"
+    }
+  }
+}
+
+data "honeycombio_query_specification" "query" {
+  calculation {
+    op     = "AVG"
+    column = "db_dur_ms"
+  }
+
+  time_range = 900 // in seconds, 15 minutes
+  
+}
+
+variable "dataset" {
+  type = string
+}
+
+resource "honeycombio_trigger" "trigger" {
+  name        = "Requests are slower than usual"
+  description = "Average duration of all requests for ThatSpecialTenant for the last 15 minutes."
+
+  disabled = false
+
+  query_json = data.honeycombio_query_specification.query.json
+  dataset    = var.dataset
+
+  frequency = 900 // in seconds, 15 minutes
+
+  threshold {
+    op    = ">="
+    value = 1000
+  }
+
+  # zero or more recipients
+  recipient {
+    type   = "email"
+    target = "hello@example.com"
+  }
+  recipient {
+    type   = "marker"
+    target = "Trigger - slow requests" # name of the marker
+  }
+
+  baseline_details {
+    type = "percentage"
+    offset_minutes = 1440
+  }
+}
+
+
+output "result" {
+  value = resource.honeycombio_trigger.trigger
+}
\ No newline at end of file
diff --git a/internal/models/triggers.go b/internal/models/triggers.go
index dcd5b95..1d7a419 100644
--- a/internal/models/triggers.go
+++ b/internal/models/triggers.go
@@ -18,6 +18,17 @@ type TriggerResourceModel struct {
 	Threshold          types.List   `tfsdk:"threshold"`           // TriggerThresholdModel
 	Recipients         types.Set    `tfsdk:"recipient"`           // NotificationRecipientModel
 	EvaluationSchedule types.List   `tfsdk:"evaluation_schedule"` // TriggerEvaluationScheduleModel
+	BaselineDetails    types.List   `tfsdk:"baseline_details"`
+}
+
+type TriggerBaselineDetailsModel struct {
+	Type          types.String `tfsdk:"type"`
+	OffsetMinutes types.Int64  `tfsdk:"offset_minutes"`
+}
+
+var TriggerBaselineDetailsAttrType = map[string]attr.Type{
+	"type":           types.StringType,
+	"offset_minutes": types.Int64Type,
 }
 
 type TriggerThresholdModel struct {
diff --git a/internal/provider/trigger_resource.go b/internal/provider/trigger_resource.go
index 08e7a74..8a72b9a 100644
--- a/internal/provider/trigger_resource.go
+++ b/internal/provider/trigger_resource.go
@@ -212,6 +212,31 @@ func (r *triggerResource) Schema(_ context.Context, _ resource.SchemaRequest, re
 				},
 			},
 			"recipient": notificationRecipientSchema(client.TriggerRecipientTypes()),
+			"baseline_details": schema.ListNestedBlock{
+				Description: "A configuration block that allows you to receive notifications when the delta between values in your data, " +
+					"compared to a previous time period, cross thresholds you configure.",
+				Validators: []validator.List{
+					listvalidator.SizeAtMost(1),
+				},
+				NestedObject: schema.NestedBlockObject{
+					Attributes: map[string]schema.Attribute{
+						"type": schema.StringAttribute{
+							Description: "Whether to use an absolute value or percentage delta.",
+							Required:    true,
+							Validators: []validator.String{
+								stringvalidator.OneOf("value", "percentage"),
+							},
+						},
+						"offset_minutes": schema.Int64Attribute{
+							Description: "What previous time period to evaluate against: 1 hour, 1 day, 1 week, or 4 weeks.",
+							Required:    true,
+							Validators: []validator.Int64{
+								int64validator.OneOf(60, 1440, 10080, 40320),
+							},
+						},
+					},
+				},
+			},
 		},
 	}
 }
@@ -247,6 +272,7 @@ func (r *triggerResource) Create(ctx context.Context, req resource.CreateRequest
 		Frequency:          int(plan.Frequency.ValueInt64()),
 		Recipients:         expandNotificationRecipients(ctx, plan.Recipients, &resp.Diagnostics),
 		EvaluationSchedule: expandTriggerEvaluationSchedule(ctx, plan.EvaluationSchedule, &resp.Diagnostics),
+		BaselineDetails:    expandBaselineDetails(ctx, plan.BaselineDetails, &resp.Diagnostics),
 	}
 	if resp.Diagnostics.HasError() {
 		return
@@ -291,6 +317,7 @@ func (r *triggerResource) Create(ctx context.Context, req resource.CreateRequest
 	state.EvaluationSchedule = flattenTriggerEvaluationSchedule(ctx, trigger.EvaluationSchedule, &resp.Diagnostics)
 	// we created them as authored so to avoid matching type-target or ID we can just use the same value
 	state.Recipients = config.Recipients
+	state.BaselineDetails = flattenBaselineDetails(ctx, trigger.BaselineDetails, &resp.Diagnostics)
 
 	if specifiedByID {
 		state.QueryID = types.StringValue(trigger.QueryID)
@@ -344,6 +371,7 @@ func (r *triggerResource) Read(ctx context.Context, req resource.ReadRequest, re
 	state.Frequency = types.Int64Value(int64(trigger.Frequency))
 	state.EvaluationSchedule = flattenTriggerEvaluationSchedule(ctx, trigger.EvaluationSchedule, &resp.Diagnostics)
 	state.Recipients = reconcileReadNotificationRecipientState(ctx, trigger.Recipients, state.Recipients, &resp.Diagnostics)
+	state.BaselineDetails = flattenBaselineDetails(ctx, trigger.BaselineDetails, &resp.Diagnostics)
 
 	specifiedByID := !state.QueryID.IsNull()
 	if specifiedByID {
@@ -385,6 +413,7 @@ func (r *triggerResource) Update(ctx context.Context, req resource.UpdateRequest
 		Threshold:          expandTriggerThreshold(ctx, plan.Threshold, &resp.Diagnostics),
 		Recipients:         expandNotificationRecipients(ctx, plan.Recipients, &resp.Diagnostics),
 		EvaluationSchedule: expandTriggerEvaluationSchedule(ctx, plan.EvaluationSchedule, &resp.Diagnostics),
+		BaselineDetails:    expandBaselineDetails(ctx, plan.BaselineDetails, &resp.Diagnostics),
 	}
 	if resp.Diagnostics.HasError() {
 		return
@@ -436,6 +465,7 @@ func (r *triggerResource) Update(ctx context.Context, req resource.UpdateRequest
 	state.EvaluationSchedule = flattenTriggerEvaluationSchedule(ctx, trigger.EvaluationSchedule, &resp.Diagnostics)
 	// we created them as authored so to avoid matching type-target or ID we can just use the same value
 	state.Recipients = config.Recipients
+	state.BaselineDetails = flattenBaselineDetails(ctx, trigger.BaselineDetails, &resp.Diagnostics)
 
 	if specifiedByID {
 		state.QueryID = types.StringValue(trigger.QueryID)
@@ -496,6 +526,7 @@ func (r *triggerResource) ImportState(ctx context.Context, req resource.ImportSt
 		Recipients:         types.SetUnknown(types.ObjectType{AttrTypes: models.NotificationRecipientAttrType}),
 		Threshold:          types.ListUnknown(types.ObjectType{AttrTypes: models.TriggerThresholdAttrType}),
 		EvaluationSchedule: types.ListUnknown(types.ObjectType{AttrTypes: models.TriggerEvaluationScheduleAttrType}),
+		BaselineDetails:    types.ListUnknown(types.ObjectType{AttrTypes: models.TriggerBaselineDetailsAttrType}),
 	})...)
 }
 
@@ -642,6 +673,53 @@ func flattenTriggerThreshold(
 	return result
 }
 
+func flattenBaselineDetails(
+	ctx context.Context,
+	t *client.TriggerBaselineDetails,
+	diags *diag.Diagnostics,
+) types.List {
+	if t == nil {
+		return types.ListNull(types.ObjectType{AttrTypes: models.TriggerBaselineDetailsAttrType})
+	}
+
+	detailsObj, d := types.ObjectValue(models.TriggerBaselineDetailsAttrType, map[string]attr.Value{
+		"type":           types.StringValue(t.Type),
+		"offset_minutes": types.Int64Value(int64(t.OffsetMinutes)),
+	})
+	diags.Append(d...)
+
+	result, d := types.ListValueFrom(
+		ctx,
+		types.ObjectType{AttrTypes: models.TriggerBaselineDetailsAttrType},
+		[]attr.Value{detailsObj},
+	)
+	diags.Append(d...)
+
+	return result
+}
+
+func expandBaselineDetails(
+	ctx context.Context,
+	l types.List,
+	diags *diag.Diagnostics,
+) *client.TriggerBaselineDetails {
+	// if plan doesn't include baseline_details, send the API an empty object so trigger becomes a static threshold
+	if l.IsNull() || l.IsUnknown() {
+		return &client.TriggerBaselineDetails{}
+	}
+
+	var s []models.TriggerBaselineDetailsModel
+	diags.Append(l.ElementsAs(ctx, &s, false)...)
+	if diags.HasError() || len(s) == 0 {
+		return nil
+	}
+
+	return &client.TriggerBaselineDetails{
+		Type:          s[0].Type.ValueString(),
+		OffsetMinutes: int(s[0].OffsetMinutes.ValueInt64()),
+	}
+}
+
 func expandTriggerEvaluationSchedule(
 	ctx context.Context,
 	l types.List,


Test patch:
diff --git a/client/trigger_test.go b/client/trigger_test.go
index de1c641..3c9526d 100644
--- a/client/trigger_test.go
+++ b/client/trigger_test.go
@@ -206,3 +206,102 @@ func TestMatchesTriggerSubset(t *testing.T) {
 		assert.Equal(t, c.expectedErr, err, "Test case %d, QuerySpec: %v", i, c.in)
 	}
 }
+
+func TestTriggersWithBaselineDetails(t *testing.T) {
+	t.Parallel()
+
+	ctx := context.Background()
+
+	var trigger *client.Trigger
+	var err error
+
+	c := newTestClient(t)
+	dataset := testDataset(t)
+	floatCol, _, _ := createRandomTestColumns(t, c, dataset)
+
+	// delete the trigger after the tests run
+	t.Cleanup(func() {
+		c.Triggers.Delete(ctx, dataset, trigger.ID)
+	})
+
+	t.Run("Create - with baseline_details", func(t *testing.T) {
+		data := &client.Trigger{
+			Name:        test.RandomStringWithPrefix("test.", 8),
+			Description: "Some description",
+			Disabled:    true,
+			Query: &client.QuerySpec{
+				Breakdowns: nil,
+				Calculations: []client.CalculationSpec{
+					{
+						Op:     client.CalculationOpRateAvg,
+						Column: &floatCol.KeyName,
+					},
+				},
+				TimeRange: client.ToPtr(900),
+			},
+			Frequency: 900,
+			Threshold: &client.TriggerThreshold{
+				Op:    client.TriggerThresholdOpGreaterThanOrEqual,
+				Value: 10000,
+			},
+			Recipients: []client.NotificationRecipient{
+				{
+					Type:   client.RecipientTypeMarker,
+					Target: "This marker is created by a trigger",
+				},
+			},
+			BaselineDetails: &client.TriggerBaselineDetails{
+				Type:          "percentage",
+				OffsetMinutes: 1440,
+			},
+		}
+		trigger, err = c.Triggers.Create(ctx, dataset, data)
+		require.NoError(t, err)
+		assert.NotNil(t, trigger.ID)
+
+		// copy IDs before asserting equality
+		data.ID = trigger.ID
+		data.QueryID = trigger.QueryID
+		data.AlertType = trigger.AlertType
+		data.Recipients[0].ID = trigger.Recipients[0].ID
+		// set default time range
+		data.Query.TimeRange = client.ToPtr(900)
+
+		// set the default alert type
+		data.AlertType = client.TriggerAlertTypeOnChange
+		// set the default evaluation window type
+		data.EvaluationScheduleType = client.TriggerEvaluationScheduleFrequency
+		// set the default threshold exceeded limit
+		data.Threshold.ExceededLimit = 1
+		data.BaselineDetails = &client.TriggerBaselineDetails{
+			Type:          "percentage",
+			OffsetMinutes: 1440,
+		}
+		assert.Equal(t, data, trigger)
+	})
+
+	t.Run("Get - with baseline details", func(t *testing.T) {
+		trigger.BaselineDetails = &client.TriggerBaselineDetails{
+			Type:          "percentage",
+			OffsetMinutes: 1440,
+		}
+		getTrigger, err := c.Triggers.Get(ctx, dataset, trigger.ID)
+
+		require.NoError(t, err)
+		assert.Equal(t, trigger.BaselineDetails.Type, getTrigger.BaselineDetails.Type)
+		assert.Equal(t, trigger.BaselineDetails.OffsetMinutes, getTrigger.BaselineDetails.OffsetMinutes)
+	})
+
+	t.Run("Update - with baseline_details", func(t *testing.T) {
+		trigger.BaselineDetails = &client.TriggerBaselineDetails{
+			Type:          "percentage",
+			OffsetMinutes: 1440,
+		}
+		result, err := c.Triggers.Update(ctx, dataset, trigger)
+
+		require.NoError(t, err)
+		assert.Equal(t, trigger.BaselineDetails.Type, result.BaselineDetails.Type)
+		assert.Equal(t, trigger.BaselineDetails.OffsetMinutes, result.BaselineDetails.OffsetMinutes)
+	})
+
+}
diff --git a/internal/provider/trigger_resource_test.go b/internal/provider/trigger_resource_test.go
index 5fd6df3..9316ab8 100644
--- a/internal/provider/trigger_resource_test.go
+++ b/internal/provider/trigger_resource_test.go
@@ -152,6 +152,55 @@ func TestAcc_TriggerResource(t *testing.T) {
 			},
 		})
 	})
+
+	t.Run("trigger resource with baseline_details", func(t *testing.T) {
+		resource.Test(t, resource.TestCase{
+			PreCheck:                 testAccPreCheck(t),
+			ProtoV5ProviderFactories: testAccProtoV5MuxServerFactory,
+			Steps: []resource.TestStep{
+				// create a trigger with baseline_details
+				{
+					Config: testAccConfigBasicTriggerWithBaselineDetailsTest(dataset, name, true, false),
+					Check: resource.ComposeAggregateTestCheckFunc(
+						testAccEnsureTriggerExists(t, "honeycombio_trigger.test"),
+						resource.TestCheckResourceAttr("honeycombio_trigger.test", "name", name),
+						resource.TestCheckResourceAttr("honeycombio_trigger.test", "frequency", "1200"),
+						resource.TestCheckResourceAttr("honeycombio_trigger.test", "recipient.#", "2"),
+						resource.TestCheckResourceAttr("honeycombio_trigger.test", "threshold.0.exceeded_limit", "1"),
+						resource.TestCheckResourceAttrPair("honeycombio_trigger.test", "query_id", "honeycombio_query.test", "id"),
+						resource.TestCheckNoResourceAttr("honeycombio_trigger.test", "query_json"),
+						resource.TestCheckResourceAttr("honeycombio_trigger.test", "baseline_details.0.offset_minutes", "1440"),
+					),
+				},
+				// update trigger with no baseline_details
+				{
+					Config: testAccConfigBasicTriggerWithBaselineDetailsTest(dataset, name, false, false),
+					Check: resource.ComposeAggregateTestCheckFunc(
+						testAccEnsureTriggerExists(t, "honeycombio_trigger.test"),
+						resource.TestCheckNoResourceAttr("honeycombio_trigger.test", "baseline_details.#"),
+					),
+				},
+				{
+					ResourceName:        "honeycombio_trigger.test",
+					ImportStateIdPrefix: fmt.Sprintf("%v/", dataset),
+					ImportState:         true,
+				},
+			},
+		})
+	})
+
+	t.Run("trigger with partial baseline_details errors", func(t *testing.T) {
+		resource.Test(t, resource.TestCase{
+			PreCheck:                 testAccPreCheck(t),
+			ProtoV5ProviderFactories: testAccProtoV5MuxServerFactory,
+			Steps: []resource.TestStep{
+				{
+					Config:      testAccConfigBasicTriggerWithBaselineDetailsTest(dataset, name, true, true),
+					ExpectError: regexp.MustCompile(`The argument "offset_minutes" is required, but no definition was found.`),
+				},
+			},
+		})
+	})
 }
 
 // TestAcc_TriggerResourceUpgradeFromVersion014 is intended to test the migration
@@ -950,6 +999,78 @@ resource "honeycombio_trigger" "test" {
 }`, dataset, name, pdseverity, email, pdKey, pdName)
 }
 
+func testAccConfigBasicTriggerWithBaselineDetailsTest(dataset string, name string, includeBaselineDetails bool, includePartialBaselineDetails bool) string {
+	email := test.RandomEmail()
+	pdKey := test.RandomString(32)
+	pdName := test.RandomStringWithPrefix("test.", 20)
+
+	baselineDetails := ""
+	if includeBaselineDetails {
+		baselineDetails =
+			`baseline_details {
+		type           = "value"
+		offset_minutes = 1440
+}`
+	}
+
+	if includePartialBaselineDetails {
+		baselineDetails =
+			`baseline_details {
+	type           = "value"
+}`
+	}
+
+	return fmt.Sprintf(`
+data "honeycombio_query_specification" "test" {
+  calculation {
+    op     = "AVG"
+    column = "duration_ms"
+  }
+  time_range = 1200
+}
+
+resource "honeycombio_query" "test" {
+  dataset    = "%[1]s"
+  query_json = data.honeycombio_query_specification.test.json
+}
+
+resource "honeycombio_pagerduty_recipient" "test" {
+  integration_key  = "%[5]s"
+  integration_name = "%[6]s"
+}
+
+resource "honeycombio_trigger" "test" {
+  name    = "%[2]s"
+  dataset = "%[1]s"
+
+  description = "My nice description"
+
+  query_id = honeycombio_query.test.id
+
+  threshold {
+    op    = ">="
+    value = 100
+  }
+
+	frequency = 1200
+
+  recipient {
+    type   = "email"
+    target = "%[4]s"
+  }
+
+  recipient {
+    id = honeycombio_pagerduty_recipient.test.id
+
+    notification_details {
+      pagerduty_severity = "info"
+    }
+  }
+
+  %[3]s
+}`, dataset, name, baselineDetails, email, pdKey, pdName)
+}
+
 func testAccConfigBasicTriggerTestWithWebhookRecip(dataset, name, varValue string) string {
 	tmplBody := `<<EOT
 		{