Skip to content

Commit 8dd3cc4

Browse files
committed
fix(logs_alert): read destinations from the alert, not their own endpoint
PostHog #84509 dropped the separate destinations list endpoint and returns the same groups on the alert read instead. A GET to the destinations path now answers 405, so refresh and import would fail against the deployed API. The list is a field on the alert, so it is not paginated. That removes the listAllWithStatus helper this branch added for it.
1 parent eb21b41 commit 8dd3cc4

6 files changed

Lines changed: 42 additions & 80 deletions

File tree

docs/resources/logs_alert_destination.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ subcategory: ""
55
description: |-
66
Manage where a log alert https://posthog.com/docs/logs/alerts sends its notifications. A posthog_logs_alert with no destination still evaluates and reports its state, but notifies nobody.
77
~> Every attribute forces replacement. PostHog has no update endpoint for destinations, so changing a channel or a URL destroys the destination and creates a new one. There is a short window during the apply where the alert has no destination and would notify nobody if it fired.
8-
Each resource represents one user-visible destination. PostHog implements that destination as a group of hog functions, one per alert transition (firing, resolved, errored, auto-disabled), sharing the configuration below. The group has no id of its own, so this resource's id is the group's hog_function_ids, sorted and joined by commas. Those hog functions are owned by the alert: posthog_hog_function cannot create, update, or delete them. Terraform reads, creates, and deletes each managed group through the alert's dedicated destinations API.
8+
Each resource represents one user-visible destination. PostHog implements that destination as a group of hog functions, one per alert transition (firing, resolved, errored, auto-disabled), sharing the configuration below. The group has no id of its own, so this resource's id is the group's hog_function_ids, sorted and joined by commas. Those hog functions are owned by the alert: posthog_hog_function cannot create, update, or delete them. Terraform creates and deletes each managed group through the alert's destinations API, and reads it back from the alert itself.
99
---
1010

1111
# posthog_logs_alert_destination (Resource)
@@ -14,7 +14,7 @@ Manage where a [log alert](https://posthog.com/docs/logs/alerts) sends its notif
1414

1515
~> **Every attribute forces replacement.** PostHog has no update endpoint for destinations, so changing a channel or a URL destroys the destination and creates a new one. There is a short window during the apply where the alert has no destination and would notify nobody if it fired.
1616

17-
Each resource represents one user-visible destination. PostHog implements that destination as a group of hog functions, one per alert transition (firing, resolved, errored, auto-disabled), sharing the configuration below. The group has no id of its own, so this resource's `id` is the group's `hog_function_ids`, sorted and joined by commas. Those hog functions are owned by the alert: `posthog_hog_function` cannot create, update, or delete them. Terraform reads, creates, and deletes each managed group through the alert's dedicated destinations API.
17+
Each resource represents one user-visible destination. PostHog implements that destination as a group of hog functions, one per alert transition (firing, resolved, errored, auto-disabled), sharing the configuration below. The group has no id of its own, so this resource's `id` is the group's `hog_function_ids`, sorted and joined by commas. Those hog functions are owned by the alert: `posthog_hog_function` cannot create, update, or delete them. Terraform creates and deletes each managed group through the alert's destinations API, and reads it back from the alert itself.
1818

1919
## Example Usage
2020

internal/httpclient/client.go

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -211,20 +211,13 @@ func doDelete(c *PosthogClient, ctx context.Context, path string) (HTTPStatusCod
211211

212212
// listAll fetches all pages from a paginated endpoint, following Next links until exhausted.
213213
func listAll[T any](c *PosthogClient, ctx context.Context, initialPath string) ([]T, error) {
214-
all, _, err := listAllWithStatus[T](c, ctx, initialPath)
215-
return all, err
216-
}
217-
218-
func listAllWithStatus[T any](c *PosthogClient, ctx context.Context, initialPath string) ([]T, HTTPStatusCode, error) {
219214
var all []T
220-
var status HTTPStatusCode
221215
path := initialPath
222216

223217
for path != "" {
224-
page, pageStatus, err := doGet[PaginatedResponse[T]](c, ctx, path)
225-
status = pageStatus
218+
page, _, err := doGet[PaginatedResponse[T]](c, ctx, path)
226219
if err != nil {
227-
return nil, status, err
220+
return nil, err
228221
}
229222

230223
all = append(all, page.Results...)
@@ -248,5 +241,5 @@ func listAllWithStatus[T any](c *PosthogClient, ctx context.Context, initialPath
248241
}
249242
}
250243

251-
return all, status, nil
244+
return all, nil
252245
}

internal/httpclient/logs_alert_destinations_test.go

Lines changed: 9 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -16,27 +16,21 @@ const (
1616
testLogsAlertProjectID = "123"
1717
testLogsAlertID = "019dbe94-cec8-781b-9470-4a970cd69ebf"
1818
testLogsAlertDestinationsP = "/api/projects/123/logs/alerts/019dbe94-cec8-781b-9470-4a970cd69ebf/destinations/"
19+
testLogsAlertP = "/api/projects/123/logs/alerts/019dbe94-cec8-781b-9470-4a970cd69ebf/"
1920
)
2021

21-
func writeDestinationPage(t *testing.T, w http.ResponseWriter, next any, destinations ...LogsAlertDestination) {
22+
// Destinations are read off the alert itself; PostHog exposes no endpoint that lists
23+
// them on their own.
24+
func writeAlertWithDestinations(t *testing.T, w http.ResponseWriter, destinations ...LogsAlertDestination) {
2225
t.Helper()
23-
writeJSONResponse(t, w, map[string]any{
24-
"count": len(destinations),
25-
"next": next,
26-
"previous": nil,
27-
"results": destinations,
28-
})
29-
}
30-
31-
func absoluteNextPageURL(server *httptest.Server, query string) string {
32-
return server.URL + testLogsAlertDestinationsP + "?" + query
26+
writeJSONResponse(t, w, LogsAlert{ID: testLogsAlertID, Destinations: destinations})
3327
}
3428

3529
func TestListLogsAlertDestinations_PopulatesOnlyTheFieldsOfEachDestinationType(t *testing.T) {
3630
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
3731
assert.Equal(t, http.MethodGet, r.Method)
38-
assert.Equal(t, testLogsAlertDestinationsP, r.URL.Path)
39-
writeDestinationPage(t, w, nil,
32+
assert.Equal(t, testLogsAlertP, r.URL.Path)
33+
writeAlertWithDestinations(t, w,
4034
LogsAlertDestination{HogFunctionIDs: []string{"hf-2", "hf-1"}, Type: "slack", SlackWorkspaceID: util.Int64Ptr(1), SlackChannelID: util.StringPtr("C0123456789")},
4135
LogsAlertDestination{HogFunctionIDs: []string{"hf-3"}, Type: "webhook", RedactedWebhookURL: util.StringPtr("https://example.com/…")},
4236
)
@@ -56,33 +50,6 @@ func TestListLogsAlertDestinations_PopulatesOnlyTheFieldsOfEachDestinationType(t
5650
assert.Nil(t, destinations[1].SlackChannelID)
5751
}
5852

59-
func TestListLogsAlertDestinations_ReturnsDestinationsFromEveryPage(t *testing.T) {
60-
var server *httptest.Server
61-
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
62-
assert.Equal(t, testLogsAlertDestinationsP, r.URL.Path)
63-
64-
if r.URL.Query().Get("offset") == "" {
65-
writeDestinationPage(t, w, absoluteNextPageURL(server, "limit=1&offset=1"),
66-
LogsAlertDestination{HogFunctionIDs: []string{"hf-1"}, Type: "webhook", RedactedWebhookURL: util.StringPtr("https://first.example.com/…")})
67-
return
68-
}
69-
70-
assert.Equal(t, "1", r.URL.Query().Get("offset"))
71-
writeDestinationPage(t, w, nil,
72-
LogsAlertDestination{HogFunctionIDs: []string{"hf-2"}, Type: "teams", RedactedWebhookURL: util.StringPtr("https://second.example.com/…")})
73-
}))
74-
defer server.Close()
75-
76-
client := newTestPosthogClient(server)
77-
destinations, status, err := client.ListLogsAlertDestinations(context.Background(), testLogsAlertProjectID, testLogsAlertID)
78-
79-
require.NoError(t, err)
80-
assert.Equal(t, HTTPStatusCode(http.StatusOK), status)
81-
require.Len(t, destinations, 2)
82-
assert.Equal(t, []string{"hf-1"}, destinations[0].HogFunctionIDs)
83-
assert.Equal(t, []string{"hf-2"}, destinations[1].HogFunctionIDs)
84-
}
85-
8653
func TestCreateLogsAlertDestination_ReturnsOnlyTheNewHogFunctionIDs(t *testing.T) {
8754
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
8855
assert.Equal(t, http.MethodPost, r.Method)
@@ -155,7 +122,8 @@ func TestDeleteLogsAlertDestination_SendsTheWholeGroupInOneCall(t *testing.T) {
155122
}
156123

157124
func TestListLogsAlertDestinations_ReturnsTheNotFoundStatusToTheCaller(t *testing.T) {
158-
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
125+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
126+
assert.Equal(t, testLogsAlertP, r.URL.Path)
159127
w.WriteHeader(http.StatusNotFound)
160128
}))
161129
defer server.Close()

internal/httpclient/logs_alerts.go

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,11 @@ type LogsAlert struct {
1919
ScheduleRestriction *LogsAlertSchedule `json:"schedule_restriction,omitempty"`
2020
SnoozeUntil *string `json:"snooze_until,omitempty"`
2121
State *string `json:"state,omitempty"`
22-
CreatedAt *string `json:"created_at,omitempty"`
23-
UpdatedAt *string `json:"updated_at,omitempty"`
22+
// The alert read carries its destinations. PostHog has no endpoint that lists them
23+
// on their own, so this is how a managed destination is refreshed and imported.
24+
Destinations []LogsAlertDestination `json:"destinations,omitempty"`
25+
CreatedAt *string `json:"created_at,omitempty"`
26+
UpdatedAt *string `json:"updated_at,omitempty"`
2427
}
2528

2629
type LogsAlertRequest struct {
@@ -106,7 +109,11 @@ func logsAlertDestinationsPath(projectID, alertID string) string {
106109
}
107110

108111
func (c *PosthogClient) ListLogsAlertDestinations(ctx context.Context, projectID, alertID string) ([]LogsAlertDestination, HTTPStatusCode, error) {
109-
return listAllWithStatus[LogsAlertDestination](c, ctx, logsAlertDestinationsPath(projectID, alertID))
112+
alert, status, err := c.GetLogsAlert(ctx, projectID, alertID)
113+
if err != nil {
114+
return nil, status, err
115+
}
116+
return alert.Destinations, status, nil
110117
}
111118

112119
func (c *PosthogClient) CreateLogsAlertDestination(ctx context.Context, projectID, alertID string, input LogsAlertDestinationRequest) (LogsAlertDestination, error) {

internal/resource/logs_alert_destination.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,8 +81,8 @@ func (o LogsAlertDestinationOps) Schema() schema.Schema {
8181
"(firing, resolved, errored, auto-disabled), sharing the configuration below. The group has no id " +
8282
"of its own, so this resource's `id` is the group's `hog_function_ids`, sorted and joined by " +
8383
"commas. Those hog functions are owned by the alert: `posthog_hog_function` cannot create, update, or " +
84-
"delete them. Terraform reads, creates, and deletes each managed group through the alert's " +
85-
"dedicated destinations API.",
84+
"delete them. Terraform creates and deletes each managed group through the alert's " +
85+
"destinations API, and reads it back from the alert itself.",
8686
Attributes: map[string]schema.Attribute{
8787
"id": schema.StringAttribute{
8888
Computed: true,

internal/resource/logs_alert_destination_test.go

Lines changed: 16 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -342,28 +342,22 @@ func destinationInState(id string) LogsAlertDestinationTFModel {
342342
}
343343
}
344344

345-
func writeDestinationPage(t *testing.T, w http.ResponseWriter, next any, destinations ...httpclient.LogsAlertDestination) {
345+
// Destinations are read off the alert itself; PostHog exposes no endpoint that lists
346+
// them on their own.
347+
func writeAlertWithDestinations(t *testing.T, w http.ResponseWriter, destinations ...httpclient.LogsAlertDestination) {
346348
t.Helper()
347349
w.Header().Set("Content-Type", "application/json")
348-
require.NoError(t, json.NewEncoder(w).Encode(map[string]any{
349-
"count": len(destinations),
350-
"next": next,
351-
"previous": nil,
352-
"results": destinations,
350+
require.NoError(t, json.NewEncoder(w).Encode(httpclient.LogsAlert{
351+
ID: testLogsAlertDestinationAlertID,
352+
Destinations: destinations,
353353
}))
354354
}
355355

356-
func TestLogsAlertDestinationRead_FindsADestinationOnALaterPage(t *testing.T) {
357-
var server *httptest.Server
358-
server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
359-
require.Equal(t, logsAlertDestinationsPath(), r.URL.Path)
360-
361-
if r.URL.Query().Get("offset") == "" {
362-
writeDestinationPage(t, w, server.URL+logsAlertDestinationsPath()+"?limit=1&offset=1",
363-
httpclient.LogsAlertDestination{HogFunctionIDs: []string{"hf-1"}, Type: destinationTypeWebhook})
364-
return
365-
}
366-
writeDestinationPage(t, w, nil,
356+
func TestLogsAlertDestinationRead_FindsTheGroupAmongTheAlertsDestinations(t *testing.T) {
357+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
358+
require.Equal(t, logsAlertPath(), r.URL.Path)
359+
writeAlertWithDestinations(t, w,
360+
httpclient.LogsAlertDestination{HogFunctionIDs: []string{"hf-1"}, Type: destinationTypeWebhook},
367361
httpclient.LogsAlertDestination{HogFunctionIDs: []string{"hf-2", "hf-3"}, Type: destinationTypeTeams})
368362
}))
369363
defer server.Close()
@@ -379,8 +373,8 @@ func TestLogsAlertDestinationRead_FindsADestinationOnALaterPage(t *testing.T) {
379373

380374
func TestLogsAlertDestinationRead_ReportsADestinationMissingFromALiveAlertAsDeleted(t *testing.T) {
381375
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
382-
require.Equal(t, logsAlertDestinationsPath(), r.URL.Path)
383-
writeDestinationPage(t, w, nil,
376+
require.Equal(t, logsAlertPath(), r.URL.Path)
377+
writeAlertWithDestinations(t, w,
384378
httpclient.LogsAlertDestination{HogFunctionIDs: []string{"hf-9"}, Type: destinationTypeWebhook})
385379
}))
386380
defer server.Close()
@@ -395,8 +389,8 @@ func TestLogsAlertDestinationRead_ReportsADestinationMissingFromALiveAlertAsDele
395389

396390
func TestLogsAlertDestinationRead_RefusesAGroupSplitAcrossConfigurations(t *testing.T) {
397391
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
398-
require.Equal(t, logsAlertDestinationsPath(), r.URL.Path)
399-
writeDestinationPage(t, w, nil,
392+
require.Equal(t, logsAlertPath(), r.URL.Path)
393+
writeAlertWithDestinations(t, w,
400394
httpclient.LogsAlertDestination{HogFunctionIDs: []string{"hf-1"}, Type: destinationTypeWebhook},
401395
httpclient.LogsAlertDestination{HogFunctionIDs: []string{"hf-2"}, Type: destinationTypeWebhook})
402396
}))
@@ -411,7 +405,7 @@ func TestLogsAlertDestinationRead_RefusesAGroupSplitAcrossConfigurations(t *test
411405

412406
func TestLogsAlertDestinationRead_RefusesAGroupWidenedWithExtraIDs(t *testing.T) {
413407
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
414-
writeDestinationPage(t, w, nil,
408+
writeAlertWithDestinations(t, w,
415409
httpclient.LogsAlertDestination{HogFunctionIDs: []string{"hf-1", "hf-2"}, Type: destinationTypeWebhook})
416410
}))
417411
defer server.Close()

0 commit comments

Comments
 (0)