From b81822810d9da15ce3ef8cd927d98e634fc6fa70 Mon Sep 17 00:00:00 2001 From: Dragica Draskic Date: Tue, 17 Feb 2026 18:41:20 +0100 Subject: [PATCH 01/24] feat: Add Honeycomb integration with trigger and action Signed-off-by: Dragica Draskic --- docs/components/Honeycomb.mdx | 118 ++++++++++ pkg/integrations/honeycomb/client.go | 125 +++++++++++ pkg/integrations/honeycomb/create_event.go | 161 ++++++++++++++ .../honeycomb/create_event_test.go | 121 +++++++++++ .../example_data_on_alert_fired.json | 34 +++ .../example_output_create_event.json | 13 ++ pkg/integrations/honeycomb/examples.go | 38 ++++ pkg/integrations/honeycomb/honeycomb.go | 118 ++++++++++ pkg/integrations/honeycomb/honeycomb_test.go | 110 ++++++++++ pkg/integrations/honeycomb/on_alert_fired.go | 201 ++++++++++++++++++ .../honeycomb/on_alert_fired_test.go | 128 +++++++++++ pkg/integrations/honeycomb/webhook_handler.go | 23 ++ pkg/server/server.go | 1 + .../assets/icons/integrations/honeycomb.svg | 1 + .../mappers/honeycomb/create_event.ts | 121 +++++++++++ .../mappers/honeycomb/custom_fields.tsx | 89 ++++++++ .../workflowv2/mappers/honeycomb/index.ts | 25 +++ .../mappers/honeycomb/on_alert_fired.ts | 74 +++++++ web_src/src/pages/workflowv2/mappers/index.ts | 16 ++ 19 files changed, 1517 insertions(+) create mode 100644 docs/components/Honeycomb.mdx create mode 100644 pkg/integrations/honeycomb/client.go create mode 100644 pkg/integrations/honeycomb/create_event.go create mode 100644 pkg/integrations/honeycomb/create_event_test.go create mode 100644 pkg/integrations/honeycomb/example_data_on_alert_fired.json create mode 100644 pkg/integrations/honeycomb/example_output_create_event.json create mode 100644 pkg/integrations/honeycomb/examples.go create mode 100644 pkg/integrations/honeycomb/honeycomb.go create mode 100644 pkg/integrations/honeycomb/honeycomb_test.go create mode 100644 pkg/integrations/honeycomb/on_alert_fired.go create mode 100644 pkg/integrations/honeycomb/on_alert_fired_test.go create mode 100644 pkg/integrations/honeycomb/webhook_handler.go create mode 100644 web_src/src/assets/icons/integrations/honeycomb.svg create mode 100644 web_src/src/pages/workflowv2/mappers/honeycomb/create_event.ts create mode 100644 web_src/src/pages/workflowv2/mappers/honeycomb/custom_fields.tsx create mode 100644 web_src/src/pages/workflowv2/mappers/honeycomb/index.ts create mode 100644 web_src/src/pages/workflowv2/mappers/honeycomb/on_alert_fired.ts diff --git a/docs/components/Honeycomb.mdx b/docs/components/Honeycomb.mdx new file mode 100644 index 0000000000..b05ead93e9 --- /dev/null +++ b/docs/components/Honeycomb.mdx @@ -0,0 +1,118 @@ +--- +title: "Honeycomb" +--- + +Triggers and actions for Honeycomb + +## Triggers + + + + + +import { CardGrid, LinkCard } from "@astrojs/starlight/components"; + +## Actions + + + + + +## Instructions + +Connect Honeycomb to Superplane using a Honeycomb API key. + +**Get your API key**: +Honeycomb → Account → Team Settings → API Keys → copy key → paste here. + +**Trigger setup**: +After saving a Honeycomb trigger node, Superplane generates a Webhook URL and Shared Secret. +Add them in Honeycomb → Team Settings → Integrations → Webhooks. + +Once configured, Honeycomb events will trigger your workflow automatically. + + + +## On Alert Fired + +The On Alert Fired trigger starts a workflow execution when Honeycomb sends an alert webhook. + +Setup: +1) Add this trigger to a workflow +2) Optionally set Alert Name (used to filter which node fires) +3) SAVE the node +4) Copy Webhook URL and Shared Secret into Honeycomb webhook integration + +### Example Data + +```json +{ + "dataset": { + "name": "Production API", + "slug": "api-production" + }, + "description": "API error rate has exceeded the acceptable threshold", + "id": "01HQXYZ123ABC", + "name": "High Error Rate - Production API", + "query": { + "query_url": "https://ui.honeycomb.io/myteam/datasets/api-production/query/xyz789", + "time_range": 900 + }, + "result_groups": [ + { + "group": { + "endpoint": "/api/users" + }, + "result_value": 12.3 + }, + { + "group": { + "endpoint": "/api/orders" + }, + "result_value": 6.8 + } + ], + "result_value": 8.5, + "severity": "critical", + "status": "firing", + "summary": "Error rate is 8.5% (threshold: 5%)", + "threshold": { + "op": "above", + "value": 5 + }, + "trigger_url": "https://ui.honeycomb.io/myteam/triggers/abc123", + "triggered_at": "2026-02-15T10:30:00Z", + "version": "v1" +} +``` + + + +## Create Event + +Sends a custom event to Honeycomb using the Events API. + +The component sends a single event as a JSON object where each key becomes a Honeycomb field. + +Notes: +- The request body is the JSON object you provide in "Fields". +- If you do not include a "time" field, the current time is automatically set via request header. + +### Example Output + +```json +{ + "dataset": "deployments", + "fields": { + "deployed_by": "github-actions", + "duration_seconds": 42, + "environment": "production", + "event_type": "deployment", + "service": "billing-api", + "success": true, + "version": "2.4.1" + }, + "status": "sent" +} +``` + diff --git a/pkg/integrations/honeycomb/client.go b/pkg/integrations/honeycomb/client.go new file mode 100644 index 0000000000..8a6bf2e2aa --- /dev/null +++ b/pkg/integrations/honeycomb/client.go @@ -0,0 +1,125 @@ +package honeycomb + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/superplanehq/superplane/pkg/core" +) + +const ( + BaseURLUS = "https://api.honeycomb.io" + BaseURLEU = "https://api.eu1.honeycomb.io" +) + +type Client struct { + APIKey string + BaseURL string + http core.HTTPContext +} + +func NewClient(httpCtx core.HTTPContext, ctx core.IntegrationContext) (*Client, error) { + apiKeyAny, err := ctx.GetConfig("apiKey") + if err != nil { + return nil, fmt.Errorf("api key is required") + } + apiKey := strings.TrimSpace(string(apiKeyAny)) + if apiKey == "" { + return nil, fmt.Errorf("api key is required") + } + + siteAny, err := ctx.GetConfig("site") + if err != nil { + siteAny = []byte("api.honeycomb.io") + } + site := strings.TrimSpace(string(siteAny)) + if site == "" { + site = strings.TrimPrefix(BaseURLUS, "https://") + } + + baseURL := site + if !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") { + baseURL = "https://" + baseURL + } + + return &Client{ + APIKey: apiKey, + BaseURL: baseURL, + http: httpCtx, + }, nil + +} + +func (c *Client) Validate() error { + req, err := http.NewRequest(http.MethodGet, c.BaseURL+"/1/auth", nil) + if err != nil { + return err + } + + req.Header.Set("X-Honeycomb-Team", c.APIKey) + + resp, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("failed to validate api key: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusOK { + return nil + } + + if resp.StatusCode == http.StatusUnauthorized { + return fmt.Errorf("invalid api key") + } + + if resp.StatusCode == http.StatusForbidden { + return fmt.Errorf("api key is valid, but does not have permission for this account/team") + } + + return fmt.Errorf("honeycomb authentication failed (http %d)", resp.StatusCode) +} + +func (c *Client) CreateEvent(datasetSlug string, fields map[string]any) error { + datasetSlug = strings.TrimSpace(datasetSlug) + if datasetSlug == "" { + return fmt.Errorf("dataset is required") + } + + u, _ := url.Parse(c.BaseURL) + u.Path = fmt.Sprintf("/1/events/%s", url.PathEscape(datasetSlug)) + + body, err := json.Marshal(fields) + if err != nil { + return fmt.Errorf("failed to marshal fields: %w", err) + } + + req, err := http.NewRequest(http.MethodPost, u.String(), bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("X-Honeycomb-Team", c.APIKey) + req.Header.Set("Content-Type", "application/json") + + // Always set event timestamp header (Honeycomb uses the header for event time). + req.Header.Set("X-Honeycomb-Event-Time", fmt.Sprintf("%d", time.Now().Unix())) + + resp, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return nil + } + + b, _ := io.ReadAll(resp.Body) + return fmt.Errorf("honeycomb create event failed (status %d): %s", resp.StatusCode, string(b)) +} diff --git a/pkg/integrations/honeycomb/create_event.go b/pkg/integrations/honeycomb/create_event.go new file mode 100644 index 0000000000..34fbedc2d5 --- /dev/null +++ b/pkg/integrations/honeycomb/create_event.go @@ -0,0 +1,161 @@ +package honeycomb + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + + "github.com/google/uuid" + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" +) + +type CreateEvent struct{} + +type CreateEventConfiguration struct { + Dataset string `json:"dataset" mapstructure:"dataset"` + FieldsJSON string `json:"fields" mapstructure:"fields"` +} + +func (c *CreateEvent) Name() string { + return "honeycomb.createEvent" +} + +func (c *CreateEvent) Label() string { + return "Create Event" +} + +func (c *CreateEvent) Description() string { + return "Send an event to Honeycomb" +} + +func (c *CreateEvent) Documentation() string { + return `Sends a custom event to Honeycomb using the Events API. + +The component sends a single event as a JSON object where each key becomes a Honeycomb field. + +Notes: +- The request body is the JSON object you provide in "Fields". +- If you do not include a "time" field, the current time is automatically set via request header.` +} + +func (c *CreateEvent) Icon() string { + return "honeycomb" +} + +func (c *CreateEvent) Color() string { + return "gray" +} + +func (c *CreateEvent) OutputChannels(configuration any) []core.OutputChannel { + return []core.OutputChannel{core.DefaultOutputChannel} +} + +func (c *CreateEvent) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "dataset", + Label: "Dataset", + Type: configuration.FieldTypeString, + Required: true, + Description: "Honeycomb dataset slug.", + }, + { + Name: "fields", + Label: "Fields (JSON)", + Type: configuration.FieldTypeText, + Required: true, + Description: `JSON object of fields to send, e.g. {"message":"hello","severity":"info"}`, + }, + } +} + +func (c *CreateEvent) Setup(ctx core.SetupContext) error { + var cfg CreateEventConfiguration + if err := mapstructure.Decode(ctx.Configuration, &cfg); err != nil { + return fmt.Errorf("failed to decode configuration: %w", err) + } + + if cfg.Dataset == "" { + return errors.New("dataset is required") + } + if cfg.FieldsJSON == "" { + return errors.New("fields is required") + } + + var tmp map[string]any + if err := json.Unmarshal([]byte(cfg.FieldsJSON), &tmp); err != nil { + return fmt.Errorf("invalid fields json: %w", err) + } + + return nil +} + +func (c *CreateEvent) ProcessQueueItem(ctx core.ProcessQueueContext) (*uuid.UUID, error) { + return ctx.DefaultProcessing() +} + +func (c *CreateEvent) Execute(ctx core.ExecutionContext) error { + var cfg CreateEventConfiguration + if err := mapstructure.Decode(ctx.Configuration, &cfg); err != nil { + return fmt.Errorf("failed to decode configuration: %w", err) + } + if cfg.Dataset == "" { + return errors.New("dataset is required") + } + if cfg.FieldsJSON == "" { + return errors.New("fields is required") + } + + var fields map[string]any + if err := json.Unmarshal([]byte(cfg.FieldsJSON), &fields); err != nil { + return fmt.Errorf("invalid fields json: %w", err) + } + + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return fmt.Errorf("failed to create honeycomb client: %w", err) + } + + if err := client.CreateEvent(cfg.Dataset, fields); err != nil { + return err + } + + // Emit payload aligned with UI expectations + examples. + payload := map[string]any{ + "status": "sent", + "dataset": cfg.Dataset, + "fields": fields, + } + + return ctx.ExecutionState.Emit( + core.DefaultOutputChannel.Name, + "honeycomb.event.created", + []any{payload}, + ) +} + +func (c *CreateEvent) HandleWebhook(ctx core.WebhookRequestContext) (int, error) { + return http.StatusOK, nil +} +func (c *CreateEvent) Actions() []core.Action { + return []core.Action{} +} + +func (c *CreateEvent) HandleAction(ctx core.ActionContext) error { + return nil +} + +func (c *CreateEvent) Cancel(ctx core.ExecutionContext) error { + return nil +} + +func (c *CreateEvent) Cleanup(ctx core.SetupContext) error { + return nil +} + +func (c *CreateEvent) ExampleOutput() map[string]any { + return embeddedExampleOutputCreateEvent() +} diff --git a/pkg/integrations/honeycomb/create_event_test.go b/pkg/integrations/honeycomb/create_event_test.go new file mode 100644 index 0000000000..b6ce85cbd4 --- /dev/null +++ b/pkg/integrations/honeycomb/create_event_test.go @@ -0,0 +1,121 @@ +package honeycomb + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/superplanehq/superplane/pkg/core" + "github.com/superplanehq/superplane/test/support/contexts" +) + +func Test__CreateEvent__Setup(t *testing.T) { + component := &CreateEvent{} + + t.Run("missing dataset -> error", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{ + "dataset": "", + "fields": `{"key":"value"}`, + }, + }) + require.ErrorContains(t, err, "dataset is required") + }) + + t.Run("invalid JSON fields -> error", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{ + "dataset": "test-dataset", + "fields": `{invalid json}`, + }, + }) + require.ErrorContains(t, err, "invalid fields json") + }) + + t.Run("valid configuration -> success", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{ + "dataset": "test-dataset", + "fields": `{"message":"hello","severity":"info"}`, + }, + }) + require.NoError(t, err) + }) +} + +func Test__CreateEvent__Execute(t *testing.T) { + component := &CreateEvent{} + + t.Run("missing API key -> error", func(t *testing.T) { + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{}, + } + + err := component.Execute(core.ExecutionContext{ + Integration: integrationCtx, + Configuration: map[string]any{ + "dataset": "test-dataset", + "fields": `{"key":"value"}`, + }, + HTTP: &contexts.HTTPContext{}, + }) + + require.ErrorContains(t, err, "api key is required") + }) + + t.Run("successful event creation -> emits payload", func(t *testing.T) { + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{}`)), + }, + }, + } + + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "apiKey": "test-api-key", + "site": "api.honeycomb.io", + }, + } + + execState := &contexts.ExecutionStateContext{KVs: map[string]string{}} + + err := component.Execute(core.ExecutionContext{ + Integration: integrationCtx, + ExecutionState: execState, + HTTP: httpCtx, + Configuration: map[string]any{ + "dataset": "test-dataset", + "fields": `{"message":"deployment","version":"1.2.3"}`, + }, + }) + + require.NoError(t, err) + assert.Equal(t, core.DefaultOutputChannel.Name, execState.Channel) + assert.Equal(t, "honeycomb.event.created", execState.Type) + + require.Len(t, httpCtx.Requests, 1) + req := httpCtx.Requests[0] + assert.Equal(t, http.MethodPost, req.Method) + assert.Contains(t, req.URL.String(), "https://api.honeycomb.io/1/events/test-dataset") + assert.Equal(t, "test-api-key", req.Header.Get("X-Honeycomb-Team")) + assert.Equal(t, "application/json", req.Header.Get("Content-Type")) + + bodyBytes, _ := io.ReadAll(req.Body) + bodyStr := strings.TrimSpace(string(bodyBytes)) + + // tvoj client šalje JSON object (fields) + assert.True(t, strings.HasPrefix(bodyStr, "{"), "payload should be a JSON object") + assert.Contains(t, bodyStr, `"message":"deployment"`) + assert.Contains(t, bodyStr, `"version":"1.2.3"`) + + // vreme ide kroz header (auto) + assert.NotEmpty(t, req.Header.Get("X-Honeycomb-Event-Time")) + + }) +} diff --git a/pkg/integrations/honeycomb/example_data_on_alert_fired.json b/pkg/integrations/honeycomb/example_data_on_alert_fired.json new file mode 100644 index 0000000000..0c5ea391dd --- /dev/null +++ b/pkg/integrations/honeycomb/example_data_on_alert_fired.json @@ -0,0 +1,34 @@ +{ + "version": "v1", + "id": "01HQXYZ123ABC", + "name": "High Error Rate - Production API", + "status": "firing", + "summary": "Error rate is 8.5% (threshold: 5%)", + "description": "API error rate has exceeded the acceptable threshold", + "trigger_url": "https://ui.honeycomb.io/myteam/triggers/abc123", + "dataset": { + "slug": "api-production", + "name": "Production API" + }, + "query": { + "query_url": "https://ui.honeycomb.io/myteam/datasets/api-production/query/xyz789", + "time_range": 900 + }, + "threshold": { + "op": "above", + "value": 5 + }, + "result_value": 8.5, + "result_groups": [ + { + "group": {"endpoint": "/api/users"}, + "result_value": 12.3 + }, + { + "group": {"endpoint": "/api/orders"}, + "result_value": 6.8 + } + ], + "triggered_at": "2026-02-15T10:30:00Z", + "severity": "critical" +} \ No newline at end of file diff --git a/pkg/integrations/honeycomb/example_output_create_event.json b/pkg/integrations/honeycomb/example_output_create_event.json new file mode 100644 index 0000000000..53c943e2b8 --- /dev/null +++ b/pkg/integrations/honeycomb/example_output_create_event.json @@ -0,0 +1,13 @@ +{ + "status": "sent", + "dataset": "deployments", + "fields": { + "event_type": "deployment", + "service": "billing-api", + "environment": "production", + "version": "2.4.1", + "deployed_by": "github-actions", + "duration_seconds": 42, + "success": true + } +} diff --git a/pkg/integrations/honeycomb/examples.go b/pkg/integrations/honeycomb/examples.go new file mode 100644 index 0000000000..b910984270 --- /dev/null +++ b/pkg/integrations/honeycomb/examples.go @@ -0,0 +1,38 @@ +package honeycomb + +import ( + _ "embed" + "sync" + + "github.com/superplanehq/superplane/pkg/utils" +) + +//go:embed example_data_on_alert_fired.json +var exampleDataOnAlertFiredBytes []byte + +//go:embed example_output_create_event.json +var exampleOutputCreateEventBytes []byte + +var ( + exampleDataOnAlertFiredOnce sync.Once + exampleDataOnAlertFired map[string]any + + exampleOutputCreateEventOnce sync.Once + exampleOutputCreateEvent map[string]any +) + +func embeddedExampleDataOnAlertFired() map[string]any { + return utils.UnmarshalEmbeddedJSON( + &exampleDataOnAlertFiredOnce, + exampleDataOnAlertFiredBytes, + &exampleDataOnAlertFired, + ) +} + +func embeddedExampleOutputCreateEvent() map[string]any { + return utils.UnmarshalEmbeddedJSON( + &exampleOutputCreateEventOnce, + exampleOutputCreateEventBytes, + &exampleOutputCreateEvent, + ) +} diff --git a/pkg/integrations/honeycomb/honeycomb.go b/pkg/integrations/honeycomb/honeycomb.go new file mode 100644 index 0000000000..1c4e0705e9 --- /dev/null +++ b/pkg/integrations/honeycomb/honeycomb.go @@ -0,0 +1,118 @@ +package honeycomb + +import ( + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" + "github.com/superplanehq/superplane/pkg/registry" +) + +func init() { + registry.RegisterIntegrationWithWebhookHandler("honeycomb", &Honeycomb{}, &HoneycombWebhookHandler{}) +} + +type Honeycomb struct{} + +func (h *Honeycomb) Name() string { + return "honeycomb" +} + +func (h *Honeycomb) Label() string { + return "Honeycomb" +} + +func (h *Honeycomb) Icon() string { + return "honeycomb" +} + +func (h *Honeycomb) Description() string { + return "Triggers and actions for Honeycomb" +} + +func (h *Honeycomb) Instructions() string { + return ` +Connect Honeycomb to Superplane using a Honeycomb API key. + +**Get your API key**: +Honeycomb → Account → Team Settings → API Keys → copy key → paste here. + +**Trigger setup**: +After saving a Honeycomb trigger node, Superplane generates a Webhook URL and Shared Secret. +Add them in Honeycomb → Team Settings → Integrations → Webhooks. + +Once configured, Honeycomb events will trigger your workflow automatically. +` +} + +func (h *Honeycomb) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "site", + Label: "Honeycomb Site", + Type: configuration.FieldTypeSelect, + Required: true, + Default: "api.honeycomb.io", + TypeOptions: &configuration.TypeOptions{ + Select: &configuration.SelectTypeOptions{ + Options: []configuration.FieldOption{ + {Label: "US (api.honeycomb.io)", Value: "api.honeycomb.io"}, + {Label: "EU (api.eu1.honeycomb.io)", Value: "api.eu1.honeycomb.io"}, + }, + }, + }, + Description: "Select the Honeycomb API host for your account region.", + }, + { + Name: "apiKey", + Label: "API Key", + Type: configuration.FieldTypeString, + Description: "Honeycomb API key used for actions (e.g. Create Event).", + Sensitive: true, + Required: true, + }, + } +} + +func (h *Honeycomb) Components() []core.Component { + return []core.Component{ + &CreateEvent{}, + } +} + +func (h *Honeycomb) Triggers() []core.Trigger { + return []core.Trigger{ + &OnAlertFired{}, + } +} + +func (h *Honeycomb) Sync(ctx core.SyncContext) error { + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return err + } + if err := client.Validate(); err != nil { + return err + } + ctx.Integration.Ready() + return nil +} + +func (h *Honeycomb) Cleanup(ctx core.IntegrationCleanupContext) error { + return nil +} + +func (h *Honeycomb) Actions() []core.Action { + return []core.Action{} +} + +func (h *Honeycomb) HandleAction(ctx core.IntegrationActionContext) error { + return nil +} + +func (h *Honeycomb) ListResources(resourceType string, ctx core.ListResourcesContext) ([]core.IntegrationResource, error) { + return []core.IntegrationResource{}, nil +} + +func (h *Honeycomb) HandleRequest(ctx core.HTTPRequestContext) { + ctx.Response.WriteHeader(404) + _, _ = ctx.Response.Write([]byte("not found")) +} diff --git a/pkg/integrations/honeycomb/honeycomb_test.go b/pkg/integrations/honeycomb/honeycomb_test.go new file mode 100644 index 0000000000..b61a326cd3 --- /dev/null +++ b/pkg/integrations/honeycomb/honeycomb_test.go @@ -0,0 +1,110 @@ +package honeycomb + +import ( + "bytes" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "github.com/superplanehq/superplane/pkg/core" + contexts "github.com/superplanehq/superplane/test/support/contexts" +) + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +func withDefaultTransport(t *testing.T, rt roundTripFunc) { + t.Helper() + original := http.DefaultTransport + http.DefaultTransport = rt + t.Cleanup(func() { + http.DefaultTransport = original + }) +} + +func jsonResponse(status int, body string) *http.Response { + return &http.Response{ + StatusCode: status, + Body: io.NopCloser(bytes.NewBufferString(body)), + Header: http.Header{"Content-Type": []string{"application/json"}}, + } +} + +func Test__Honeycomb__Sync(t *testing.T) { + h := &Honeycomb{} + + t.Run("missing api key -> error", func(t *testing.T) { + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "site": BaseURLUS, + }, + } + + err := h.Sync(core.SyncContext{ + Integration: integrationCtx, + HTTP: &contexts.HTTPContext{}, + }) + + require.Error(t, err) + }) + + t.Run("valid key -> ready", func(t *testing.T) { + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"ok":true}`)), + }, + }, + } + + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "site": BaseURLUS, + "apiKey": "test-api-key", + }, + } + + err := h.Sync(core.SyncContext{ + Integration: integrationCtx, + HTTP: httpCtx, + }) + + require.NoError(t, err) + require.Equal(t, "ready", integrationCtx.State) + require.Len(t, httpCtx.Requests, 1) + require.Contains(t, httpCtx.Requests[0].URL.String(), BaseURLUS+"/1/auth") + }) + + t.Run("invalid key -> error", func(t *testing.T) { + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusUnauthorized, + Body: io.NopCloser(strings.NewReader(`{"error":"Unknown API key"}`)), + }, + }, + } + + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "site": BaseURLUS, + "apiKey": "bad-key", + }, + } + + err := h.Sync(core.SyncContext{ + Integration: integrationCtx, + HTTP: httpCtx, + }) + + require.Error(t, err) + require.Contains(t, err.Error(), "invalid api key") + require.NotEqual(t, "ready", integrationCtx.State) + }) +} diff --git a/pkg/integrations/honeycomb/on_alert_fired.go b/pkg/integrations/honeycomb/on_alert_fired.go new file mode 100644 index 0000000000..b647315714 --- /dev/null +++ b/pkg/integrations/honeycomb/on_alert_fired.go @@ -0,0 +1,201 @@ +package honeycomb + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/mitchellh/mapstructure" + "github.com/superplanehq/superplane/pkg/configuration" + "github.com/superplanehq/superplane/pkg/core" +) + +type OnAlertFired struct{} + +type OnAlertFiredConfiguration struct { + AlertName string `json:"alertName" mapstructure:"alertName"` +} + +type OnAlertFiredMetadata struct { + WebhookURL string `json:"webhookUrl" mapstructure:"webhookUrl"` + SharedSecret string `json:"sharedSecret" mapstructure:"sharedSecret"` +} + +func (t *OnAlertFired) Name() string { + return "honeycomb.onAlertFired" +} + +func (t *OnAlertFired) Label() string { + return "On Alert Fired" +} + +func (t *OnAlertFired) Description() string { + return "Triggers when Honeycomb sends an alert webhook" +} + +func (t *OnAlertFired) Icon() string { + return "honeycomb" +} + +func (t *OnAlertFired) Color() string { + return "yellow" +} + +func (t *OnAlertFired) Documentation() string { + return ` +The On Alert Fired trigger starts a workflow execution when Honeycomb sends an alert webhook. + +Setup: +1) Add this trigger to a workflow +2) Optionally set Alert Name (used to filter which node fires) +3) SAVE the node +4) Copy Webhook URL and Shared Secret into Honeycomb webhook integration +` +} + +func (t *OnAlertFired) Configuration() []configuration.Field { + return []configuration.Field{ + { + Name: "alertName", + Label: "Alert Name (optional)", + Type: configuration.FieldTypeString, + Required: false, + Description: "If set, only webhooks whose payload name matches will trigger this node.", + }, + } +} + +func (t *OnAlertFired) Setup(ctx core.TriggerContext) error { + + if ctx.Webhook == nil { + if ctx.Integration == nil { + return nil + } + if err := ctx.Integration.RequestWebhook(map[string]any{}); err != nil { + return fmt.Errorf("failed to request webhook: %w", err) + } + return nil + } + + if ctx.Integration == nil { + return nil + } + + secret, err := ctx.Webhook.GetSecret() + if err != nil { + + if err := ctx.Integration.RequestWebhook(map[string]any{}); err != nil { + return fmt.Errorf("failed to request webhook: %w", err) + } + + secret, err = ctx.Webhook.GetSecret() + if err != nil { + return fmt.Errorf("failed to get webhook secret after request: %w", err) + } + } + + url, err := ctx.Webhook.Setup() + if err != nil { + return fmt.Errorf("failed to setup webhook url: %w", err) + } + + md := OnAlertFiredMetadata{ + WebhookURL: strings.TrimSpace(url), + SharedSecret: strings.TrimSpace(string(secret)), + } + + if err := ctx.Metadata.Set(md); err != nil { + return fmt.Errorf("failed to set node metadata: %w", err) + } + + return nil +} + +func (t *OnAlertFired) Actions() []core.Action { + return []core.Action{} +} + +func (t *OnAlertFired) HandleAction(ctx core.TriggerActionContext) (map[string]any, error) { + return nil, nil +} + +func (t *OnAlertFired) Cleanup(ctx core.TriggerContext) error { + return nil +} + +func (t *OnAlertFired) HandleWebhook(ctx core.WebhookRequestContext) (int, error) { + + cfg := OnAlertFiredConfiguration{} + if err := mapstructure.Decode(ctx.Configuration, &cfg); err != nil { + return http.StatusInternalServerError, fmt.Errorf("failed to decode configuration: %w", err) + } + + secretBytes, err := ctx.Webhook.GetSecret() + if err != nil { + return http.StatusInternalServerError, fmt.Errorf("failed to get webhook secret: %w", err) + } + secret := strings.TrimSpace(string(secretBytes)) + if secret == "" { + return http.StatusInternalServerError, fmt.Errorf("webhook secret is empty") + } + + provided := strings.TrimSpace(ctx.Headers.Get("X-Honeycomb-Webhook-Token")) + + // Fallbacks + if provided == "" { + provided = strings.TrimSpace(ctx.Headers.Get("Authorization")) + } + + if provided == "" { + provided = strings.TrimSpace(ctx.Headers.Get("X-Shared-Secret")) + } + + if provided == "" { + return http.StatusUnauthorized, fmt.Errorf("missing webhook token") + } + + if strings.HasPrefix(strings.ToLower(provided), "bearer ") { + provided = strings.TrimSpace(provided[len("bearer "):]) + } + + if provided != secret { + return http.StatusForbidden, fmt.Errorf("invalid webhook token") + } + + var payload map[string]any + if err := json.Unmarshal(ctx.Body, &payload); err != nil { + payload = map[string]any{"raw": string(ctx.Body)} + } + + want := strings.TrimSpace(cfg.AlertName) + if want != "" && !payloadHasAlertName(payload, want) { + return http.StatusOK, nil + } + + if err := ctx.Events.Emit("honeycomb.alert.fired", payload); err != nil { + return http.StatusInternalServerError, fmt.Errorf("emit failed: %w", err) + } + + return http.StatusOK, nil +} + +func payloadHasAlertName(payload map[string]any, want string) bool { + want = strings.TrimSpace(want) + + if name, ok := payload["name"].(string); ok { + return strings.EqualFold(strings.TrimSpace(name), want) + } + + if alert, ok := payload["alert"].(map[string]any); ok { + if name, ok := alert["name"].(string); ok { + return strings.EqualFold(strings.TrimSpace(name), want) + } + } + + return false +} + +func (t *OnAlertFired) ExampleData() map[string]any { + return embeddedExampleDataOnAlertFired() +} diff --git a/pkg/integrations/honeycomb/on_alert_fired_test.go b/pkg/integrations/honeycomb/on_alert_fired_test.go new file mode 100644 index 0000000000..9cdcafc521 --- /dev/null +++ b/pkg/integrations/honeycomb/on_alert_fired_test.go @@ -0,0 +1,128 @@ +package honeycomb + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/require" + "github.com/superplanehq/superplane/pkg/core" + contexts "github.com/superplanehq/superplane/test/support/contexts" +) + +func Test__OnAlertFired__Setup(t *testing.T) { + trigger := OnAlertFired{} + + t.Run("requests shared webhook with empty config", func(t *testing.T) { + integrationCtx := &contexts.IntegrationContext{} + + err := trigger.Setup(core.TriggerContext{ + Integration: integrationCtx, + Metadata: &contexts.MetadataContext{}, + Configuration: map[string]any{ + "alertName": "High Error Rate", + }, + }) + + require.NoError(t, err) + require.Len(t, integrationCtx.WebhookRequests, 1) + + req, ok := integrationCtx.WebhookRequests[0].(map[string]any) + require.True(t, ok) + require.Len(t, req, 0) // empty config => shared webhook + }) +} + +func Test__OnAlertFired__HandleWebhook(t *testing.T) { + trigger := &OnAlertFired{} + + validConfig := map[string]any{ + "alertName": "High Error Rate", + } + + body := []byte(`{"alert":{"name":"High Error Rate"},"status":"firing"}`) + + t.Run("missing token header -> 401", func(t *testing.T) { + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Headers: http.Header{}, + Body: body, + Configuration: validConfig, + Webhook: &contexts.WebhookContext{Secret: "test-secret"}, + Events: &contexts.EventContext{}, + }) + + require.Equal(t, http.StatusUnauthorized, code) + require.ErrorContains(t, err, "missing webhook token") + }) + + t.Run("invalid token -> 403", func(t *testing.T) { + h := http.Header{} + h.Set("X-Honeycomb-Webhook-Token", "wrong-secret") + + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Headers: h, + Body: body, + Configuration: validConfig, + Webhook: &contexts.WebhookContext{Secret: "test-secret"}, + Events: &contexts.EventContext{}, + }) + + require.Equal(t, http.StatusForbidden, code) + require.ErrorContains(t, err, "invalid webhook token") + }) + + t.Run("valid token but alertName does not match -> no emit", func(t *testing.T) { + h := http.Header{} + h.Set("X-Honeycomb-Webhook-Token", "test-secret") + + events := &contexts.EventContext{} + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Headers: h, + Body: []byte(`{"alert":{"name":"Different"}}`), + Configuration: validConfig, + Webhook: &contexts.WebhookContext{Secret: "test-secret"}, + Events: events, + }) + + require.Equal(t, http.StatusOK, code) + require.NoError(t, err) + require.Equal(t, 0, events.Count()) + }) + + t.Run("valid token + match -> emits", func(t *testing.T) { + h := http.Header{} + h.Set("X-Honeycomb-Webhook-Token", "test-secret") + + events := &contexts.EventContext{} + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Headers: h, + Body: body, + Configuration: validConfig, + Webhook: &contexts.WebhookContext{Secret: "test-secret"}, + Events: events, + }) + + require.Equal(t, http.StatusOK, code) + require.NoError(t, err) + require.Equal(t, 1, events.Count()) + require.Equal(t, "honeycomb.alert.fired", events.Payloads[0].Type) + }) + + t.Run("authorization bearer token works -> emits", func(t *testing.T) { + h := http.Header{} + h.Set("Authorization", "Bearer test-secret") + + events := &contexts.EventContext{} + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Headers: h, + Body: body, + Configuration: validConfig, + Webhook: &contexts.WebhookContext{Secret: "test-secret"}, + Events: events, + }) + + require.Equal(t, http.StatusOK, code) + require.NoError(t, err) + require.Equal(t, 1, events.Count()) + }) + +} diff --git a/pkg/integrations/honeycomb/webhook_handler.go b/pkg/integrations/honeycomb/webhook_handler.go new file mode 100644 index 0000000000..4eee864766 --- /dev/null +++ b/pkg/integrations/honeycomb/webhook_handler.go @@ -0,0 +1,23 @@ +package honeycomb + +import ( + "github.com/superplanehq/superplane/pkg/core" +) + +type HoneycombWebhookHandler struct{} + +func (h *HoneycombWebhookHandler) CompareConfig(a, b any) (bool, error) { + return true, nil +} + +func (h *HoneycombWebhookHandler) Merge(current, requested any) (any, bool, error) { + return current, false, nil +} + +func (h *HoneycombWebhookHandler) Setup(ctx core.WebhookHandlerContext) (any, error) { + return nil, nil +} + +func (h *HoneycombWebhookHandler) Cleanup(ctx core.WebhookHandlerContext) error { + return nil +} diff --git a/pkg/server/server.go b/pkg/server/server.go index 78bf5e69eb..e5a7acae0f 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -42,6 +42,7 @@ import ( _ "github.com/superplanehq/superplane/pkg/integrations/dockerhub" _ "github.com/superplanehq/superplane/pkg/integrations/github" _ "github.com/superplanehq/superplane/pkg/integrations/gitlab" + _ "github.com/superplanehq/superplane/pkg/integrations/honeycomb" _ "github.com/superplanehq/superplane/pkg/integrations/jira" _ "github.com/superplanehq/superplane/pkg/integrations/openai" _ "github.com/superplanehq/superplane/pkg/integrations/pagerduty" diff --git a/web_src/src/assets/icons/integrations/honeycomb.svg b/web_src/src/assets/icons/integrations/honeycomb.svg new file mode 100644 index 0000000000..e42823905e --- /dev/null +++ b/web_src/src/assets/icons/integrations/honeycomb.svg @@ -0,0 +1 @@ +3e9a0894-1fb6-4d5a-8ff4-b62928e92b6a \ No newline at end of file diff --git a/web_src/src/pages/workflowv2/mappers/honeycomb/create_event.ts b/web_src/src/pages/workflowv2/mappers/honeycomb/create_event.ts new file mode 100644 index 0000000000..ef082028a0 --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/honeycomb/create_event.ts @@ -0,0 +1,121 @@ +import { + ComponentBaseContext, + ComponentBaseMapper, + ExecutionDetailsContext, + ExecutionInfo, + NodeInfo, + OutputPayload, + SubtitleContext, +} from "../types"; +import { ComponentBaseProps, ComponentBaseSpec, EventSection } from "@/ui/componentBase"; +import { getBackgroundColorClass, getColorClass } from "@/utils/colors"; +import { getState, getStateMap, getTriggerRenderer } from ".."; +import { MetadataItem } from "@/ui/metadataList"; +import honeycombIcon from "@/assets/icons/integrations/honeycomb.svg"; +import { formatTimeAgo } from "@/utils/date"; + +interface CreateEventConfiguration { + dataset?: string; + fields?: string; // JSON string +} + +type HoneycombCreateEventPayload = { + status?: string; + dataset?: string; + fields?: Record; +}; + +export const createEventMapper: ComponentBaseMapper = { + props(context: ComponentBaseContext): ComponentBaseProps { + const lastExecution = context.lastExecutions.length > 0 ? context.lastExecutions[0] : null; + const componentName = context.componentDefinition.name || "unknown"; + + return { + title: + context.node.name || + context.componentDefinition.label || + context.componentDefinition.name || + "Unnamed component", + iconSrc: honeycombIcon, + iconSlug: "honeycomb", + iconColor: getColorClass(context.componentDefinition.color), + collapsedBackground: getBackgroundColorClass(context.componentDefinition.color), + collapsed: context.node.isCollapsed, + eventSections: lastExecution ? createEventEventSections(context.nodes, lastExecution, componentName) : undefined, + includeEmptyState: !lastExecution, + metadata: createEventMetadataList(context.node), + specs: createEventSpecs(context.node), + eventStateMap: getStateMap(componentName), + }; + }, + + getExecutionDetails(context: ExecutionDetailsContext): Record { + const outputs = context.execution.outputs as { default?: OutputPayload[] } | undefined; + const first = outputs?.default?.[0]; + const data = first?.data as HoneycombCreateEventPayload | undefined; + + return { + "Created At": context.execution.createdAt ? new Date(context.execution.createdAt).toLocaleString() : "-", + Status: data?.status ?? "-", + Dataset: data?.dataset ?? "-", + "Sent Fields": data?.fields ? safeJSONStringify(data.fields) : "-", + }; + }, + + subtitle(context: SubtitleContext): string { + if (!context.execution.createdAt) return ""; + return formatTimeAgo(new Date(context.execution.createdAt)); + }, +}; + +function createEventMetadataList(node: NodeInfo): MetadataItem[] { + const metadata: MetadataItem[] = []; + const configuration = node.configuration as CreateEventConfiguration | undefined; + + if (configuration?.dataset) { + metadata.push({ icon: "database", label: configuration.dataset }); + } + + return metadata; +} + +function createEventSpecs(node: NodeInfo): ComponentBaseSpec[] { + const specs: ComponentBaseSpec[] = []; + const configuration = node.configuration as CreateEventConfiguration | undefined; + + if (configuration?.fields) { + specs.push({ + title: "fields", + tooltipTitle: "fields", + iconSlug: "braces", + value: configuration.fields, + contentType: "json", + }); + } + + return specs; +} + +function createEventEventSections(nodes: NodeInfo[], execution: ExecutionInfo, componentName: string): EventSection[] { + const rootTriggerNode = nodes.find((n) => n.id === execution.rootEvent?.nodeId); + const rootTriggerRenderer = getTriggerRenderer(rootTriggerNode?.componentName!); + const { title } = rootTriggerRenderer.getTitleAndSubtitle({ event: execution.rootEvent }); + + return [ + { + receivedAt: new Date(execution.createdAt!), + eventTitle: title, + eventSubtitle: formatTimeAgo(new Date(execution.createdAt!)), + eventState: getState(componentName)(execution), + eventId: execution.rootEvent!.id!, + }, + ]; +} + +function safeJSONStringify(value: unknown): string { + try { + return JSON.stringify(value, null, 2); + } catch { + return String(value ?? ""); + } +} diff --git a/web_src/src/pages/workflowv2/mappers/honeycomb/custom_fields.tsx b/web_src/src/pages/workflowv2/mappers/honeycomb/custom_fields.tsx new file mode 100644 index 0000000000..805ca49259 --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/honeycomb/custom_fields.tsx @@ -0,0 +1,89 @@ +import { CustomFieldRenderer, NodeInfo } from "../types"; +import React from "react"; + +type OnAlertFiredMetadata = { + webhookUrl?: string; + sharedSecret?: string; +}; + +function extractWebhookData(node: NodeInfo): { webhookUrl: string; sharedSecret: string } { + const md = (node.metadata ?? {}) as OnAlertFiredMetadata; + + const webhookUrl = (md.webhookUrl ?? "").trim(); + const sharedSecret = (md.sharedSecret ?? "").trim(); + + return { webhookUrl, sharedSecret }; +} + +function CopyRow({ label, value }: { label: string; value: string }): React.ReactElement { + const shown = value?.trim() ? value : "Save this node to generate"; + + const [copied, setCopied] = React.useState(false); + + const onCopy = async () => { + if (!value?.trim()) return; + try { + await navigator.clipboard.writeText(value); + setCopied(true); + setTimeout(() => setCopied(false), 1200); + } catch { + // ignore + } + }; + + return ( +
+
+
{label}
+
{shown}
+
+ + +
+ ); +} + +export const honeycombOnAlertFiredCustomFieldRenderer: CustomFieldRenderer = { + render(node: NodeInfo) { + const { webhookUrl, sharedSecret } = extractWebhookData(node); + + return ( +
+
Honeycomb webhook setup
+
+ After saving this trigger, SuperPlane will generate a shared Webhook URL and Shared Secret. +
+ + + + +
+ If no events arrive, verify the webhook is configured as a recipient in Honeycomb. +
+
+ ); + }, +}; + +export const honeycombCustomFieldRenderers: Record = { + onAlertFired: honeycombOnAlertFiredCustomFieldRenderer, + "honeycomb.onAlertFired": honeycombOnAlertFiredCustomFieldRenderer, +}; diff --git a/web_src/src/pages/workflowv2/mappers/honeycomb/index.ts b/web_src/src/pages/workflowv2/mappers/honeycomb/index.ts new file mode 100644 index 0000000000..128b0b65ec --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/honeycomb/index.ts @@ -0,0 +1,25 @@ +import { ComponentBaseMapper, TriggerRenderer, EventStateRegistry, CustomFieldRenderer } from "../types"; +import { buildActionStateRegistry } from "../utils"; + +import { createEventMapper } from "./create_event"; +import { onAlertFiredTriggerRenderer } from "./on_alert_fired"; +import { honeycombCustomFieldRenderers } from "./custom_fields"; + +export const componentMappers: Record = { + createEvent: createEventMapper, + "honeycomb.createEvent": createEventMapper, +}; + +export const triggerRenderers: Record = { + onAlertFired: onAlertFiredTriggerRenderer, + "honeycomb.onAlertFired": onAlertFiredTriggerRenderer, +}; + +export const eventStateRegistry: Record = { + createEvent: buildActionStateRegistry("Sent"), + "honeycomb.createEvent": buildActionStateRegistry("Sent"), +}; + +export const customFieldRenderers: Record = { + ...honeycombCustomFieldRenderers, +}; diff --git a/web_src/src/pages/workflowv2/mappers/honeycomb/on_alert_fired.ts b/web_src/src/pages/workflowv2/mappers/honeycomb/on_alert_fired.ts new file mode 100644 index 0000000000..02ae970b6c --- /dev/null +++ b/web_src/src/pages/workflowv2/mappers/honeycomb/on_alert_fired.ts @@ -0,0 +1,74 @@ +import { TriggerRenderer, TriggerRendererContext, TriggerEventContext } from "../types"; +import { defaultTriggerRenderer } from "../default"; + +type OnAlertFiredMetadata = { + webhookUrl?: string; + sharedSecret?: string; +}; + +export const onAlertFiredTriggerRenderer: TriggerRenderer = { + getTitleAndSubtitle: (context: TriggerEventContext) => { + const title = context.event?.customName?.trim() || "On Alert Fired"; + return { title, subtitle: "Triggers when Honeycomb sends an alert webhook" }; + }, + + getRootEventValues: (context: TriggerEventContext) => ({ + type: context.event?.type, + createdAt: context.event?.createdAt, + data: context.event?.data, + }), + + getTriggerProps: (context: TriggerRendererContext) => { + const base = defaultTriggerRenderer.getTriggerProps(context); + + const md = (context.node?.metadata || {}) as OnAlertFiredMetadata; + const webhookUrl = (md.webhookUrl || "").trim(); + const sharedSecret = (md.sharedSecret || "").trim(); + + const hasGenerated = !!webhookUrl && !!sharedSecret; + + return { + ...base, + nodeMeta: { + title: "Honeycomb webhook setup", + sections: [ + { + title: "Copy into Honeycomb", + items: [ + { + label: "Webhook URL", + value: hasGenerated ? webhookUrl : "Save this trigger to generate", + copyable: hasGenerated, + }, + { + label: "Shared Secret", + value: hasGenerated ? sharedSecret : "Save this trigger to generate", + copyable: hasGenerated, + }, + ], + }, + { + title: "Honeycomb steps", + items: [ + { + label: "Where to paste", + value: + "Honeycomb → Team Settings → Integrations → Webhooks. Create a webhook integration and paste the values above. Then attach the webhook as an alert recipient.", + }, + ], + }, + { + title: "Troubleshooting", + items: [ + { + label: "No events?", + value: + "If no events arrive, verify the webhook is configured as a recipient in Honeycomb and trigger an alert to test.", + }, + ], + }, + ], + }, + }; + }, +}; diff --git a/web_src/src/pages/workflowv2/mappers/index.ts b/web_src/src/pages/workflowv2/mappers/index.ts index 1060a598b1..ae6f782242 100644 --- a/web_src/src/pages/workflowv2/mappers/index.ts +++ b/web_src/src/pages/workflowv2/mappers/index.ts @@ -108,6 +108,15 @@ import { eventStateRegistry as dockerhubEventStateRegistry, } from "./dockerhub"; +import { + componentMappers as honeycombComponentMappers, + triggerRenderers as honeycombTriggerRenderers, + eventStateRegistry as honeycombEventStateRegistry, + customFieldRenderers as honeycombCustomFieldRenderers, +} from "./honeycomb/index"; + + + import { filterMapper, FILTER_STATE_REGISTRY } from "./filter"; import { sshMapper, SSH_STATE_REGISTRY } from "./ssh"; import { waitCustomFieldRenderer, waitMapper, WAIT_STATE_REGISTRY } from "./wait"; @@ -158,6 +167,8 @@ const appMappers: Record> = { openai: openaiComponentMappers, claude: claudeComponentMappers, dockerhub: dockerhubComponentMappers, + honeycomb: honeycombComponentMappers, + }; const appTriggerRenderers: Record> = { @@ -179,6 +190,8 @@ const appTriggerRenderers: Record> = { openai: openaiTriggerRenderers, claude: claudeTriggerRenderers, dockerhub: dockerhubTriggerRenderers, + honeycomb: honeycombTriggerRenderers, + }; const appEventStateRegistries: Record> = { @@ -200,6 +213,8 @@ const appEventStateRegistries: Record aws: awsEventStateRegistry, gitlab: gitlabEventStateRegistry, dockerhub: dockerhubEventStateRegistry, + honeycomb: honeycombEventStateRegistry, + }; const componentAdditionalDataBuilders: Record = { @@ -226,6 +241,7 @@ const customFieldRenderers: Record = { const appCustomFieldRenderers: Record> = { github: githubCustomFieldRenderers, dockerhub: dockerhubCustomFieldRenderers, + honeycomb: honeycombCustomFieldRenderers, }; /** From a559d69dad7da50afc422ad7ef50fa4cb681163e Mon Sep 17 00:00:00 2001 From: Dragica Draskic Date: Tue, 17 Feb 2026 20:49:17 +0100 Subject: [PATCH 02/24] fix: Improve webhook token validation in Honeycomb integration Enhances the security of the webhook token validation by using constant-time comparison to prevent timing attacks. This change ensures that the length of the provided token does not leak information, and improves the overall robustness of the integration. Signed-off-by: Dragica dragica.draskic@gmail.com Signed-off-by: Dragica Draskic --- pkg/integrations/honeycomb/on_alert_fired.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/pkg/integrations/honeycomb/on_alert_fired.go b/pkg/integrations/honeycomb/on_alert_fired.go index b647315714..6dc021afa0 100644 --- a/pkg/integrations/honeycomb/on_alert_fired.go +++ b/pkg/integrations/honeycomb/on_alert_fired.go @@ -1,6 +1,7 @@ package honeycomb import ( + "crypto/subtle" "encoding/json" "fmt" "net/http" @@ -159,7 +160,13 @@ func (t *OnAlertFired) HandleWebhook(ctx core.WebhookRequestContext) (int, error provided = strings.TrimSpace(provided[len("bearer "):]) } - if provided != secret { + providedBytes := []byte(provided) + secretBytes := []byte(secret) + if len(providedBytes) != len(secretBytes) { + subtle.ConstantTimeCompare(secretBytes, secretBytes) // constant-time dummy to avoid leaking length + return http.StatusForbidden, fmt.Errorf("invalid webhook token") + } + if subtle.ConstantTimeCompare(providedBytes, secretBytes) != 1 { return http.StatusForbidden, fmt.Errorf("invalid webhook token") } From c0958ce3e96ebcf626b47944a498cbfc6516ee92 Mon Sep 17 00:00:00 2001 From: Dragica Draskic Date: Tue, 17 Feb 2026 20:49:34 +0100 Subject: [PATCH 03/24] fix: Enhance error handling for webhook integration Improves error handling in the webhook integration by providing clearer error messages and ensuring that all potential failure points are properly logged. This change aims to enhance the user experience by making it easier to diagnose issues related to webhook events. Signed-off-by: Dragica Draskic --- pkg/integrations/honeycomb/client.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/integrations/honeycomb/client.go b/pkg/integrations/honeycomb/client.go index 8a6bf2e2aa..7b12065d62 100644 --- a/pkg/integrations/honeycomb/client.go +++ b/pkg/integrations/honeycomb/client.go @@ -108,7 +108,8 @@ func (c *Client) CreateEvent(datasetSlug string, fields map[string]any) error { req.Header.Set("Content-Type", "application/json") // Always set event timestamp header (Honeycomb uses the header for event time). - req.Header.Set("X-Honeycomb-Event-Time", fmt.Sprintf("%d", time.Now().Unix())) + // Use RFC3339Nano in UTC to match Honeycomb's expected format (same as libhoney-go). + req.Header.Set("X-Honeycomb-Event-Time", time.Now().UTC().Format(time.RFC3339Nano)) resp, err := c.http.Do(req) if err != nil { From 55a3330995ffedab5f45ece38b11a36e3c68f901 Mon Sep 17 00:00:00 2001 From: Dragica Draskic Date: Tue, 17 Feb 2026 21:22:08 +0100 Subject: [PATCH 04/24] fix: correct webhook token comparison and variable redeclaration Signed-off-by: Dragica Draskic --- pkg/integrations/honeycomb/on_alert_fired.go | 9 +++++---- pkg/server/server.go | 5 +---- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/pkg/integrations/honeycomb/on_alert_fired.go b/pkg/integrations/honeycomb/on_alert_fired.go index 6dc021afa0..9ec1e331ee 100644 --- a/pkg/integrations/honeycomb/on_alert_fired.go +++ b/pkg/integrations/honeycomb/on_alert_fired.go @@ -161,12 +161,13 @@ func (t *OnAlertFired) HandleWebhook(ctx core.WebhookRequestContext) (int, error } providedBytes := []byte(provided) - secretBytes := []byte(secret) - if len(providedBytes) != len(secretBytes) { - subtle.ConstantTimeCompare(secretBytes, secretBytes) // constant-time dummy to avoid leaking length + secretTokenBytes := []byte(secret) + + if len(providedBytes) != len(secretTokenBytes) { + subtle.ConstantTimeCompare(secretTokenBytes, secretTokenBytes) // dummy work return http.StatusForbidden, fmt.Errorf("invalid webhook token") } - if subtle.ConstantTimeCompare(providedBytes, secretBytes) != 1 { + if subtle.ConstantTimeCompare(providedBytes, secretTokenBytes) != 1 { return http.StatusForbidden, fmt.Errorf("invalid webhook token") } diff --git a/pkg/server/server.go b/pkg/server/server.go index 7dd69ff24c..aa095e4f04 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -45,11 +45,8 @@ import ( _ "github.com/superplanehq/superplane/pkg/integrations/dockerhub" _ "github.com/superplanehq/superplane/pkg/integrations/github" _ "github.com/superplanehq/superplane/pkg/integrations/gitlab" -<<<<<<< feat/honeycomb-integration - _ "github.com/superplanehq/superplane/pkg/integrations/honeycomb" -======= _ "github.com/superplanehq/superplane/pkg/integrations/hetzner" ->>>>>>> main + _ "github.com/superplanehq/superplane/pkg/integrations/honeycomb" _ "github.com/superplanehq/superplane/pkg/integrations/jira" _ "github.com/superplanehq/superplane/pkg/integrations/openai" _ "github.com/superplanehq/superplane/pkg/integrations/pagerduty" From b330bc229601b9f81e3c3fae4c9434dc4b810bb3 Mon Sep 17 00:00:00 2001 From: Dragica Draskic Date: Tue, 17 Feb 2026 21:46:28 +0100 Subject: [PATCH 05/24] fix: Update test assertions for Honeycomb event creation Refines assertions in the Honeycomb event creation test to ensure the payload is a valid JSON object and that the event time is correctly sent via the header. This change enhances the clarity of the test and improves validation of the event data structure. Signed-off-by: Dragica Draskic --- pkg/integrations/honeycomb/create_event_test.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/pkg/integrations/honeycomb/create_event_test.go b/pkg/integrations/honeycomb/create_event_test.go index b6ce85cbd4..4167449e5b 100644 --- a/pkg/integrations/honeycomb/create_event_test.go +++ b/pkg/integrations/honeycomb/create_event_test.go @@ -109,13 +109,11 @@ func Test__CreateEvent__Execute(t *testing.T) { bodyBytes, _ := io.ReadAll(req.Body) bodyStr := strings.TrimSpace(string(bodyBytes)) - // tvoj client šalje JSON object (fields) assert.True(t, strings.HasPrefix(bodyStr, "{"), "payload should be a JSON object") assert.Contains(t, bodyStr, `"message":"deployment"`) assert.Contains(t, bodyStr, `"version":"1.2.3"`) - // vreme ide kroz header (auto) - assert.NotEmpty(t, req.Header.Get("X-Honeycomb-Event-Time")) + assert.NotEmpty(t, req.Header.Get("X-Honeycomb-Event-Time"), "event time is sent via header") }) } From 91e7a2aadc1e4917b94c10d0e3c5b00fbf1b2d16 Mon Sep 17 00:00:00 2001 From: Dragica Draskic Date: Tue, 17 Feb 2026 21:50:12 +0100 Subject: [PATCH 06/24] fix: Update Honeycomb event timestamp handling and tests Refines the Honeycomb client to set the event timestamp header only when the "time" field is not provided in the fields map. This change ensures that user-defined timestamps are respected. Additionally, updates the test cases to verify the correct behavior of the timestamp header based on the presence of the "time" field, enhancing test clarity and coverage. Signed-off-by: Dragica Draskic --- pkg/integrations/honeycomb/client.go | 10 +++- .../honeycomb/create_event_test.go | 56 ++++++++++++++++++- 2 files changed, 61 insertions(+), 5 deletions(-) diff --git a/pkg/integrations/honeycomb/client.go b/pkg/integrations/honeycomb/client.go index 7b12065d62..f05747ed98 100644 --- a/pkg/integrations/honeycomb/client.go +++ b/pkg/integrations/honeycomb/client.go @@ -107,9 +107,13 @@ func (c *Client) CreateEvent(datasetSlug string, fields map[string]any) error { req.Header.Set("X-Honeycomb-Team", c.APIKey) req.Header.Set("Content-Type", "application/json") - // Always set event timestamp header (Honeycomb uses the header for event time). - // Use RFC3339Nano in UTC to match Honeycomb's expected format (same as libhoney-go). - req.Header.Set("X-Honeycomb-Event-Time", time.Now().UTC().Format(time.RFC3339Nano)) + // Set event timestamp header only if "time" field is not provided in the fields map. + // Honeycomb uses this header as the authoritative event timestamp, so we only set it + // when the user hasn't provided their own timestamp. Use RFC3339Nano in UTC to match + // Honeycomb's expected format (same as libhoney-go). + if _, hasTimeField := fields["time"]; !hasTimeField { + req.Header.Set("X-Honeycomb-Event-Time", time.Now().UTC().Format(time.RFC3339Nano)) + } resp, err := c.http.Do(req) if err != nil { diff --git a/pkg/integrations/honeycomb/create_event_test.go b/pkg/integrations/honeycomb/create_event_test.go index 4167449e5b..dbd1a6e47f 100644 --- a/pkg/integrations/honeycomb/create_event_test.go +++ b/pkg/integrations/honeycomb/create_event_test.go @@ -66,7 +66,7 @@ func Test__CreateEvent__Execute(t *testing.T) { require.ErrorContains(t, err, "api key is required") }) - t.Run("successful event creation -> emits payload", func(t *testing.T) { + t.Run("successful event creation without time field -> emits payload and sets header", func(t *testing.T) { httpCtx := &contexts.HTTPContext{ Responses: []*http.Response{ { @@ -113,7 +113,59 @@ func Test__CreateEvent__Execute(t *testing.T) { assert.Contains(t, bodyStr, `"message":"deployment"`) assert.Contains(t, bodyStr, `"version":"1.2.3"`) - assert.NotEmpty(t, req.Header.Get("X-Honeycomb-Event-Time"), "event time is sent via header") + // When time field is not provided, header should be set automatically + assert.NotEmpty(t, req.Header.Get("X-Honeycomb-Event-Time"), "event time header should be set when time field is not provided") + }) + + t.Run("successful event creation with time field -> emits payload without header", func(t *testing.T) { + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{}`)), + }, + }, + } + + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "apiKey": "test-api-key", + "site": "api.honeycomb.io", + }, + } + + execState := &contexts.ExecutionStateContext{KVs: map[string]string{}} + + err := component.Execute(core.ExecutionContext{ + Integration: integrationCtx, + ExecutionState: execState, + HTTP: httpCtx, + Configuration: map[string]any{ + "dataset": "test-dataset", + "fields": `{"message":"deployment","version":"1.2.3","time":"2024-01-15T10:30:00Z"}`, + }, + }) + + require.NoError(t, err) + assert.Equal(t, core.DefaultOutputChannel.Name, execState.Channel) + assert.Equal(t, "honeycomb.event.created", execState.Type) + + require.Len(t, httpCtx.Requests, 1) + req := httpCtx.Requests[0] + assert.Equal(t, http.MethodPost, req.Method) + assert.Contains(t, req.URL.String(), "https://api.honeycomb.io/1/events/test-dataset") + assert.Equal(t, "test-api-key", req.Header.Get("X-Honeycomb-Team")) + assert.Equal(t, "application/json", req.Header.Get("Content-Type")) + + bodyBytes, _ := io.ReadAll(req.Body) + bodyStr := strings.TrimSpace(string(bodyBytes)) + + assert.True(t, strings.HasPrefix(bodyStr, "{"), "payload should be a JSON object") + assert.Contains(t, bodyStr, `"message":"deployment"`) + assert.Contains(t, bodyStr, `"version":"1.2.3"`) + assert.Contains(t, bodyStr, `"time":"2024-01-15T10:30:00Z"`) + // When time field is provided, header should NOT be set (user's timestamp should be used) + assert.Empty(t, req.Header.Get("X-Honeycomb-Event-Time"), "event time header should not be set when time field is provided") }) } From 39139ceb897f3b0b8e007c737abc3122f73c66c5 Mon Sep 17 00:00:00 2001 From: Dragica Draskic Date: Tue, 17 Feb 2026 22:08:35 +0100 Subject: [PATCH 07/24] chore: run prettier Signed-off-by: Dragica Draskic --- .../src/pages/workflowv2/mappers/honeycomb/custom_fields.tsx | 1 - web_src/src/pages/workflowv2/mappers/index.ts | 5 ----- 2 files changed, 6 deletions(-) diff --git a/web_src/src/pages/workflowv2/mappers/honeycomb/custom_fields.tsx b/web_src/src/pages/workflowv2/mappers/honeycomb/custom_fields.tsx index 805ca49259..1ba2b515ec 100644 --- a/web_src/src/pages/workflowv2/mappers/honeycomb/custom_fields.tsx +++ b/web_src/src/pages/workflowv2/mappers/honeycomb/custom_fields.tsx @@ -52,7 +52,6 @@ function CopyRow({ label, value }: { label: string; value: string }): React.Reac transition: "background 150ms ease", fontWeight: 600, minWidth: 72, - }} > {copied ? "Copied" : "Copy"} diff --git a/web_src/src/pages/workflowv2/mappers/index.ts b/web_src/src/pages/workflowv2/mappers/index.ts index b9e5c84cb8..27e94719ac 100644 --- a/web_src/src/pages/workflowv2/mappers/index.ts +++ b/web_src/src/pages/workflowv2/mappers/index.ts @@ -133,8 +133,6 @@ import { customFieldRenderers as honeycombCustomFieldRenderers, } from "./honeycomb/index"; - - import { filterMapper, FILTER_STATE_REGISTRY } from "./filter"; import { sshMapper, SSH_STATE_REGISTRY } from "./ssh"; import { waitCustomFieldRenderer, waitMapper, WAIT_STATE_REGISTRY } from "./wait"; @@ -190,7 +188,6 @@ const appMappers: Record> = { hetzner: hetznerComponentMappers, dockerhub: dockerhubComponentMappers, honeycomb: honeycombComponentMappers, - }; const appTriggerRenderers: Record> = { @@ -217,7 +214,6 @@ const appTriggerRenderers: Record> = { cursor: cursorTriggerRenderers, dockerhub: dockerhubTriggerRenderers, honeycomb: honeycombTriggerRenderers, - }; const appEventStateRegistries: Record> = { @@ -243,7 +239,6 @@ const appEventStateRegistries: Record gitlab: gitlabEventStateRegistry, dockerhub: dockerhubEventStateRegistry, honeycomb: honeycombEventStateRegistry, - }; const componentAdditionalDataBuilders: Record = { From 4c2bef1cc6001bab9f3ce249929fbf29c886f9f0 Mon Sep 17 00:00:00 2001 From: Dragica Draskic Date: Tue, 17 Feb 2026 22:11:38 +0100 Subject: [PATCH 08/24] fix: Refine webhook token handling in Honeycomb integration Enhances the token validation logic in the OnAlertFired handler to ensure that tokens prefixed with "bearer" from the Authorization header are not stripped. Additionally, adds new test cases to verify the correct handling of tokens from both the X-Honeycomb-Webhook-Token and X-Shared-Secret headers, improving overall robustness and coverage of the integration. Signed-off-by: Dragica Draskic --- pkg/integrations/honeycomb/on_alert_fired.go | 4 ++- .../honeycomb/on_alert_fired_test.go | 36 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/pkg/integrations/honeycomb/on_alert_fired.go b/pkg/integrations/honeycomb/on_alert_fired.go index 9ec1e331ee..8850e7ba6e 100644 --- a/pkg/integrations/honeycomb/on_alert_fired.go +++ b/pkg/integrations/honeycomb/on_alert_fired.go @@ -142,10 +142,12 @@ func (t *OnAlertFired) HandleWebhook(ctx core.WebhookRequestContext) (int, error } provided := strings.TrimSpace(ctx.Headers.Get("X-Honeycomb-Webhook-Token")) + fromAuthorizationHeader := false // Fallbacks if provided == "" { provided = strings.TrimSpace(ctx.Headers.Get("Authorization")) + fromAuthorizationHeader = provided != "" } if provided == "" { @@ -156,7 +158,7 @@ func (t *OnAlertFired) HandleWebhook(ctx core.WebhookRequestContext) (int, error return http.StatusUnauthorized, fmt.Errorf("missing webhook token") } - if strings.HasPrefix(strings.ToLower(provided), "bearer ") { + if fromAuthorizationHeader && strings.HasPrefix(strings.ToLower(provided), "bearer ") { provided = strings.TrimSpace(provided[len("bearer "):]) } diff --git a/pkg/integrations/honeycomb/on_alert_fired_test.go b/pkg/integrations/honeycomb/on_alert_fired_test.go index 9cdcafc521..b710b1f9c7 100644 --- a/pkg/integrations/honeycomb/on_alert_fired_test.go +++ b/pkg/integrations/honeycomb/on_alert_fired_test.go @@ -125,4 +125,40 @@ func Test__OnAlertFired__HandleWebhook(t *testing.T) { require.Equal(t, 1, events.Count()) }) + t.Run("custom token header starting with bearer is not stripped -> emits", func(t *testing.T) { + h := http.Header{} + h.Set("X-Honeycomb-Webhook-Token", "bearer test-secret") + + events := &contexts.EventContext{} + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Headers: h, + Body: body, + Configuration: validConfig, + Webhook: &contexts.WebhookContext{Secret: "bearer test-secret"}, + Events: events, + }) + + require.Equal(t, http.StatusOK, code) + require.NoError(t, err) + require.Equal(t, 1, events.Count()) + }) + + t.Run("shared secret header starting with bearer is not stripped -> emits", func(t *testing.T) { + h := http.Header{} + h.Set("X-Shared-Secret", "Bearer test-secret") + + events := &contexts.EventContext{} + code, err := trigger.HandleWebhook(core.WebhookRequestContext{ + Headers: h, + Body: body, + Configuration: validConfig, + Webhook: &contexts.WebhookContext{Secret: "Bearer test-secret"}, + Events: events, + }) + + require.Equal(t, http.StatusOK, code) + require.NoError(t, err) + require.Equal(t, 1, events.Count()) + }) + } From 12a2e4ebe5ea50c53e1967c16bad762236545e75 Mon Sep 17 00:00:00 2001 From: Dragica Draskic Date: Tue, 17 Feb 2026 22:17:33 +0100 Subject: [PATCH 09/24] refactor: Remove unused EU base URL from Honeycomb client Eliminates the unused BaseURLEU constant from the Honeycomb client, streamlining the code and improving clarity. This change helps maintain cleaner integration code by removing unnecessary elements. Signed-off-by: Dragica Draskic --- pkg/integrations/honeycomb/client.go | 1 - 1 file changed, 1 deletion(-) diff --git a/pkg/integrations/honeycomb/client.go b/pkg/integrations/honeycomb/client.go index f05747ed98..e1fec01c4b 100644 --- a/pkg/integrations/honeycomb/client.go +++ b/pkg/integrations/honeycomb/client.go @@ -15,7 +15,6 @@ import ( const ( BaseURLUS = "https://api.honeycomb.io" - BaseURLEU = "https://api.eu1.honeycomb.io" ) type Client struct { From 2a3e227d52db4aefdb4a43f4503c132bc7e0b8f3 Mon Sep 17 00:00:00 2001 From: Dragica Draskic Date: Sun, 22 Feb 2026 13:08:52 +0100 Subject: [PATCH 10/24] feat: Add Honeycomb integration components and mappers Signed-off-by: Dragica Draskic --- docs/components/Honeycomb.mdx | 45 +- pkg/integrations/honeycomb/client.go | 725 +++++++++++++++++- pkg/integrations/honeycomb/create_event.go | 74 +- .../honeycomb/create_event_test.go | 93 ++- pkg/integrations/honeycomb/honeycomb.go | 101 ++- pkg/integrations/honeycomb/honeycomb_test.go | 212 +++-- pkg/integrations/honeycomb/on_alert_fired.go | 162 ++-- .../honeycomb/on_alert_fired_test.go | 125 +-- pkg/integrations/honeycomb/webhook_handler.go | 143 +++- .../mappers/honeycomb/create_event.ts | 8 +- .../mappers/honeycomb/custom_fields.tsx | 88 --- .../workflowv2/mappers/honeycomb/index.ts | 16 +- .../mappers/honeycomb/on_alert_fired.ts | 138 ++-- web_src/src/pages/workflowv2/mappers/index.ts | 2 - .../src/ui/BuildingBlocksSidebar/index.tsx | 3 + .../ui/componentSidebar/integrationIcons.tsx | 3 + 16 files changed, 1465 insertions(+), 473 deletions(-) delete mode 100644 web_src/src/pages/workflowv2/mappers/honeycomb/custom_fields.tsx diff --git a/docs/components/Honeycomb.mdx b/docs/components/Honeycomb.mdx index b05ead93e9..c952956b9f 100644 --- a/docs/components/Honeycomb.mdx +++ b/docs/components/Honeycomb.mdx @@ -2,12 +2,12 @@ title: "Honeycomb" --- -Triggers and actions for Honeycomb +Monitor observability alerts and send events to Honeycomb datasets ## Triggers - + import { CardGrid, LinkCard } from "@astrojs/starlight/components"; @@ -15,33 +15,35 @@ import { CardGrid, LinkCard } from "@astrojs/starlight/components"; ## Actions - + ## Instructions -Connect Honeycomb to Superplane using a Honeycomb API key. +Connect Honeycomb to SuperPlane using a Management Key. -**Get your API key**: -Honeycomb → Account → Team Settings → API Keys → copy key → paste here. +**Required configuration:** +- **Site**: US (api.honeycomb.io) or EU (api.eu1.honeycomb.io) based on your account region. +- **Management Key**: Found in Honeycomb under Team Settings > API Keys. Must be in format :. +- **Team Slug**: Your team identifier, visible in the Honeycomb URL: honeycomb.io/. +- **Environment Slug**: The environment containing your datasets (e.g. "production"). Found under Team Settings > Environments. -**Trigger setup**: -After saving a Honeycomb trigger node, Superplane generates a Webhook URL and Shared Secret. -Add them in Honeycomb → Team Settings → Integrations → Webhooks. - -Once configured, Honeycomb events will trigger your workflow automatically. +SuperPlane will automatically validate your credentials and manage all necessary Honeycomb resources — webhook recipients for triggers and ingest keys for actions — so no manual setup is required. ## On Alert Fired -The On Alert Fired trigger starts a workflow execution when Honeycomb sends an alert webhook. +Starts a workflow execution when a Honeycomb Trigger fires. + +**Configuration:** +- **Dataset Slug**: The slug of the dataset that contains your Honeycomb trigger. Found in the dataset URL: honeycomb.io//datasets/. +- **Alert Name**: The exact name of the Honeycomb trigger to listen to (case-insensitive). Found in your dataset under Triggers. + +**How it works:** +SuperPlane automatically creates a webhook recipient in Honeycomb and attaches it to the selected trigger. No manual webhook setup is required. -Setup: -1) Add this trigger to a workflow -2) Optionally set Alert Name (used to filter which node fires) -3) SAVE the node -4) Copy Webhook URL and Shared Secret into Honeycomb webhook integration +When the trigger fires, SuperPlane receives the webhook and starts a workflow execution with the full alert payload. ### Example Data @@ -90,13 +92,14 @@ Setup: ## Create Event -Sends a custom event to Honeycomb using the Events API. +Sends a JSON event to a Honeycomb dataset. -The component sends a single event as a JSON object where each key becomes a Honeycomb field. +Each key in the JSON object becomes a Honeycomb field. Notes: -- The request body is the JSON object you provide in "Fields". -- If you do not include a "time" field, the current time is automatically set via request header. +• Dataset must exist +• Fields must be valid JSON object +• Timestamp is auto-added if missing ### Example Output diff --git a/pkg/integrations/honeycomb/client.go b/pkg/integrations/honeycomb/client.go index e1fec01c4b..1b476f51a1 100644 --- a/pkg/integrations/honeycomb/client.go +++ b/pkg/integrations/honeycomb/client.go @@ -2,6 +2,8 @@ package honeycomb import ( "bytes" + "crypto/rand" + "encoding/hex" "encoding/json" "fmt" "io" @@ -14,32 +16,25 @@ import ( ) const ( - BaseURLUS = "https://api.honeycomb.io" + secretNameIngestKey = "honeycomb_ingest_key" + secretNameConfigurationKey = "honeycomb_configuration_key" ) type Client struct { - APIKey string - BaseURL string - http core.HTTPContext + BaseURL string + ManagementKey string + http core.HTTPContext + integrationCtx core.IntegrationContext } func NewClient(httpCtx core.HTTPContext, ctx core.IntegrationContext) (*Client, error) { - apiKeyAny, err := ctx.GetConfig("apiKey") - if err != nil { - return nil, fmt.Errorf("api key is required") - } - apiKey := strings.TrimSpace(string(apiKeyAny)) - if apiKey == "" { - return nil, fmt.Errorf("api key is required") - } - siteAny, err := ctx.GetConfig("site") if err != nil { siteAny = []byte("api.honeycomb.io") } site := strings.TrimSpace(string(siteAny)) if site == "" { - site = strings.TrimPrefix(BaseURLUS, "https://") + site = "api.honeycomb.io" } baseURL := site @@ -47,41 +42,597 @@ func NewClient(httpCtx core.HTTPContext, ctx core.IntegrationContext) (*Client, baseURL = "https://" + baseURL } + mkAny, err := ctx.GetConfig("managementKey") + if err != nil { + return nil, fmt.Errorf("managementKey is required") + } + mk := strings.TrimSpace(string(mkAny)) + if mk == "" { + return nil, fmt.Errorf("managementKey is required") + } + return &Client{ - APIKey: apiKey, - BaseURL: baseURL, - http: httpCtx, + BaseURL: baseURL, + ManagementKey: mk, + http: httpCtx, + integrationCtx: ctx, }, nil +} +// bearerFromManagementKey normalizes the management key into "keyID:secret" format +// required by the Honeycomb v2 API Authorization header. +func (c *Client) bearerFromManagementKey() (string, error) { + mk := strings.TrimSpace(c.ManagementKey) + if mk == "" { + return "", fmt.Errorf("managementKey is empty") + } + + if strings.HasPrefix(strings.ToLower(mk), "bearer ") { + mk = strings.TrimSpace(mk[len("bearer "):]) + } + + parts := strings.SplitN(mk, ":", 2) + if len(parts) != 2 { + return "", fmt.Errorf("managementKey must be in format :") + } + + id := strings.TrimSpace(parts[0]) + sec := strings.TrimSpace(parts[1]) + + if id == "" || sec == "" { + return "", fmt.Errorf("managementKey must be in format : (both parts required)") + } + + return id + ":" + sec, nil } -func (c *Client) Validate() error { - req, err := http.NewRequest(http.MethodGet, c.BaseURL+"/1/auth", nil) +// newReqV1 builds a request for the Honeycomb /1 API using the configuration key secret. +func (c *Client) newReqV1(method, path string, body io.Reader) (*http.Request, error) { + u, _ := url.Parse(c.BaseURL) + u.Path = path + + req, err := http.NewRequest(method, u.String(), body) if err != nil { - return err + return nil, err + } + + cfgKey, err := c.getSecretValue(secretNameConfigurationKey) + if err != nil { + return nil, fmt.Errorf("missing configuration key secret %q: %w", secretNameConfigurationKey, err) + } + cfgKey = strings.TrimSpace(cfgKey) + if cfgKey == "" { + return nil, fmt.Errorf("configuration key secret %q is empty", secretNameConfigurationKey) + } + + req.Header.Set("X-Honeycomb-Team", cfgKey) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + return req, nil +} + +// newReqV2 builds a request for the Honeycomb /2 API using the management key. +func (c *Client) newReqV2(method, path string, body io.Reader) (*http.Request, error) { + u, _ := url.Parse(c.BaseURL) + u.Path = path + + req, err := http.NewRequest(method, u.String(), body) + if err != nil { + return nil, err } - req.Header.Set("X-Honeycomb-Team", c.APIKey) + bearer, err := c.bearerFromManagementKey() + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+bearer) + req.Header.Set("Accept", "application/vnd.api+json") + req.Header.Set("Content-Type", "application/vnd.api+json") + return req, nil +} + +func (c *Client) do(req *http.Request) ([]byte, int, error) { resp, err := c.http.Do(req) if err != nil { - return fmt.Errorf("failed to validate api key: %w", err) + return nil, 0, err } defer resp.Body.Close() - if resp.StatusCode == http.StatusOK { + b, _ := io.ReadAll(resp.Body) + return b, resp.StatusCode, nil +} + +func (c *Client) ValidateManagementKey(teamSlug string) error { + teamSlug = strings.TrimSpace(teamSlug) + if teamSlug == "" { + return fmt.Errorf("teamSlug is required") + } + + req, err := c.newReqV2( + http.MethodGet, + fmt.Sprintf("/2/teams/%s/environments", url.PathEscape(teamSlug)), + nil, + ) + if err != nil { + return err + } + + body, code, err := c.do(req) + if err != nil { + return fmt.Errorf("failed to validate management key: %w", err) + } + + switch code { + case http.StatusOK: return nil + case http.StatusUnauthorized: + return fmt.Errorf("invalid management key (401): check keyID:secret and that you're using the correct site (US vs EU). body=%s", string(body)) + case http.StatusForbidden: + return fmt.Errorf("management key forbidden (403): missing permissions/scopes. body=%s", string(body)) + default: + return fmt.Errorf("management key validation failed (http %d): %s", code, string(body)) + } +} + +func (c *Client) pingV1WithConfigKey() (int, []byte, error) { + req, err := c.newReqV1(http.MethodGet, "/1/auth", nil) + if err != nil { + return 0, nil, err + } + + b, code, err := c.do(req) + return code, b, err +} + +type listEnvironmentsResponse struct { + Data []struct { + ID string `json:"id"` + Type string `json:"type"` + Attributes struct { + Name string `json:"name"` + Slug string `json:"slug"` + } `json:"attributes"` + } `json:"data"` +} + +func (c *Client) getEnvironmentID(teamSlug, envSlug string) (string, error) { + envSlug = strings.TrimSpace(envSlug) + if envSlug == "" { + return "", fmt.Errorf("environmentSlug is required") + } + + req, err := c.newReqV2(http.MethodGet, + fmt.Sprintf("/2/teams/%s/environments", url.PathEscape(teamSlug)), + nil, + ) + if err != nil { + return "", err + } + + body, code, err := c.do(req) + if err != nil { + return "", err + } + if code < 200 || code >= 300 { + return "", fmt.Errorf("list environments failed (http %d): %s", code, string(body)) } - if resp.StatusCode == http.StatusUnauthorized { - return fmt.Errorf("invalid api key") + var parsed listEnvironmentsResponse + if err := json.Unmarshal(body, &parsed); err != nil { + return "", fmt.Errorf("failed to parse environments: %w", err) } - if resp.StatusCode == http.StatusForbidden { - return fmt.Errorf("api key is valid, but does not have permission for this account/team") + for _, e := range parsed.Data { + if strings.EqualFold(strings.TrimSpace(e.Attributes.Slug), envSlug) { + id := strings.TrimSpace(e.ID) + if id != "" { + return id, nil + } + } } - return fmt.Errorf("honeycomb authentication failed (http %d)", resp.StatusCode) + return "", fmt.Errorf("environmentSlug %q not found in team %q", envSlug, teamSlug) +} + +// EnsureConfigurationKey creates a configuration API key via the /2 API and stores +// its secret for use in /1 API requests. If a valid key already exists, it is reused. +func (c *Client) EnsureConfigurationKey(teamSlug string) error { + teamSlug = strings.TrimSpace(teamSlug) + if teamSlug == "" { + return fmt.Errorf("teamSlug is required") + } + + if c.hasSecret(secretNameConfigurationKey) { + code, body, err := c.pingV1WithConfigKey() + if err == nil && code >= 200 && code < 300 { + return nil + } + + if code == http.StatusUnauthorized || code == http.StatusForbidden { + _ = c.integrationCtx.SetSecret(secretNameConfigurationKey, []byte{}) + } else { + return fmt.Errorf("existing configuration key failed v1 ping (http %d): %s", code, string(body)) + } + } + + envSlugAny, err := c.integrationCtx.GetConfig("environmentSlug") + if err != nil || strings.TrimSpace(string(envSlugAny)) == "" { + return fmt.Errorf("environmentSlug is required") + } + envSlug := strings.TrimSpace(string(envSlugAny)) + + envID, err := c.getEnvironmentID(teamSlug, envSlug) + if err != nil { + return fmt.Errorf("failed to resolve environment ID for slug %q: %w", envSlug, err) + } + + payload := map[string]any{ + "data": map[string]any{ + "type": "api-keys", + "attributes": map[string]any{ + "key_type": "configuration", + "name": "SuperPlane Configuration Key", + "disabled": false, + "permissions": map[string]any{ + "manage_triggers": true, + "manage_recipients": true, + "send_events": false, + }, + }, + "relationships": map[string]any{ + "environment": map[string]any{ + "data": map[string]any{ + "id": envID, + "type": "environments", + }, + }, + }, + }, + } + + body, _ := json.Marshal(payload) + + req, err := c.newReqV2( + http.MethodPost, + fmt.Sprintf("/2/teams/%s/api-keys", url.PathEscape(teamSlug)), + bytes.NewReader(body), + ) + if err != nil { + return err + } + + respBody, code, err := c.do(req) + if err != nil { + return err + } + if code < 200 || code >= 300 { + return fmt.Errorf("create configuration key failed (http %d): %s", code, string(respBody)) + } + + keySecret, err := parseCreatedEnvKeyValue(respBody) + if err != nil { + return err + } + + if err := c.integrationCtx.SetSecret(secretNameConfigurationKey, []byte(keySecret)); err != nil { + return fmt.Errorf("failed to store configuration key: %w", err) + } + + code2, body2, err2 := c.pingV1WithConfigKey() + if err2 != nil { + return fmt.Errorf("v1 ping failed after creating config key: %w", err2) + } + if code2 < 200 || code2 >= 300 { + return fmt.Errorf("created configuration key but v1 ping failed (http %d): %s", code2, string(body2)) + } + + return nil +} + +// EnsureIngestKey creates an ingest API key via the /2 API and stores it for use +// when sending events. If a valid key already exists, it is reused. +func (c *Client) EnsureIngestKey(teamSlug string) error { + if c.hasSecret(secretNameIngestKey) { + return nil + } + + teamSlug = strings.TrimSpace(teamSlug) + if teamSlug == "" { + return fmt.Errorf("teamSlug is required") + } + + envSlugAny, err := c.integrationCtx.GetConfig("environmentSlug") + if err != nil || strings.TrimSpace(string(envSlugAny)) == "" { + return fmt.Errorf("environmentSlug is required") + } + envSlug := strings.TrimSpace(string(envSlugAny)) + + envID, err := c.getEnvironmentID(teamSlug, envSlug) + if err != nil { + return fmt.Errorf("failed to resolve environment ID for slug %q: %w", envSlug, err) + } + + payload := map[string]any{ + "data": map[string]any{ + "type": "api-keys", + "attributes": map[string]any{ + "key_type": "ingest", + "name": "SuperPlane Ingest Key", + "disabled": false, + "permissions": map[string]any{ + "create_datasets": true, + }, + }, + "relationships": map[string]any{ + "environment": map[string]any{ + "data": map[string]any{ + "id": envID, + "type": "environments", + }, + }, + }, + }, + } + + body, _ := json.Marshal(payload) + + req, err := c.newReqV2( + http.MethodPost, + fmt.Sprintf("/2/teams/%s/api-keys", url.PathEscape(teamSlug)), + bytes.NewReader(body), + ) + if err != nil { + return err + } + + respBody, code, err := c.do(req) + if err != nil { + return err + } + if code < 200 || code >= 300 { + return fmt.Errorf("create ingest key failed (http %d): %s", code, string(respBody)) + } + + keyValue, err := parseCreatedIngestKeyValue(respBody) + if err != nil { + return err + } + + if err := c.integrationCtx.SetSecret(secretNameIngestKey, []byte(keyValue)); err != nil { + return fmt.Errorf("failed to store ingest key secret: %w", err) + } + + return nil +} + +type HoneycombTrigger struct { + ID string `json:"id"` + Name string `json:"name"` + Raw map[string]any `json:"-"` +} + +func (c *Client) ListTriggers(datasetSlug string) ([]HoneycombTrigger, error) { + req, err := c.newReqV1(http.MethodGet, fmt.Sprintf("/1/triggers/%s", url.PathEscape(datasetSlug)), nil) + if err != nil { + return nil, err + } + respBody, code, err := c.do(req) + if err != nil { + return nil, err + } + if code < 200 || code >= 300 { + return nil, fmt.Errorf("list triggers failed (http %d): %s", code, string(respBody)) + } + + var arr []map[string]any + if err := json.Unmarshal(respBody, &arr); err != nil { + return nil, fmt.Errorf("failed to parse triggers list: %w", err) + } + + out := make([]HoneycombTrigger, 0, len(arr)) + for _, m := range arr { + id, _ := m["id"].(string) + name, _ := m["name"].(string) + out = append(out, HoneycombTrigger{ID: id, Name: name, Raw: m}) + } + return out, nil +} + +func (c *Client) GetTrigger(datasetSlug, triggerID string) (map[string]any, error) { + req, err := c.newReqV1(http.MethodGet, fmt.Sprintf("/1/triggers/%s/%s", url.PathEscape(datasetSlug), url.PathEscape(triggerID)), nil) + if err != nil { + return nil, err + } + respBody, code, err := c.do(req) + if err != nil { + return nil, err + } + if code < 200 || code >= 300 { + return nil, fmt.Errorf("get trigger failed (http %d): %s", code, string(respBody)) + } + + var obj map[string]any + if err := json.Unmarshal(respBody, &obj); err != nil { + return nil, fmt.Errorf("failed to parse trigger: %w", err) + } + return obj, nil +} + +func (c *Client) UpdateTrigger(datasetSlug, triggerID string, trigger map[string]any) error { + body, _ := json.Marshal(trigger) + req, err := c.newReqV1(http.MethodPut, fmt.Sprintf("/1/triggers/%s/%s", url.PathEscape(datasetSlug), url.PathEscape(triggerID)), bytes.NewReader(body)) + if err != nil { + return err + } + respBody, code, err := c.do(req) + if err != nil { + return err + } + if code < 200 || code >= 300 { + return fmt.Errorf("update trigger failed (http %d): %s", code, string(respBody)) + } + return nil +} + +// EnsureRecipientOnTrigger attaches a webhook recipient to a Honeycomb trigger if not already attached. +func (c *Client) EnsureRecipientOnTrigger(datasetSlug, triggerID, recipientID string) error { + trigger, err := c.GetTrigger(datasetSlug, triggerID) + if err != nil { + return err + } + + recipientsAny, ok := trigger["recipients"] + if !ok || recipientsAny == nil { + recipientsAny = []any{} + } + recipientsSlice, _ := recipientsAny.([]any) + + for _, r := range recipientsSlice { + if rm, ok := r.(map[string]any); ok { + if id, _ := rm["id"].(string); strings.TrimSpace(id) == recipientID { + return nil // already attached + } + } + } + + recipientsSlice = append(recipientsSlice, map[string]any{ + "id": recipientID, + "type": "webhook", + "target": "SuperPlane", + }) + trigger["recipients"] = recipientsSlice + + // Honeycomb rejects requests with both query and query_id + if _, hasQueryID := trigger["query_id"]; hasQueryID { + delete(trigger, "query") + } + + // Remove read-only fields rejected by Honeycomb + delete(trigger, "id") + delete(trigger, "dataset_slug") + delete(trigger, "created_at") + delete(trigger, "updated_at") + delete(trigger, "triggered") + + return c.UpdateTrigger(datasetSlug, triggerID, trigger) +} + +type Recipient struct { + ID string `json:"id"` + Type string `json:"type"` + Target string `json:"target"` + Details map[string]any `json:"details,omitempty"` +} + +func (c *Client) CreateWebhookRecipient(webhookURL, secret string) (Recipient, error) { + payload := map[string]any{ + "type": "webhook", + "details": map[string]any{ + "webhook_name": "SuperPlane", + "webhook_url": webhookURL, + "webhook_secret": secret, + }, + } + + body, _ := json.Marshal(payload) + req, err := c.newReqV1(http.MethodPost, "/1/recipients", bytes.NewReader(body)) + if err != nil { + return Recipient{}, err + } + + respBody, code, err := c.do(req) + if err != nil { + return Recipient{}, err + } + + if code == http.StatusConflict { + return Recipient{}, fmt.Errorf("recipient already exists in Honeycomb but cannot be retrieved. Delete old SuperPlane recipients in Honeycomb UI under Team Settings > Recipients, then retry") + } + if code < 200 || code >= 300 { + return Recipient{}, fmt.Errorf("create recipient failed (http %d): %s", code, string(respBody)) + } + + var obj map[string]any + if err := json.Unmarshal(respBody, &obj); err != nil { + return Recipient{}, fmt.Errorf("failed to parse recipient response: %w", err) + } + + id, _ := obj["id"].(string) + typ, _ := obj["type"].(string) + details, _ := obj["details"].(map[string]any) + + return Recipient{ID: id, Type: typ, Target: webhookURL, Details: details}, nil +} + +func (c *Client) DeleteRecipient(recipientID string, datasetSlug string) error { + // First, remove the recipient from all associated triggers + req, err := c.newReqV1(http.MethodGet, fmt.Sprintf("/1/recipients/%s/triggers", url.PathEscape(recipientID)), nil) + if err != nil { + return err + } + body, code, err := c.do(req) + if err != nil { + return err + } + if code == http.StatusOK { + var triggers []map[string]any + if json.Unmarshal(body, &triggers) == nil { + for _, tr := range triggers { + triggerID, _ := tr["id"].(string) + if datasetSlug != "" && triggerID != "" { + if err := c.RemoveRecipientFromTrigger(datasetSlug, triggerID, recipientID); err != nil { + return err + } + } + } + } + } + + req, err = c.newReqV1(http.MethodDelete, fmt.Sprintf("/1/recipients/%s", url.PathEscape(recipientID)), nil) + if err != nil { + return err + } + _, code, err = c.do(req) + if err != nil { + return err + } + if code == http.StatusNotFound { + return nil + } + if code < 200 || code >= 300 { + return fmt.Errorf("delete recipient failed (http %d)", code) + } + return nil +} + +func (c *Client) RemoveRecipientFromTrigger(datasetSlug, triggerID, recipientID string) error { + trigger, err := c.GetTrigger(datasetSlug, triggerID) + if err != nil { + return err + } + + recipientsAny, _ := trigger["recipients"].([]any) + filtered := make([]any, 0) + for _, r := range recipientsAny { + if rm, ok := r.(map[string]any); ok { + if id, _ := rm["id"].(string); id != recipientID { + filtered = append(filtered, rm) + } + } + } + trigger["recipients"] = filtered + + if _, hasQueryID := trigger["query_id"]; hasQueryID { + delete(trigger, "query") + } + delete(trigger, "id") + delete(trigger, "dataset_slug") + delete(trigger, "created_at") + delete(trigger, "updated_at") + delete(trigger, "triggered") + + return c.UpdateTrigger(datasetSlug, triggerID, trigger) } func (c *Client) CreateEvent(datasetSlug string, fields map[string]any) error { @@ -90,6 +641,11 @@ func (c *Client) CreateEvent(datasetSlug string, fields map[string]any) error { return fmt.Errorf("dataset is required") } + ingestHeader, err := c.getIngestHeaderValue() + if err != nil { + return err + } + u, _ := url.Parse(c.BaseURL) u.Path = fmt.Sprintf("/1/events/%s", url.PathEscape(datasetSlug)) @@ -103,13 +659,11 @@ func (c *Client) CreateEvent(datasetSlug string, fields map[string]any) error { return fmt.Errorf("failed to create request: %w", err) } - req.Header.Set("X-Honeycomb-Team", c.APIKey) + req.Header.Set("X-Honeycomb-Team", ingestHeader) req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") - // Set event timestamp header only if "time" field is not provided in the fields map. - // Honeycomb uses this header as the authoritative event timestamp, so we only set it - // when the user hasn't provided their own timestamp. Use RFC3339Nano in UTC to match - // Honeycomb's expected format (same as libhoney-go). + // If the event does not include a time field, set it automatically if _, hasTimeField := fields["time"]; !hasTimeField { req.Header.Set("X-Honeycomb-Event-Time", time.Now().UTC().Format(time.RFC3339Nano)) } @@ -127,3 +681,108 @@ func (c *Client) CreateEvent(datasetSlug string, fields map[string]any) error { b, _ := io.ReadAll(resp.Body) return fmt.Errorf("honeycomb create event failed (status %d): %s", resp.StatusCode, string(b)) } + +func (c *Client) getIngestHeaderValue() (string, error) { + secrets, err := c.integrationCtx.GetSecrets() + if err != nil { + return "", err + } + for _, s := range secrets { + if s.Name == secretNameIngestKey { + v := strings.TrimSpace(string(s.Value)) + if v != "" { + return v, nil + } + } + } + return "", fmt.Errorf("ingest key not found (expected secret %q)", secretNameIngestKey) +} + +func (c *Client) getSecretValue(name string) (string, error) { + secrets, err := c.integrationCtx.GetSecrets() + if err != nil { + return "", err + } + for _, s := range secrets { + if s.Name == name { + v := strings.TrimSpace(string(s.Value)) + if v != "" { + return v, nil + } + } + } + return "", fmt.Errorf("secret %q not found", name) +} + +func (c *Client) hasSecret(name string) bool { + secrets, err := c.integrationCtx.GetSecrets() + if err != nil { + return false + } + for _, s := range secrets { + if s.Name == name && strings.TrimSpace(string(s.Value)) != "" { + return true + } + } + return false +} + +func generateTokenHex(nBytes int) (string, error) { + b := make([]byte, nBytes) + if _, err := rand.Read(b); err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +func parseCreatedIngestKeyValue(respBody []byte) (string, error) { + type createKeyResp struct { + Data struct { + ID string `json:"id"` + Attributes struct { + Secret string `json:"secret"` + } `json:"attributes"` + } `json:"data"` + } + + var parsed createKeyResp + if err := json.Unmarshal(respBody, &parsed); err != nil { + return "", fmt.Errorf("failed to parse create key response: %w", err) + } + + id := strings.TrimSpace(parsed.Data.ID) + secret := strings.TrimSpace(parsed.Data.Attributes.Secret) + + if id == "" { + return "", fmt.Errorf("create key response missing data.id") + } + if secret == "" { + return "", fmt.Errorf("create key response missing data.attributes.secret") + } + + // Ingest key value is ID concatenated with secret + return id + secret, nil +} + +func parseCreatedEnvKeyValue(respBody []byte) (string, error) { + type createKeyResp struct { + Data struct { + ID string `json:"id"` + Attributes struct { + Secret string `json:"secret"` + } `json:"attributes"` + } `json:"data"` + } + + var parsed createKeyResp + if err := json.Unmarshal(respBody, &parsed); err != nil { + return "", fmt.Errorf("failed to parse create key response: %w", err) + } + + secret := strings.TrimSpace(parsed.Data.Attributes.Secret) + if secret == "" { + return "", fmt.Errorf("create key response missing data.attributes.secret: %s", string(respBody)) + } + + return secret, nil +} diff --git a/pkg/integrations/honeycomb/create_event.go b/pkg/integrations/honeycomb/create_event.go index 34fbedc2d5..ac403a350e 100644 --- a/pkg/integrations/honeycomb/create_event.go +++ b/pkg/integrations/honeycomb/create_event.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "net/http" + "strings" "github.com/google/uuid" "github.com/mitchellh/mapstructure" @@ -15,8 +16,8 @@ import ( type CreateEvent struct{} type CreateEventConfiguration struct { - Dataset string `json:"dataset" mapstructure:"dataset"` - FieldsJSON string `json:"fields" mapstructure:"fields"` + Dataset string `json:"dataset" mapstructure:"dataset"` + Fields string `json:"fields" mapstructure:"fields"` } func (c *CreateEvent) Name() string { @@ -28,17 +29,7 @@ func (c *CreateEvent) Label() string { } func (c *CreateEvent) Description() string { - return "Send an event to Honeycomb" -} - -func (c *CreateEvent) Documentation() string { - return `Sends a custom event to Honeycomb using the Events API. - -The component sends a single event as a JSON object where each key becomes a Honeycomb field. - -Notes: -- The request body is the JSON object you provide in "Fields". -- If you do not include a "time" field, the current time is automatically set via request header.` + return "Send an event to Honeycomb dataset" } func (c *CreateEvent) Icon() string { @@ -49,6 +40,19 @@ func (c *CreateEvent) Color() string { return "gray" } +func (c *CreateEvent) Documentation() string { + return ` +Sends a JSON event to a Honeycomb dataset. + +Each key in the JSON object becomes a Honeycomb field. + +Notes: +• Dataset must exist +• Fields must be valid JSON object +• Timestamp is auto-added if missing +` +} + func (c *CreateEvent) OutputChannels(configuration any) []core.OutputChannel { return []core.OutputChannel{core.DefaultOutputChannel} } @@ -60,33 +64,38 @@ func (c *CreateEvent) Configuration() []configuration.Field { Label: "Dataset", Type: configuration.FieldTypeString, Required: true, - Description: "Honeycomb dataset slug.", + Description: "Dataset slug", }, { - Name: "fields", - Label: "Fields (JSON)", - Type: configuration.FieldTypeText, - Required: true, - Description: `JSON object of fields to send, e.g. {"message":"hello","severity":"info"}`, + Name: "fields", + Label: "Fields JSON", + Type: configuration.FieldTypeText, + Required: true, + Description: `JSON object to send as event. + Example: + {"message":"deploy","status":"ok"}`, }, } } func (c *CreateEvent) Setup(ctx core.SetupContext) error { + var cfg CreateEventConfiguration if err := mapstructure.Decode(ctx.Configuration, &cfg); err != nil { - return fmt.Errorf("failed to decode configuration: %w", err) + return fmt.Errorf("invalid configuration: %w", err) } + cfg.Dataset = strings.TrimSpace(cfg.Dataset) if cfg.Dataset == "" { return errors.New("dataset is required") } - if cfg.FieldsJSON == "" { - return errors.New("fields is required") + + if strings.TrimSpace(cfg.Fields) == "" { + return errors.New("fields json is required") } var tmp map[string]any - if err := json.Unmarshal([]byte(cfg.FieldsJSON), &tmp); err != nil { + if err := json.Unmarshal([]byte(cfg.Fields), &tmp); err != nil { return fmt.Errorf("invalid fields json: %w", err) } @@ -98,33 +107,27 @@ func (c *CreateEvent) ProcessQueueItem(ctx core.ProcessQueueContext) (*uuid.UUID } func (c *CreateEvent) Execute(ctx core.ExecutionContext) error { + var cfg CreateEventConfiguration if err := mapstructure.Decode(ctx.Configuration, &cfg); err != nil { - return fmt.Errorf("failed to decode configuration: %w", err) - } - if cfg.Dataset == "" { - return errors.New("dataset is required") - } - if cfg.FieldsJSON == "" { - return errors.New("fields is required") + return err } var fields map[string]any - if err := json.Unmarshal([]byte(cfg.FieldsJSON), &fields); err != nil { + if err := json.Unmarshal([]byte(cfg.Fields), &fields); err != nil { return fmt.Errorf("invalid fields json: %w", err) } client, err := NewClient(ctx.HTTP, ctx.Integration) if err != nil { - return fmt.Errorf("failed to create honeycomb client: %w", err) + return err } if err := client.CreateEvent(cfg.Dataset, fields); err != nil { return err } - // Emit payload aligned with UI expectations + examples. - payload := map[string]any{ + output := map[string]any{ "status": "sent", "dataset": cfg.Dataset, "fields": fields, @@ -133,13 +136,14 @@ func (c *CreateEvent) Execute(ctx core.ExecutionContext) error { return ctx.ExecutionState.Emit( core.DefaultOutputChannel.Name, "honeycomb.event.created", - []any{payload}, + []any{output}, ) } func (c *CreateEvent) HandleWebhook(ctx core.WebhookRequestContext) (int, error) { return http.StatusOK, nil } + func (c *CreateEvent) Actions() []core.Action { return []core.Action{} } diff --git a/pkg/integrations/honeycomb/create_event_test.go b/pkg/integrations/honeycomb/create_event_test.go index dbd1a6e47f..df149a0a47 100644 --- a/pkg/integrations/honeycomb/create_event_test.go +++ b/pkg/integrations/honeycomb/create_event_test.go @@ -25,6 +25,16 @@ func Test__CreateEvent__Setup(t *testing.T) { require.ErrorContains(t, err, "dataset is required") }) + t.Run("missing fields -> error", func(t *testing.T) { + err := component.Setup(core.SetupContext{ + Configuration: map[string]any{ + "dataset": "test-dataset", + "fields": "", + }, + }) + require.ErrorContains(t, err, "fields json is required") + }) + t.Run("invalid JSON fields -> error", func(t *testing.T) { err := component.Setup(core.SetupContext{ Configuration: map[string]any{ @@ -49,9 +59,11 @@ func Test__CreateEvent__Setup(t *testing.T) { func Test__CreateEvent__Execute(t *testing.T) { component := &CreateEvent{} - t.Run("missing API key -> error", func(t *testing.T) { + t.Run("missing managementKey -> error", func(t *testing.T) { integrationCtx := &contexts.IntegrationContext{ - Configuration: map[string]any{}, + Configuration: map[string]any{ + "site": "api.honeycomb.io", + }, } err := component.Execute(core.ExecutionContext{ @@ -63,7 +75,60 @@ func Test__CreateEvent__Execute(t *testing.T) { HTTP: &contexts.HTTPContext{}, }) - require.ErrorContains(t, err, "api key is required") + require.ErrorContains(t, err, "managementKey is required") + }) + + t.Run("missing ingest key secret -> error", func(t *testing.T) { + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "managementKey": "keyid:secret", + "site": "api.honeycomb.io", + }, + Secrets: map[string]core.IntegrationSecret{}, + } + + err := component.Execute(core.ExecutionContext{ + Integration: integrationCtx, + HTTP: &contexts.HTTPContext{}, + Configuration: map[string]any{ + "dataset": "test-dataset", + "fields": `{"key":"value"}`, + }, + }) + + require.ErrorContains(t, err, "ingest key not found") + }) + + t.Run("API returns error -> Execute fails", func(t *testing.T) { + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusUnauthorized, + Body: io.NopCloser(strings.NewReader(`{"error":"unauthorized"}`)), + }, + }, + } + + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "managementKey": "keyid:secret", + "site": "api.honeycomb.io", + }, + Secrets: map[string]core.IntegrationSecret{ + secretNameIngestKey: {Name: secretNameIngestKey, Value: []byte("test-ingest-key")}, + }, + } + + err := component.Execute(core.ExecutionContext{ + Integration: integrationCtx, + HTTP: httpCtx, + Configuration: map[string]any{ + "dataset": "test-dataset", + "fields": `{"key":"value"}`, + }, + }) + + require.ErrorContains(t, err, "401") }) t.Run("successful event creation without time field -> emits payload and sets header", func(t *testing.T) { @@ -78,8 +143,11 @@ func Test__CreateEvent__Execute(t *testing.T) { integrationCtx := &contexts.IntegrationContext{ Configuration: map[string]any{ - "apiKey": "test-api-key", - "site": "api.honeycomb.io", + "managementKey": "keyid:secret", + "site": "api.honeycomb.io", + }, + Secrets: map[string]core.IntegrationSecret{ + secretNameIngestKey: {Name: secretNameIngestKey, Value: []byte("test-ingest-key")}, }, } @@ -103,17 +171,15 @@ func Test__CreateEvent__Execute(t *testing.T) { req := httpCtx.Requests[0] assert.Equal(t, http.MethodPost, req.Method) assert.Contains(t, req.URL.String(), "https://api.honeycomb.io/1/events/test-dataset") - assert.Equal(t, "test-api-key", req.Header.Get("X-Honeycomb-Team")) + assert.Equal(t, "test-ingest-key", req.Header.Get("X-Honeycomb-Team")) assert.Equal(t, "application/json", req.Header.Get("Content-Type")) bodyBytes, _ := io.ReadAll(req.Body) bodyStr := strings.TrimSpace(string(bodyBytes)) - assert.True(t, strings.HasPrefix(bodyStr, "{"), "payload should be a JSON object") assert.Contains(t, bodyStr, `"message":"deployment"`) assert.Contains(t, bodyStr, `"version":"1.2.3"`) - // When time field is not provided, header should be set automatically assert.NotEmpty(t, req.Header.Get("X-Honeycomb-Event-Time"), "event time header should be set when time field is not provided") }) @@ -129,8 +195,11 @@ func Test__CreateEvent__Execute(t *testing.T) { integrationCtx := &contexts.IntegrationContext{ Configuration: map[string]any{ - "apiKey": "test-api-key", - "site": "api.honeycomb.io", + "managementKey": "keyid:secret", + "site": "api.honeycomb.io", + }, + Secrets: map[string]core.IntegrationSecret{ + secretNameIngestKey: {Name: secretNameIngestKey, Value: []byte("test-ingest-key")}, }, } @@ -154,18 +223,16 @@ func Test__CreateEvent__Execute(t *testing.T) { req := httpCtx.Requests[0] assert.Equal(t, http.MethodPost, req.Method) assert.Contains(t, req.URL.String(), "https://api.honeycomb.io/1/events/test-dataset") - assert.Equal(t, "test-api-key", req.Header.Get("X-Honeycomb-Team")) + assert.Equal(t, "test-ingest-key", req.Header.Get("X-Honeycomb-Team")) assert.Equal(t, "application/json", req.Header.Get("Content-Type")) bodyBytes, _ := io.ReadAll(req.Body) bodyStr := strings.TrimSpace(string(bodyBytes)) - assert.True(t, strings.HasPrefix(bodyStr, "{"), "payload should be a JSON object") assert.Contains(t, bodyStr, `"message":"deployment"`) assert.Contains(t, bodyStr, `"version":"1.2.3"`) assert.Contains(t, bodyStr, `"time":"2024-01-15T10:30:00Z"`) - // When time field is provided, header should NOT be set (user's timestamp should be used) assert.Empty(t, req.Header.Get("X-Honeycomb-Event-Time"), "event time header should not be set when time field is provided") }) } diff --git a/pkg/integrations/honeycomb/honeycomb.go b/pkg/integrations/honeycomb/honeycomb.go index 1c4e0705e9..f82af7fe3e 100644 --- a/pkg/integrations/honeycomb/honeycomb.go +++ b/pkg/integrations/honeycomb/honeycomb.go @@ -1,6 +1,10 @@ package honeycomb import ( + "fmt" + "strings" + + "github.com/mitchellh/mapstructure" "github.com/superplanehq/superplane/pkg/configuration" "github.com/superplanehq/superplane/pkg/core" "github.com/superplanehq/superplane/pkg/registry" @@ -12,6 +16,13 @@ func init() { type Honeycomb struct{} +type Configuration struct { + Site string `json:"site" mapstructure:"site"` + ManagementKey string `json:"managementKey" mapstructure:"managementKey"` + TeamSlug string `json:"teamSlug" mapstructure:"teamSlug"` + EnvironmentSlug string `json:"environmentSlug" mapstructure:"environmentSlug"` +} + func (h *Honeycomb) Name() string { return "honeycomb" } @@ -25,21 +36,20 @@ func (h *Honeycomb) Icon() string { } func (h *Honeycomb) Description() string { - return "Triggers and actions for Honeycomb" + return "Monitor observability alerts and send events to Honeycomb datasets" } func (h *Honeycomb) Instructions() string { return ` -Connect Honeycomb to Superplane using a Honeycomb API key. - -**Get your API key**: -Honeycomb → Account → Team Settings → API Keys → copy key → paste here. +Connect Honeycomb to SuperPlane using a Management Key. -**Trigger setup**: -After saving a Honeycomb trigger node, Superplane generates a Webhook URL and Shared Secret. -Add them in Honeycomb → Team Settings → Integrations → Webhooks. +**Required configuration:** +- **Site**: US (api.honeycomb.io) or EU (api.eu1.honeycomb.io) based on your account region. +- **Management Key**: Found in Honeycomb under Team Settings > API Keys. Must be in format :. +- **Team Slug**: Your team identifier, visible in the Honeycomb URL: honeycomb.io/. +- **Environment Slug**: The environment containing your datasets (e.g. "production"). Found under Team Settings > Environments. -Once configured, Honeycomb events will trigger your workflow automatically. +SuperPlane will automatically validate your credentials and manage all necessary Honeycomb resources — webhook recipients for triggers and ingest keys for actions — so no manual setup is required. ` } @@ -62,13 +72,27 @@ func (h *Honeycomb) Configuration() []configuration.Field { Description: "Select the Honeycomb API host for your account region.", }, { - Name: "apiKey", - Label: "API Key", + Name: "managementKey", + Label: "Management Key", Type: configuration.FieldTypeString, - Description: "Honeycomb API key used for actions (e.g. Create Event).", + Description: "Honeycomb Management key in format :.", Sensitive: true, Required: true, }, + { + Name: "teamSlug", + Label: "Team Slug", + Type: configuration.FieldTypeString, + Description: "Your team identifier, visible in the Honeycomb URL: honeycomb.io/.", + Required: true, + }, + { + Name: "environmentSlug", + Label: "Environment Slug", + Type: configuration.FieldTypeString, + Description: "The environment containing your datasets (e.g. \"production\"). Found under Team Settings > Environments.", + Required: true, + }, } } @@ -84,27 +108,58 @@ func (h *Honeycomb) Triggers() []core.Trigger { } } +func (h *Honeycomb) Actions() []core.Action { + return []core.Action{} +} + +func (h *Honeycomb) HandleAction(ctx core.IntegrationActionContext) error { + return nil +} + +func (h *Honeycomb) Cleanup(ctx core.IntegrationCleanupContext) error { + return nil +} + func (h *Honeycomb) Sync(ctx core.SyncContext) error { + cfg := Configuration{} + if err := mapstructure.Decode(ctx.Configuration, &cfg); err != nil { + return fmt.Errorf("failed to decode configuration: %w", err) + } + + if strings.TrimSpace(cfg.Site) == "" { + return fmt.Errorf("site is required") + } + + if strings.TrimSpace(cfg.ManagementKey) == "" { + return fmt.Errorf("managementKey is required") + } + + if strings.TrimSpace(cfg.TeamSlug) == "" { + return fmt.Errorf("teamSlug is required") + } + + if strings.TrimSpace(cfg.EnvironmentSlug) == "" { + return fmt.Errorf("environmentSlug is required") + } + client, err := NewClient(ctx.HTTP, ctx.Integration) if err != nil { return err } - if err := client.Validate(); err != nil { + + if err := client.ValidateManagementKey(cfg.TeamSlug); err != nil { return err } - ctx.Integration.Ready() - return nil -} -func (h *Honeycomb) Cleanup(ctx core.IntegrationCleanupContext) error { - return nil -} + if err := client.EnsureConfigurationKey(cfg.TeamSlug); err != nil { + return err + } -func (h *Honeycomb) Actions() []core.Action { - return []core.Action{} -} + if err := client.EnsureIngestKey(cfg.TeamSlug); err != nil { + return err + } -func (h *Honeycomb) HandleAction(ctx core.IntegrationActionContext) error { + ctx.Integration.Ready() return nil } diff --git a/pkg/integrations/honeycomb/honeycomb_test.go b/pkg/integrations/honeycomb/honeycomb_test.go index b61a326cd3..713cfb358d 100644 --- a/pkg/integrations/honeycomb/honeycomb_test.go +++ b/pkg/integrations/honeycomb/honeycomb_test.go @@ -1,110 +1,222 @@ package honeycomb import ( - "bytes" "io" "net/http" "strings" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/superplanehq/superplane/pkg/core" contexts "github.com/superplanehq/superplane/test/support/contexts" ) -type roundTripFunc func(*http.Request) (*http.Response, error) +func Test__Honeycomb__Sync(t *testing.T) { + h := &Honeycomb{} -func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { - return f(req) -} + t.Run("missing site -> error", func(t *testing.T) { + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "managementKey": "keyid:secret", + "teamSlug": "myteam", + "environmentSlug": "production", + }, + } + + err := h.Sync(core.SyncContext{ + Configuration: integrationCtx.Configuration, + Integration: integrationCtx, + HTTP: &contexts.HTTPContext{}, + }) -func withDefaultTransport(t *testing.T, rt roundTripFunc) { - t.Helper() - original := http.DefaultTransport - http.DefaultTransport = rt - t.Cleanup(func() { - http.DefaultTransport = original + require.ErrorContains(t, err, "site is required") }) -} -func jsonResponse(status int, body string) *http.Response { - return &http.Response{ - StatusCode: status, - Body: io.NopCloser(bytes.NewBufferString(body)), - Header: http.Header{"Content-Type": []string{"application/json"}}, - } -} + t.Run("missing managementKey -> error", func(t *testing.T) { + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "site": "api.honeycomb.io", + "teamSlug": "myteam", + "environmentSlug": "production", + }, + } -func Test__Honeycomb__Sync(t *testing.T) { - h := &Honeycomb{} + err := h.Sync(core.SyncContext{ + Configuration: integrationCtx.Configuration, + Integration: integrationCtx, + HTTP: &contexts.HTTPContext{}, + }) - t.Run("missing api key -> error", func(t *testing.T) { + require.ErrorContains(t, err, "managementKey is required") + }) + + t.Run("missing teamSlug -> error", func(t *testing.T) { integrationCtx := &contexts.IntegrationContext{ Configuration: map[string]any{ - "site": BaseURLUS, + "site": "api.honeycomb.io", + "managementKey": "keyid:secret", + "environmentSlug": "production", }, } err := h.Sync(core.SyncContext{ - Integration: integrationCtx, - HTTP: &contexts.HTTPContext{}, + Configuration: integrationCtx.Configuration, + Integration: integrationCtx, + HTTP: &contexts.HTTPContext{}, }) - require.Error(t, err) + require.ErrorContains(t, err, "teamSlug is required") }) - t.Run("valid key -> ready", func(t *testing.T) { - httpCtx := &contexts.HTTPContext{ - Responses: []*http.Response{ - { - StatusCode: http.StatusOK, - Body: io.NopCloser(strings.NewReader(`{"ok":true}`)), - }, + t.Run("missing environmentSlug -> error", func(t *testing.T) { + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "site": "api.honeycomb.io", + "managementKey": "keyid:secret", + "teamSlug": "myteam", }, } + err := h.Sync(core.SyncContext{ + Configuration: integrationCtx.Configuration, + Integration: integrationCtx, + HTTP: &contexts.HTTPContext{}, + }) + + require.ErrorContains(t, err, "environmentSlug is required") + }) + + t.Run("invalid managementKey format -> error", func(t *testing.T) { integrationCtx := &contexts.IntegrationContext{ Configuration: map[string]any{ - "site": BaseURLUS, - "apiKey": "test-api-key", + "site": "api.honeycomb.io", + "managementKey": "no-colon-here", + "teamSlug": "myteam", + "environmentSlug": "production", }, } err := h.Sync(core.SyncContext{ - Integration: integrationCtx, - HTTP: httpCtx, + Configuration: integrationCtx.Configuration, + Integration: integrationCtx, + HTTP: &contexts.HTTPContext{}, }) - require.NoError(t, err) - require.Equal(t, "ready", integrationCtx.State) - require.Len(t, httpCtx.Requests, 1) - require.Contains(t, httpCtx.Requests[0].URL.String(), BaseURLUS+"/1/auth") + require.ErrorContains(t, err, "managementKey must be in format :") }) - t.Run("invalid key -> error", func(t *testing.T) { + t.Run("API returns 401 -> error", func(t *testing.T) { + integrationCtx := &contexts.IntegrationContext{ + Configuration: map[string]any{ + "site": "api.honeycomb.io", + "managementKey": "keyid:secret", + "teamSlug": "myteam", + "environmentSlug": "production", + }, + } + httpCtx := &contexts.HTTPContext{ Responses: []*http.Response{ { StatusCode: http.StatusUnauthorized, - Body: io.NopCloser(strings.NewReader(`{"error":"Unknown API key"}`)), + Body: io.NopCloser(strings.NewReader(`{"error":"unauthorized"}`)), }, }, } + err := h.Sync(core.SyncContext{ + Configuration: integrationCtx.Configuration, + Integration: integrationCtx, + HTTP: httpCtx, + }) + + require.ErrorContains(t, err, "401") + assert.NotEqual(t, "ready", integrationCtx.State) + }) + + t.Run("successful sync -> integration is ready, secrets are stored", func(t *testing.T) { integrationCtx := &contexts.IntegrationContext{ Configuration: map[string]any{ - "site": BaseURLUS, - "apiKey": "bad-key", + "site": "api.honeycomb.io", + "managementKey": "keyid:secret", + "teamSlug": "myteam", + "environmentSlug": "production", + }, + Secrets: map[string]core.IntegrationSecret{}, + } + + environmentsBody := `{ + "data": [ + { + "id": "env-123", + "type": "environments", + "attributes": {"name": "Production", "slug": "production"} + } + ] + }` + + configKeyBody := `{ + "data": { + "id": "cfgkey-123", + "type": "api-keys", + "attributes": {"secret": "cfg-secret-value"} + } + }` + + ingestKeyBody := `{ + "data": { + "id": "ingestkey-id", + "type": "api-keys", + "attributes": {"secret": "ingest-secret-value"} + } + }` + + httpCtx := &contexts.HTTPContext{ + Responses: []*http.Response{ + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(environmentsBody)), + }, + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(environmentsBody)), + }, + { + StatusCode: http.StatusCreated, + Body: io.NopCloser(strings.NewReader(configKeyBody)), + }, + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"team":{"slug":"myteam"}}`)), + }, + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(environmentsBody)), + }, + { + StatusCode: http.StatusCreated, + Body: io.NopCloser(strings.NewReader(ingestKeyBody)), + }, }, } err := h.Sync(core.SyncContext{ - Integration: integrationCtx, - HTTP: httpCtx, + Configuration: integrationCtx.Configuration, + Integration: integrationCtx, + HTTP: httpCtx, }) - require.Error(t, err) - require.Contains(t, err.Error(), "invalid api key") - require.NotEqual(t, "ready", integrationCtx.State) + require.NoError(t, err) + assert.Equal(t, "ready", integrationCtx.State) + assert.Len(t, httpCtx.Requests, 6) + + cfgSecret, ok := integrationCtx.Secrets[secretNameConfigurationKey] + require.True(t, ok) + assert.Equal(t, []byte("cfg-secret-value"), cfgSecret.Value) + + ingestSecret, ok := integrationCtx.Secrets[secretNameIngestKey] + require.True(t, ok) + assert.Equal(t, []byte("ingestkey-idingest-secret-value"), ingestSecret.Value) }) } diff --git a/pkg/integrations/honeycomb/on_alert_fired.go b/pkg/integrations/honeycomb/on_alert_fired.go index 8850e7ba6e..4280bb6bc1 100644 --- a/pkg/integrations/honeycomb/on_alert_fired.go +++ b/pkg/integrations/honeycomb/on_alert_fired.go @@ -15,12 +15,12 @@ import ( type OnAlertFired struct{} type OnAlertFiredConfiguration struct { - AlertName string `json:"alertName" mapstructure:"alertName"` + DatasetSlug string `json:"datasetSlug" mapstructure:"datasetSlug"` + AlertName string `json:"alertName" mapstructure:"alertName"` } -type OnAlertFiredMetadata struct { - WebhookURL string `json:"webhookUrl" mapstructure:"webhookUrl"` - SharedSecret string `json:"sharedSecret" mapstructure:"sharedSecret"` +type OnAlertFiredNodeMetadata struct { + TriggerID string `json:"triggerId"` } func (t *OnAlertFired) Name() string { @@ -32,7 +32,7 @@ func (t *OnAlertFired) Label() string { } func (t *OnAlertFired) Description() string { - return "Triggers when Honeycomb sends an alert webhook" + return "Triggers when a Honeycomb Trigger fires" } func (t *OnAlertFired) Icon() string { @@ -45,71 +45,93 @@ func (t *OnAlertFired) Color() string { func (t *OnAlertFired) Documentation() string { return ` -The On Alert Fired trigger starts a workflow execution when Honeycomb sends an alert webhook. +Starts a workflow execution when a Honeycomb Trigger fires. -Setup: -1) Add this trigger to a workflow -2) Optionally set Alert Name (used to filter which node fires) -3) SAVE the node -4) Copy Webhook URL and Shared Secret into Honeycomb webhook integration +**Configuration:** +- **Dataset Slug**: The slug of the dataset that contains your Honeycomb trigger. Found in the dataset URL: honeycomb.io//datasets/. +- **Alert Name**: The exact name of the Honeycomb trigger to listen to (case-insensitive). Found in your dataset under Triggers. + +**How it works:** +SuperPlane automatically creates a webhook recipient in Honeycomb and attaches it to the selected trigger. No manual webhook setup is required. + +When the trigger fires, SuperPlane receives the webhook and starts a workflow execution with the full alert payload. ` } func (t *OnAlertFired) Configuration() []configuration.Field { return []configuration.Field{ + { + Name: "datasetSlug", + Label: "Dataset Slug", + Type: configuration.FieldTypeString, + Required: true, + Description: "The dataset slug containing your Honeycomb trigger.", + }, { Name: "alertName", - Label: "Alert Name (optional)", + Label: "Alert Name", Type: configuration.FieldTypeString, - Required: false, - Description: "If set, only webhooks whose payload name matches will trigger this node.", + Required: true, + Description: "The name of the Honeycomb trigger to listen to (case-insensitive).", }, } } func (t *OnAlertFired) Setup(ctx core.TriggerContext) error { + cfg := OnAlertFiredConfiguration{} + if err := mapstructure.Decode(ctx.Configuration, &cfg); err != nil { + return fmt.Errorf("failed to decode configuration: %w", err) + } - if ctx.Webhook == nil { - if ctx.Integration == nil { - return nil - } - if err := ctx.Integration.RequestWebhook(map[string]any{}); err != nil { - return fmt.Errorf("failed to request webhook: %w", err) - } - return nil + cfg.DatasetSlug = strings.TrimSpace(cfg.DatasetSlug) + cfg.AlertName = strings.TrimSpace(cfg.AlertName) + + if cfg.DatasetSlug == "" { + return fmt.Errorf("datasetSlug is required") + } + if cfg.AlertName == "" { + return fmt.Errorf("alertName is required") } if ctx.Integration == nil { return nil } - secret, err := ctx.Webhook.GetSecret() + client, err := NewClient(ctx.HTTP, ctx.Integration) if err != nil { + return fmt.Errorf("failed to create client: %w", err) + } - if err := ctx.Integration.RequestWebhook(map[string]any{}); err != nil { - return fmt.Errorf("failed to request webhook: %w", err) - } - - secret, err = ctx.Webhook.GetSecret() - if err != nil { - return fmt.Errorf("failed to get webhook secret after request: %w", err) - } + teamAny, err := ctx.Integration.GetConfig("teamSlug") + if err == nil && strings.TrimSpace(string(teamAny)) != "" { + _ = client.EnsureConfigurationKey(strings.TrimSpace(string(teamAny))) } - url, err := ctx.Webhook.Setup() + triggers, err := client.ListTriggers(cfg.DatasetSlug) if err != nil { - return fmt.Errorf("failed to setup webhook url: %w", err) + return fmt.Errorf("failed to list triggers: %w", err) + } + + var triggerID string + for _, tr := range triggers { + if strings.EqualFold(strings.TrimSpace(tr.Name), cfg.AlertName) { + triggerID = tr.ID + break + } } - md := OnAlertFiredMetadata{ - WebhookURL: strings.TrimSpace(url), - SharedSecret: strings.TrimSpace(string(secret)), + if triggerID == "" { + return fmt.Errorf("trigger with name %q not found in dataset %q", cfg.AlertName, cfg.DatasetSlug) } - if err := ctx.Metadata.Set(md); err != nil { - return fmt.Errorf("failed to set node metadata: %w", err) + if err := ctx.Integration.RequestWebhook(map[string]any{ + "datasetSlug": cfg.DatasetSlug, + "triggerIds": []string{triggerID}, + }); err != nil { + return fmt.Errorf("failed to request webhook: %w", err) } + _ = ctx.Metadata.Set(OnAlertFiredNodeMetadata{TriggerID: triggerID}) return nil } @@ -126,50 +148,30 @@ func (t *OnAlertFired) Cleanup(ctx core.TriggerContext) error { } func (t *OnAlertFired) HandleWebhook(ctx core.WebhookRequestContext) (int, error) { - cfg := OnAlertFiredConfiguration{} if err := mapstructure.Decode(ctx.Configuration, &cfg); err != nil { - return http.StatusInternalServerError, fmt.Errorf("failed to decode configuration: %w", err) + return http.StatusInternalServerError, err } secretBytes, err := ctx.Webhook.GetSecret() if err != nil { - return http.StatusInternalServerError, fmt.Errorf("failed to get webhook secret: %w", err) + return http.StatusInternalServerError, err } secret := strings.TrimSpace(string(secretBytes)) - if secret == "" { - return http.StatusInternalServerError, fmt.Errorf("webhook secret is empty") - } provided := strings.TrimSpace(ctx.Headers.Get("X-Honeycomb-Webhook-Token")) - fromAuthorizationHeader := false - - // Fallbacks if provided == "" { - provided = strings.TrimSpace(ctx.Headers.Get("Authorization")) - fromAuthorizationHeader = provided != "" - } - - if provided == "" { - provided = strings.TrimSpace(ctx.Headers.Get("X-Shared-Secret")) + auth := strings.TrimSpace(ctx.Headers.Get("Authorization")) + if strings.HasPrefix(strings.ToLower(auth), "bearer ") { + provided = strings.TrimSpace(auth[len("bearer "):]) + } } if provided == "" { return http.StatusUnauthorized, fmt.Errorf("missing webhook token") } - if fromAuthorizationHeader && strings.HasPrefix(strings.ToLower(provided), "bearer ") { - provided = strings.TrimSpace(provided[len("bearer "):]) - } - - providedBytes := []byte(provided) - secretTokenBytes := []byte(secret) - - if len(providedBytes) != len(secretTokenBytes) { - subtle.ConstantTimeCompare(secretTokenBytes, secretTokenBytes) // dummy work - return http.StatusForbidden, fmt.Errorf("invalid webhook token") - } - if subtle.ConstantTimeCompare(providedBytes, secretTokenBytes) != 1 { + if subtle.ConstantTimeCompare([]byte(provided), []byte(secret)) != 1 { return http.StatusForbidden, fmt.Errorf("invalid webhook token") } @@ -178,28 +180,38 @@ func (t *OnAlertFired) HandleWebhook(ctx core.WebhookRequestContext) (int, error payload = map[string]any{"raw": string(ctx.Body)} } - want := strings.TrimSpace(cfg.AlertName) - if want != "" && !payloadHasAlertName(payload, want) { - return http.StatusOK, nil + meta := OnAlertFiredNodeMetadata{} + raw := ctx.Metadata.Get() + if err := mapstructure.Decode(raw, &meta); err == nil && meta.TriggerID != "" { + if !payloadHasTriggerID(payload, meta.TriggerID) { + return http.StatusOK, nil + } } if err := ctx.Events.Emit("honeycomb.alert.fired", payload); err != nil { - return http.StatusInternalServerError, fmt.Errorf("emit failed: %w", err) + return http.StatusInternalServerError, err } return http.StatusOK, nil } -func payloadHasAlertName(payload map[string]any, want string) bool { +func payloadHasTriggerID(payload map[string]any, want string) bool { want = strings.TrimSpace(want) + if want == "" { + return true + } + + if id, ok := payload["id"].(string); ok { + return strings.EqualFold(strings.TrimSpace(id), want) + } - if name, ok := payload["name"].(string); ok { - return strings.EqualFold(strings.TrimSpace(name), want) + if id, ok := payload["trigger_id"].(string); ok { + return strings.EqualFold(strings.TrimSpace(id), want) } - if alert, ok := payload["alert"].(map[string]any); ok { - if name, ok := alert["name"].(string); ok { - return strings.EqualFold(strings.TrimSpace(name), want) + if tr, ok := payload["trigger"].(map[string]any); ok { + if id, ok := tr["id"].(string); ok { + return strings.EqualFold(strings.TrimSpace(id), want) } } diff --git a/pkg/integrations/honeycomb/on_alert_fired_test.go b/pkg/integrations/honeycomb/on_alert_fired_test.go index b710b1f9c7..4fee4507db 100644 --- a/pkg/integrations/honeycomb/on_alert_fired_test.go +++ b/pkg/integrations/honeycomb/on_alert_fired_test.go @@ -4,6 +4,7 @@ import ( "net/http" "testing" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/superplanehq/superplane/pkg/core" contexts "github.com/superplanehq/superplane/test/support/contexts" @@ -12,23 +13,38 @@ import ( func Test__OnAlertFired__Setup(t *testing.T) { trigger := OnAlertFired{} - t.Run("requests shared webhook with empty config", func(t *testing.T) { - integrationCtx := &contexts.IntegrationContext{} - + t.Run("missing datasetSlug -> error", func(t *testing.T) { err := trigger.Setup(core.TriggerContext{ - Integration: integrationCtx, + Integration: &contexts.IntegrationContext{}, Metadata: &contexts.MetadataContext{}, Configuration: map[string]any{ "alertName": "High Error Rate", }, }) + require.ErrorContains(t, err, "datasetSlug is required") + }) - require.NoError(t, err) - require.Len(t, integrationCtx.WebhookRequests, 1) + t.Run("missing alertName -> error", func(t *testing.T) { + err := trigger.Setup(core.TriggerContext{ + Integration: &contexts.IntegrationContext{}, + Metadata: &contexts.MetadataContext{}, + Configuration: map[string]any{ + "datasetSlug": "production", + }, + }) + require.ErrorContains(t, err, "alertName is required") + }) - req, ok := integrationCtx.WebhookRequests[0].(map[string]any) - require.True(t, ok) - require.Len(t, req, 0) // empty config => shared webhook + t.Run("no integration -> returns nil without requesting webhook", func(t *testing.T) { + err := trigger.Setup(core.TriggerContext{ + Integration: nil, + Metadata: &contexts.MetadataContext{}, + Configuration: map[string]any{ + "datasetSlug": "production", + "alertName": "High Error Rate", + }, + }) + assert.NoError(t, err) }) } @@ -36,27 +52,28 @@ func Test__OnAlertFired__HandleWebhook(t *testing.T) { trigger := &OnAlertFired{} validConfig := map[string]any{ - "alertName": "High Error Rate", + "datasetSlug": "production", + "alertName": "High Error Rate", } - body := []byte(`{"alert":{"name":"High Error Rate"},"status":"firing"}`) + body := []byte(`{"id":"trigger-abc","name":"High Error Rate","status":"TRIGGERED"}`) - t.Run("missing token header -> 401", func(t *testing.T) { + t.Run("missing token -> 401", func(t *testing.T) { code, err := trigger.HandleWebhook(core.WebhookRequestContext{ Headers: http.Header{}, Body: body, Configuration: validConfig, Webhook: &contexts.WebhookContext{Secret: "test-secret"}, Events: &contexts.EventContext{}, + Metadata: &contexts.MetadataContext{}, }) - - require.Equal(t, http.StatusUnauthorized, code) - require.ErrorContains(t, err, "missing webhook token") + assert.Equal(t, http.StatusUnauthorized, code) + assert.ErrorContains(t, err, "missing webhook token") }) t.Run("invalid token -> 403", func(t *testing.T) { h := http.Header{} - h.Set("X-Honeycomb-Webhook-Token", "wrong-secret") + h.Set("X-Honeycomb-Webhook-Token", "wrong-secret-xx") code, err := trigger.HandleWebhook(core.WebhookRequestContext{ Headers: h, @@ -64,34 +81,37 @@ func Test__OnAlertFired__HandleWebhook(t *testing.T) { Configuration: validConfig, Webhook: &contexts.WebhookContext{Secret: "test-secret"}, Events: &contexts.EventContext{}, + Metadata: &contexts.MetadataContext{}, }) - - require.Equal(t, http.StatusForbidden, code) - require.ErrorContains(t, err, "invalid webhook token") + assert.Equal(t, http.StatusForbidden, code) + assert.ErrorContains(t, err, "invalid webhook token") }) - t.Run("valid token but alertName does not match -> no emit", func(t *testing.T) { + t.Run("invalid JSON body -> falls back to raw payload and emits", func(t *testing.T) { h := http.Header{} h.Set("X-Honeycomb-Webhook-Token", "test-secret") events := &contexts.EventContext{} code, err := trigger.HandleWebhook(core.WebhookRequestContext{ Headers: h, - Body: []byte(`{"alert":{"name":"Different"}}`), + Body: []byte(`not valid json`), Configuration: validConfig, Webhook: &contexts.WebhookContext{Secret: "test-secret"}, Events: events, + Metadata: &contexts.MetadataContext{}, }) - - require.Equal(t, http.StatusOK, code) - require.NoError(t, err) - require.Equal(t, 0, events.Count()) + assert.Equal(t, http.StatusOK, code) + assert.NoError(t, err) + assert.Equal(t, 1, events.Count()) }) - t.Run("valid token + match -> emits", func(t *testing.T) { + t.Run("valid token, triggerID matches -> emits", func(t *testing.T) { h := http.Header{} h.Set("X-Honeycomb-Webhook-Token", "test-secret") + meta := &contexts.MetadataContext{} + _ = meta.Set(OnAlertFiredNodeMetadata{TriggerID: "trigger-abc"}) + events := &contexts.EventContext{} code, err := trigger.HandleWebhook(core.WebhookRequestContext{ Headers: h, @@ -99,17 +119,20 @@ func Test__OnAlertFired__HandleWebhook(t *testing.T) { Configuration: validConfig, Webhook: &contexts.WebhookContext{Secret: "test-secret"}, Events: events, + Metadata: meta, }) - require.Equal(t, http.StatusOK, code) require.NoError(t, err) - require.Equal(t, 1, events.Count()) - require.Equal(t, "honeycomb.alert.fired", events.Payloads[0].Type) + assert.Equal(t, 1, events.Count()) + assert.Equal(t, "honeycomb.alert.fired", events.Payloads[0].Type) }) - t.Run("authorization bearer token works -> emits", func(t *testing.T) { + t.Run("valid token, triggerID does not match -> no emit", func(t *testing.T) { h := http.Header{} - h.Set("Authorization", "Bearer test-secret") + h.Set("X-Honeycomb-Webhook-Token", "test-secret") + + meta := &contexts.MetadataContext{} + _ = meta.Set(OnAlertFiredNodeMetadata{TriggerID: "different-trigger"}) events := &contexts.EventContext{} code, err := trigger.HandleWebhook(core.WebhookRequestContext{ @@ -118,47 +141,49 @@ func Test__OnAlertFired__HandleWebhook(t *testing.T) { Configuration: validConfig, Webhook: &contexts.WebhookContext{Secret: "test-secret"}, Events: events, + Metadata: meta, }) - - require.Equal(t, http.StatusOK, code) - require.NoError(t, err) - require.Equal(t, 1, events.Count()) + assert.Equal(t, http.StatusOK, code) + assert.NoError(t, err) + assert.Equal(t, 0, events.Count()) }) - t.Run("custom token header starting with bearer is not stripped -> emits", func(t *testing.T) { + t.Run("valid token, no metadata -> emits without filter", func(t *testing.T) { h := http.Header{} - h.Set("X-Honeycomb-Webhook-Token", "bearer test-secret") + h.Set("X-Honeycomb-Webhook-Token", "test-secret") events := &contexts.EventContext{} code, err := trigger.HandleWebhook(core.WebhookRequestContext{ Headers: h, Body: body, Configuration: validConfig, - Webhook: &contexts.WebhookContext{Secret: "bearer test-secret"}, + Webhook: &contexts.WebhookContext{Secret: "test-secret"}, Events: events, + Metadata: &contexts.MetadataContext{}, }) - - require.Equal(t, http.StatusOK, code) - require.NoError(t, err) - require.Equal(t, 1, events.Count()) + assert.Equal(t, http.StatusOK, code) + assert.NoError(t, err) + assert.Equal(t, 1, events.Count()) }) - t.Run("shared secret header starting with bearer is not stripped -> emits", func(t *testing.T) { + t.Run("bearer authorization header -> emits", func(t *testing.T) { h := http.Header{} - h.Set("X-Shared-Secret", "Bearer test-secret") + h.Set("Authorization", "Bearer test-secret") + + meta := &contexts.MetadataContext{} + _ = meta.Set(OnAlertFiredNodeMetadata{TriggerID: "trigger-abc"}) events := &contexts.EventContext{} code, err := trigger.HandleWebhook(core.WebhookRequestContext{ Headers: h, Body: body, Configuration: validConfig, - Webhook: &contexts.WebhookContext{Secret: "Bearer test-secret"}, + Webhook: &contexts.WebhookContext{Secret: "test-secret"}, Events: events, + Metadata: meta, }) - - require.Equal(t, http.StatusOK, code) - require.NoError(t, err) - require.Equal(t, 1, events.Count()) + assert.Equal(t, http.StatusOK, code) + assert.NoError(t, err) + assert.Equal(t, 1, events.Count()) }) - } diff --git a/pkg/integrations/honeycomb/webhook_handler.go b/pkg/integrations/honeycomb/webhook_handler.go index 4eee864766..c5310132ec 100644 --- a/pkg/integrations/honeycomb/webhook_handler.go +++ b/pkg/integrations/honeycomb/webhook_handler.go @@ -1,23 +1,156 @@ package honeycomb import ( + "fmt" + "slices" + "strings" + + "github.com/mitchellh/mapstructure" "github.com/superplanehq/superplane/pkg/core" ) +type WebhookConfiguration struct { + DatasetSlug string `json:"datasetSlug" mapstructure:"datasetSlug"` + TriggerIDs []string `json:"triggerIds" mapstructure:"triggerIds"` +} + +type WebhookMetadata struct { + RecipientID string `json:"recipientId" mapstructure:"recipientId"` +} + type HoneycombWebhookHandler struct{} +// Share webhook if dataset matches - Merge will union the trigger IDs func (h *HoneycombWebhookHandler) CompareConfig(a, b any) (bool, error) { - return true, nil + ca := WebhookConfiguration{} + cb := WebhookConfiguration{} + + if err := mapstructure.Decode(a, &ca); err != nil { + return false, err + } + if err := mapstructure.Decode(b, &cb); err != nil { + return false, err + } + + ca.DatasetSlug = strings.TrimSpace(ca.DatasetSlug) + cb.DatasetSlug = strings.TrimSpace(cb.DatasetSlug) + + if ca.DatasetSlug == "" || cb.DatasetSlug == "" { + return false, nil + } + + return ca.DatasetSlug == cb.DatasetSlug, nil } func (h *HoneycombWebhookHandler) Merge(current, requested any) (any, bool, error) { - return current, false, nil + cc := WebhookConfiguration{} + rc := WebhookConfiguration{} + + if err := mapstructure.Decode(current, &cc); err != nil { + return current, false, err + } + if err := mapstructure.Decode(requested, &rc); err != nil { + return current, false, err + } + + changed := false + + cc.DatasetSlug = strings.TrimSpace(cc.DatasetSlug) + rc.DatasetSlug = strings.TrimSpace(rc.DatasetSlug) + + if cc.DatasetSlug == "" && rc.DatasetSlug != "" { + cc.DatasetSlug = rc.DatasetSlug + changed = true + } + + for _, tid := range rc.TriggerIDs { + tid = strings.TrimSpace(tid) + if tid == "" { + continue + } + if !slices.Contains(cc.TriggerIDs, tid) { + cc.TriggerIDs = append(cc.TriggerIDs, tid) + changed = true + } + } + + return cc, changed, nil } func (h *HoneycombWebhookHandler) Setup(ctx core.WebhookHandlerContext) (any, error) { - return nil, nil -} + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return nil, err + } + + cfg := WebhookConfiguration{} + if err := mapstructure.Decode(ctx.Webhook.GetConfiguration(), &cfg); err != nil { + return nil, fmt.Errorf("error decoding webhook configuration: %w", err) + } + cfg.DatasetSlug = strings.TrimSpace(cfg.DatasetSlug) + if cfg.DatasetSlug == "" { + return nil, fmt.Errorf("datasetSlug is required for webhook") + } + + secretBytes, err := ctx.Webhook.GetSecret() + if err != nil || len(secretBytes) == 0 || strings.TrimSpace(string(secretBytes)) == "" { + token, genErr := generateTokenHex(24) + if genErr != nil { + return nil, fmt.Errorf("failed to generate webhook secret: %w", genErr) + } + if err := ctx.Webhook.SetSecret([]byte(token)); err != nil { + return nil, fmt.Errorf("failed to set webhook secret: %w", err) + } + secretBytes = []byte(token) + } + secret := strings.TrimSpace(string(secretBytes)) + webhookURL := strings.TrimSpace(ctx.Webhook.GetURL()) + if webhookURL == "" { + return nil, fmt.Errorf("webhook URL is empty") + } + + var recipientID string + existingMeta := WebhookMetadata{} + if err := mapstructure.Decode(ctx.Webhook.GetMetadata(), &existingMeta); err == nil && existingMeta.RecipientID != "" { + recipientID = existingMeta.RecipientID + } else { + recipient, err := client.CreateWebhookRecipient(webhookURL, secret) + if err != nil { + return nil, err + } + recipientID = recipient.ID + } + + for _, tid := range cfg.TriggerIDs { + tid = strings.TrimSpace(tid) + if tid == "" { + continue + } + if err := client.EnsureRecipientOnTrigger(cfg.DatasetSlug, tid, recipientID); err != nil { + return nil, fmt.Errorf("failed to attach recipient to trigger %s: %w", tid, err) + } + } + + return WebhookMetadata{RecipientID: recipientID}, nil +} func (h *HoneycombWebhookHandler) Cleanup(ctx core.WebhookHandlerContext) error { - return nil + client, err := NewClient(ctx.HTTP, ctx.Integration) + if err != nil { + return err + } + + meta := WebhookMetadata{} + if err := mapstructure.Decode(ctx.Webhook.GetMetadata(), &meta); err != nil { + return nil + } + if meta.RecipientID == "" { + return nil + } + + cfg := WebhookConfiguration{} + if err := mapstructure.Decode(ctx.Webhook.GetConfiguration(), &cfg); err != nil { + return nil + } + return client.DeleteRecipient(meta.RecipientID, cfg.DatasetSlug) } diff --git a/web_src/src/pages/workflowv2/mappers/honeycomb/create_event.ts b/web_src/src/pages/workflowv2/mappers/honeycomb/create_event.ts index ef082028a0..9d9a2e8cf7 100644 --- a/web_src/src/pages/workflowv2/mappers/honeycomb/create_event.ts +++ b/web_src/src/pages/workflowv2/mappers/honeycomb/create_event.ts @@ -16,7 +16,7 @@ import { formatTimeAgo } from "@/utils/date"; interface CreateEventConfiguration { dataset?: string; - fields?: string; // JSON string + fields?: Record; } type HoneycombCreateEventPayload = { @@ -74,6 +74,8 @@ function createEventMetadataList(node: NodeInfo): MetadataItem[] { if (configuration?.dataset) { metadata.push({ icon: "database", label: configuration.dataset }); + } else { + metadata.push({ icon: "database", label: "Uses integration dataset" }); } return metadata; @@ -88,7 +90,7 @@ function createEventSpecs(node: NodeInfo): ComponentBaseSpec[] { title: "fields", tooltipTitle: "fields", iconSlug: "braces", - value: configuration.fields, + value: safeJSONStringify(configuration.fields), contentType: "json", }); } @@ -118,4 +120,4 @@ function safeJSONStringify(value: unknown): string { } catch { return String(value ?? ""); } -} +} \ No newline at end of file diff --git a/web_src/src/pages/workflowv2/mappers/honeycomb/custom_fields.tsx b/web_src/src/pages/workflowv2/mappers/honeycomb/custom_fields.tsx deleted file mode 100644 index 1ba2b515ec..0000000000 --- a/web_src/src/pages/workflowv2/mappers/honeycomb/custom_fields.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import { CustomFieldRenderer, NodeInfo } from "../types"; -import React from "react"; - -type OnAlertFiredMetadata = { - webhookUrl?: string; - sharedSecret?: string; -}; - -function extractWebhookData(node: NodeInfo): { webhookUrl: string; sharedSecret: string } { - const md = (node.metadata ?? {}) as OnAlertFiredMetadata; - - const webhookUrl = (md.webhookUrl ?? "").trim(); - const sharedSecret = (md.sharedSecret ?? "").trim(); - - return { webhookUrl, sharedSecret }; -} - -function CopyRow({ label, value }: { label: string; value: string }): React.ReactElement { - const shown = value?.trim() ? value : "Save this node to generate"; - - const [copied, setCopied] = React.useState(false); - - const onCopy = async () => { - if (!value?.trim()) return; - try { - await navigator.clipboard.writeText(value); - setCopied(true); - setTimeout(() => setCopied(false), 1200); - } catch { - // ignore - } - }; - - return ( -
-
-
{label}
-
{shown}
-
- - -
- ); -} - -export const honeycombOnAlertFiredCustomFieldRenderer: CustomFieldRenderer = { - render(node: NodeInfo) { - const { webhookUrl, sharedSecret } = extractWebhookData(node); - - return ( -
-
Honeycomb webhook setup
-
- After saving this trigger, SuperPlane will generate a shared Webhook URL and Shared Secret. -
- - - - -
- If no events arrive, verify the webhook is configured as a recipient in Honeycomb. -
-
- ); - }, -}; - -export const honeycombCustomFieldRenderers: Record = { - onAlertFired: honeycombOnAlertFiredCustomFieldRenderer, - "honeycomb.onAlertFired": honeycombOnAlertFiredCustomFieldRenderer, -}; diff --git a/web_src/src/pages/workflowv2/mappers/honeycomb/index.ts b/web_src/src/pages/workflowv2/mappers/honeycomb/index.ts index 128b0b65ec..2767d0570c 100644 --- a/web_src/src/pages/workflowv2/mappers/honeycomb/index.ts +++ b/web_src/src/pages/workflowv2/mappers/honeycomb/index.ts @@ -1,25 +1,17 @@ -import { ComponentBaseMapper, TriggerRenderer, EventStateRegistry, CustomFieldRenderer } from "../types"; +import { ComponentBaseMapper, TriggerRenderer, EventStateRegistry } from "../types"; import { buildActionStateRegistry } from "../utils"; import { createEventMapper } from "./create_event"; import { onAlertFiredTriggerRenderer } from "./on_alert_fired"; -import { honeycombCustomFieldRenderers } from "./custom_fields"; export const componentMappers: Record = { - createEvent: createEventMapper, - "honeycomb.createEvent": createEventMapper, + "createEvent": createEventMapper, }; export const triggerRenderers: Record = { - onAlertFired: onAlertFiredTriggerRenderer, - "honeycomb.onAlertFired": onAlertFiredTriggerRenderer, + "onAlertFired": onAlertFiredTriggerRenderer, }; export const eventStateRegistry: Record = { - createEvent: buildActionStateRegistry("Sent"), - "honeycomb.createEvent": buildActionStateRegistry("Sent"), -}; - -export const customFieldRenderers: Record = { - ...honeycombCustomFieldRenderers, + "createEvent": buildActionStateRegistry("Sent"), }; diff --git a/web_src/src/pages/workflowv2/mappers/honeycomb/on_alert_fired.ts b/web_src/src/pages/workflowv2/mappers/honeycomb/on_alert_fired.ts index 02ae970b6c..957e23e3f4 100644 --- a/web_src/src/pages/workflowv2/mappers/honeycomb/on_alert_fired.ts +++ b/web_src/src/pages/workflowv2/mappers/honeycomb/on_alert_fired.ts @@ -1,74 +1,86 @@ -import { TriggerRenderer, TriggerRendererContext, TriggerEventContext } from "../types"; -import { defaultTriggerRenderer } from "../default"; +import { getColorClass, getBackgroundColorClass } from "@/utils/colors"; +import { TriggerEventContext, TriggerRenderer, TriggerRendererContext } from "../types"; +import honeycombIcon from "@/assets/icons/integrations/honeycomb.svg"; +import { TriggerProps } from "@/ui/trigger"; -type OnAlertFiredMetadata = { - webhookUrl?: string; - sharedSecret?: string; -}; +interface OnAlertFiredConfiguration { + datasetSlug?: string; + alertName?: string; +} + +interface OnAlertFiredEventData { + name?: string; + status?: string; + summary?: string; + trigger_url?: string; + triggered_at?: string; + severity?: string; + result_value?: number; +} export const onAlertFiredTriggerRenderer: TriggerRenderer = { - getTitleAndSubtitle: (context: TriggerEventContext) => { - const title = context.event?.customName?.trim() || "On Alert Fired"; - return { title, subtitle: "Triggers when Honeycomb sends an alert webhook" }; + getTitleAndSubtitle: (context: TriggerEventContext): { title: string; subtitle: string } => { + const eventData = context.event?.data as OnAlertFiredEventData; + + return { + title: eventData?.name ?? "Alert Fired", + subtitle: eventData?.status ?? "", + }; }, - getRootEventValues: (context: TriggerEventContext) => ({ - type: context.event?.type, - createdAt: context.event?.createdAt, - data: context.event?.data, - }), + getRootEventValues: (context: TriggerEventContext): Record => { + const eventData = context.event?.data as OnAlertFiredEventData; + + return { + Name: eventData?.name ?? "", + Status: eventData?.status ?? "", + Summary: eventData?.summary ?? "", + Severity: eventData?.severity ?? "", + "Result Value": eventData?.result_value?.toString() ?? "", + "Triggered At": eventData?.triggered_at ?? "", + "Trigger URL": eventData?.trigger_url ?? "", + }; + }, - getTriggerProps: (context: TriggerRendererContext) => { - const base = defaultTriggerRenderer.getTriggerProps(context); + getTriggerProps: (context: TriggerRendererContext): TriggerProps => { + const { node, definition, lastEvent } = context; + const configuration = node.configuration as unknown as OnAlertFiredConfiguration; + const metadataItems = []; - const md = (context.node?.metadata || {}) as OnAlertFiredMetadata; - const webhookUrl = (md.webhookUrl || "").trim(); - const sharedSecret = (md.sharedSecret || "").trim(); + if (configuration?.datasetSlug) { + metadataItems.push({ + icon: "database", + label: configuration.datasetSlug, + }); + } - const hasGenerated = !!webhookUrl && !!sharedSecret; + if (configuration?.alertName) { + metadataItems.push({ + icon: "bell", + label: configuration.alertName, + }); + } - return { - ...base, - nodeMeta: { - title: "Honeycomb webhook setup", - sections: [ - { - title: "Copy into Honeycomb", - items: [ - { - label: "Webhook URL", - value: hasGenerated ? webhookUrl : "Save this trigger to generate", - copyable: hasGenerated, - }, - { - label: "Shared Secret", - value: hasGenerated ? sharedSecret : "Save this trigger to generate", - copyable: hasGenerated, - }, - ], - }, - { - title: "Honeycomb steps", - items: [ - { - label: "Where to paste", - value: - "Honeycomb → Team Settings → Integrations → Webhooks. Create a webhook integration and paste the values above. Then attach the webhook as an alert recipient.", - }, - ], - }, - { - title: "Troubleshooting", - items: [ - { - label: "No events?", - value: - "If no events arrive, verify the webhook is configured as a recipient in Honeycomb and trigger an alert to test.", - }, - ], - }, - ], - }, + const props: TriggerProps = { + title: node.name || definition.label || "Unnamed trigger", + iconSrc: honeycombIcon, + iconColor: getColorClass(definition.color), + collapsedBackground: getBackgroundColorClass(definition.color), + metadata: metadataItems, }; + + if (lastEvent) { + const eventData = lastEvent.data as OnAlertFiredEventData; + + props.lastEventData = { + title: eventData?.name ?? "Alert Fired", + subtitle: eventData?.status ?? "", + receivedAt: new Date(lastEvent.createdAt), + state: "triggered", + eventId: lastEvent.id, + }; + } + + return props; }, -}; +}; \ No newline at end of file diff --git a/web_src/src/pages/workflowv2/mappers/index.ts b/web_src/src/pages/workflowv2/mappers/index.ts index 27e94719ac..f6ee880989 100644 --- a/web_src/src/pages/workflowv2/mappers/index.ts +++ b/web_src/src/pages/workflowv2/mappers/index.ts @@ -130,7 +130,6 @@ import { componentMappers as honeycombComponentMappers, triggerRenderers as honeycombTriggerRenderers, eventStateRegistry as honeycombEventStateRegistry, - customFieldRenderers as honeycombCustomFieldRenderers, } from "./honeycomb/index"; import { filterMapper, FILTER_STATE_REGISTRY } from "./filter"; @@ -266,7 +265,6 @@ const appCustomFieldRenderers: Record = { @@ -57,6 +58,7 @@ export const INTEGRATION_APP_LOGO_MAP: Record = { prometheus: prometheusIcon, render: renderIcon, dockerhub: dockerIcon, + honeycomb: honeycombIcon, }; /** Block name first part (e.g. "github") or compound (e.g. aws.lambda) → logo src for header. */ @@ -91,6 +93,7 @@ export const APP_LOGO_MAP: Record> = { ecs: awsEcsIcon, sns: awsSnsIcon, }, + honeycomb: honeycombIcon, }; /** From 39e739b06766e84dfb8f0523e1a3fc677e033a63 Mon Sep 17 00:00:00 2001 From: Dragica Draskic Date: Sun, 22 Feb 2026 13:27:52 +0100 Subject: [PATCH 11/24] chore: Fix formatting in Honeycomb mappers Signed-off-by: Dragica Draskic --- .../src/pages/workflowv2/mappers/honeycomb/create_event.ts | 2 +- web_src/src/pages/workflowv2/mappers/honeycomb/index.ts | 6 +++--- .../pages/workflowv2/mappers/honeycomb/on_alert_fired.ts | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/web_src/src/pages/workflowv2/mappers/honeycomb/create_event.ts b/web_src/src/pages/workflowv2/mappers/honeycomb/create_event.ts index 9d9a2e8cf7..c9f280af75 100644 --- a/web_src/src/pages/workflowv2/mappers/honeycomb/create_event.ts +++ b/web_src/src/pages/workflowv2/mappers/honeycomb/create_event.ts @@ -120,4 +120,4 @@ function safeJSONStringify(value: unknown): string { } catch { return String(value ?? ""); } -} \ No newline at end of file +} diff --git a/web_src/src/pages/workflowv2/mappers/honeycomb/index.ts b/web_src/src/pages/workflowv2/mappers/honeycomb/index.ts index 2767d0570c..00eb977ef9 100644 --- a/web_src/src/pages/workflowv2/mappers/honeycomb/index.ts +++ b/web_src/src/pages/workflowv2/mappers/honeycomb/index.ts @@ -5,13 +5,13 @@ import { createEventMapper } from "./create_event"; import { onAlertFiredTriggerRenderer } from "./on_alert_fired"; export const componentMappers: Record = { - "createEvent": createEventMapper, + createEvent: createEventMapper, }; export const triggerRenderers: Record = { - "onAlertFired": onAlertFiredTriggerRenderer, + onAlertFired: onAlertFiredTriggerRenderer, }; export const eventStateRegistry: Record = { - "createEvent": buildActionStateRegistry("Sent"), + createEvent: buildActionStateRegistry("Sent"), }; diff --git a/web_src/src/pages/workflowv2/mappers/honeycomb/on_alert_fired.ts b/web_src/src/pages/workflowv2/mappers/honeycomb/on_alert_fired.ts index 957e23e3f4..8cbf38cbcc 100644 --- a/web_src/src/pages/workflowv2/mappers/honeycomb/on_alert_fired.ts +++ b/web_src/src/pages/workflowv2/mappers/honeycomb/on_alert_fired.ts @@ -83,4 +83,4 @@ export const onAlertFiredTriggerRenderer: TriggerRenderer = { return props; }, -}; \ No newline at end of file +}; From e600c1af2387c8cc80b69027382c461083098bb9 Mon Sep 17 00:00:00 2001 From: Dragica Draskic Date: Sun, 22 Feb 2026 13:49:46 +0100 Subject: [PATCH 12/24] chore: Fix Honeycomb component docs formatting Signed-off-by: Dragica Draskic --- docs/components/Honeycomb.mdx | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/docs/components/Honeycomb.mdx b/docs/components/Honeycomb.mdx index c952956b9f..d4843d872e 100644 --- a/docs/components/Honeycomb.mdx +++ b/docs/components/Honeycomb.mdx @@ -4,14 +4,14 @@ title: "Honeycomb" Monitor observability alerts and send events to Honeycomb datasets +import { CardGrid, LinkCard } from "@astrojs/starlight/components"; + ## Triggers -import { CardGrid, LinkCard } from "@astrojs/starlight/components"; - ## Actions @@ -24,8 +24,8 @@ Connect Honeycomb to SuperPlane using a Management Key. **Required configuration:** - **Site**: US (api.honeycomb.io) or EU (api.eu1.honeycomb.io) based on your account region. -- **Management Key**: Found in Honeycomb under Team Settings > API Keys. Must be in format :. -- **Team Slug**: Your team identifier, visible in the Honeycomb URL: honeycomb.io/. +- **Management Key**: Found in Honeycomb under Team Settings > API Keys. Must be in format <keyID>:<secret>. +- **Team Slug**: Your team identifier, visible in the Honeycomb URL: honeycomb.io/<team-slug>. - **Environment Slug**: The environment containing your datasets (e.g. "production"). Found under Team Settings > Environments. SuperPlane will automatically validate your credentials and manage all necessary Honeycomb resources — webhook recipients for triggers and ingest keys for actions — so no manual setup is required. @@ -37,7 +37,7 @@ SuperPlane will automatically validate your credentials and manage all necessary Starts a workflow execution when a Honeycomb Trigger fires. **Configuration:** -- **Dataset Slug**: The slug of the dataset that contains your Honeycomb trigger. Found in the dataset URL: honeycomb.io//datasets/. +- **Dataset Slug**: The slug of the dataset that contains your Honeycomb trigger. Found in the dataset URL: honeycomb.io/<team>/datasets/<dataset-slug>. - **Alert Name**: The exact name of the Honeycomb trigger to listen to (case-insensitive). Found in your dataset under Triggers. **How it works:** @@ -117,5 +117,4 @@ Notes: }, "status": "sent" } -``` - +``` \ No newline at end of file From 77f1448825b8171e0b667831cdf98d0a0bd1bbba Mon Sep 17 00:00:00 2001 From: Dragica Draskic Date: Sun, 22 Feb 2026 14:43:50 +0100 Subject: [PATCH 13/24] fix: Improve error handling in Honeycomb client configuration key Adds a more descriptive error message when the configuration key v1 ping fails, enhancing clarity in error reporting and aiding in troubleshooting. Signed-off-by: Dragica Draskic --- pkg/integrations/honeycomb/client.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/integrations/honeycomb/client.go b/pkg/integrations/honeycomb/client.go index 1b476f51a1..ff0d50bb43 100644 --- a/pkg/integrations/honeycomb/client.go +++ b/pkg/integrations/honeycomb/client.go @@ -249,6 +249,9 @@ func (c *Client) EnsureConfigurationKey(teamSlug string) error { return nil } + if err != nil { + return fmt.Errorf("configuration key v1 ping failed: %w", err) + } if code == http.StatusUnauthorized || code == http.StatusForbidden { _ = c.integrationCtx.SetSecret(secretNameConfigurationKey, []byte{}) } else { From 279ce3dbc7e3228911ddcf6c9cad13f22585782f Mon Sep 17 00:00:00 2001 From: Dragica Draskic Date: Sun, 22 Feb 2026 14:46:59 +0100 Subject: [PATCH 14/24] fix: Enhance field handling in Honeycomb event mapper Updates the CreateEventConfiguration interface to allow fields to be stored as a raw JSON string or an object. Introduces a new function, formatFieldsForDisplay, to properly format fields for display, ensuring that JSON strings are parsed correctly to avoid double-encoding. This change improves the handling and presentation of event data in the Honeycomb integration. Signed-off-by: Dragica Draskic --- .../mappers/honeycomb/create_event.ts | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/web_src/src/pages/workflowv2/mappers/honeycomb/create_event.ts b/web_src/src/pages/workflowv2/mappers/honeycomb/create_event.ts index c9f280af75..09e957ef92 100644 --- a/web_src/src/pages/workflowv2/mappers/honeycomb/create_event.ts +++ b/web_src/src/pages/workflowv2/mappers/honeycomb/create_event.ts @@ -16,7 +16,8 @@ import { formatTimeAgo } from "@/utils/date"; interface CreateEventConfiguration { dataset?: string; - fields?: Record; + /** Stored as raw JSON string from backend; may be object when from execution output */ + fields?: string | Record; } type HoneycombCreateEventPayload = { @@ -58,7 +59,7 @@ export const createEventMapper: ComponentBaseMapper = { "Created At": context.execution.createdAt ? new Date(context.execution.createdAt).toLocaleString() : "-", Status: data?.status ?? "-", Dataset: data?.dataset ?? "-", - "Sent Fields": data?.fields ? safeJSONStringify(data.fields) : "-", + "Sent Fields": formatFieldsForDisplay(data?.fields as string | Record | undefined), }; }, @@ -90,7 +91,7 @@ function createEventSpecs(node: NodeInfo): ComponentBaseSpec[] { title: "fields", tooltipTitle: "fields", iconSlug: "braces", - value: safeJSONStringify(configuration.fields), + value: formatFieldsForDisplay(configuration.fields), contentType: "json", }); } @@ -121,3 +122,17 @@ function safeJSONStringify(value: unknown): string { return String(value ?? ""); } } + +/** Formats fields for display. Backend stores config.fields as raw JSON string, so we parse first to avoid double-encoding. */ +function formatFieldsForDisplay(fields: string | Record | undefined): string { + if (fields == null) return "-"; + if (typeof fields === "string") { + try { + const parsed = JSON.parse(fields) as unknown; + return safeJSONStringify(parsed); + } catch { + return fields; + } + } + return safeJSONStringify(fields); +} From 8d293da92e289d69ed0c7af3fcbc0544a9cf5c7b Mon Sep 17 00:00:00 2001 From: Dragica Draskic Date: Sun, 22 Feb 2026 14:48:47 +0100 Subject: [PATCH 15/24] fix: Improve error handling for configuration key in Honeycomb integration Enhances the error handling in the OnAlertFired setup function by adding a descriptive error message when ensuring the configuration key fails. This change improves clarity in error reporting and aids in troubleshooting. Signed-off-by: Dragica Draskic --- pkg/integrations/honeycomb/on_alert_fired.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pkg/integrations/honeycomb/on_alert_fired.go b/pkg/integrations/honeycomb/on_alert_fired.go index 4280bb6bc1..a2f60b4af9 100644 --- a/pkg/integrations/honeycomb/on_alert_fired.go +++ b/pkg/integrations/honeycomb/on_alert_fired.go @@ -104,7 +104,9 @@ func (t *OnAlertFired) Setup(ctx core.TriggerContext) error { teamAny, err := ctx.Integration.GetConfig("teamSlug") if err == nil && strings.TrimSpace(string(teamAny)) != "" { - _ = client.EnsureConfigurationKey(strings.TrimSpace(string(teamAny))) + if err := client.EnsureConfigurationKey(strings.TrimSpace(string(teamAny))); err != nil { + return fmt.Errorf("failed to ensure configuration key: %w", err) + } } triggers, err := client.ListTriggers(cfg.DatasetSlug) From 980e48a8a39469d2c68f5864cd2067706bfa2899 Mon Sep 17 00:00:00 2001 From: Dragica Draskic Date: Sun, 22 Feb 2026 14:51:21 +0100 Subject: [PATCH 16/24] chore: Fix Honeycomb docs missing newline at end of file Signed-off-by: Dragica Draskic --- docs/components/Honeycomb.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/components/Honeycomb.mdx b/docs/components/Honeycomb.mdx index d4843d872e..6bd90928ef 100644 --- a/docs/components/Honeycomb.mdx +++ b/docs/components/Honeycomb.mdx @@ -117,4 +117,4 @@ Notes: }, "status": "sent" } -``` \ No newline at end of file +``` From 0aee77f2fef73bc89bb6cd8d5bd8dd11de0dc0f9 Mon Sep 17 00:00:00 2001 From: Dragica Draskic Date: Sun, 22 Feb 2026 15:07:22 +0100 Subject: [PATCH 17/24] chore: Fix Honeycomb docs Signed-off-by: Dragica Draskic --- docs/components/Honeycomb.mdx | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/components/Honeycomb.mdx b/docs/components/Honeycomb.mdx index 6bd90928ef..c952956b9f 100644 --- a/docs/components/Honeycomb.mdx +++ b/docs/components/Honeycomb.mdx @@ -4,14 +4,14 @@ title: "Honeycomb" Monitor observability alerts and send events to Honeycomb datasets -import { CardGrid, LinkCard } from "@astrojs/starlight/components"; - ## Triggers +import { CardGrid, LinkCard } from "@astrojs/starlight/components"; + ## Actions @@ -24,8 +24,8 @@ Connect Honeycomb to SuperPlane using a Management Key. **Required configuration:** - **Site**: US (api.honeycomb.io) or EU (api.eu1.honeycomb.io) based on your account region. -- **Management Key**: Found in Honeycomb under Team Settings > API Keys. Must be in format <keyID>:<secret>. -- **Team Slug**: Your team identifier, visible in the Honeycomb URL: honeycomb.io/<team-slug>. +- **Management Key**: Found in Honeycomb under Team Settings > API Keys. Must be in format :. +- **Team Slug**: Your team identifier, visible in the Honeycomb URL: honeycomb.io/. - **Environment Slug**: The environment containing your datasets (e.g. "production"). Found under Team Settings > Environments. SuperPlane will automatically validate your credentials and manage all necessary Honeycomb resources — webhook recipients for triggers and ingest keys for actions — so no manual setup is required. @@ -37,7 +37,7 @@ SuperPlane will automatically validate your credentials and manage all necessary Starts a workflow execution when a Honeycomb Trigger fires. **Configuration:** -- **Dataset Slug**: The slug of the dataset that contains your Honeycomb trigger. Found in the dataset URL: honeycomb.io/<team>/datasets/<dataset-slug>. +- **Dataset Slug**: The slug of the dataset that contains your Honeycomb trigger. Found in the dataset URL: honeycomb.io//datasets/. - **Alert Name**: The exact name of the Honeycomb trigger to listen to (case-insensitive). Found in your dataset under Triggers. **How it works:** @@ -118,3 +118,4 @@ Notes: "status": "sent" } ``` + From 12e2a5d72eba33801e6f8d2e10d0eb484bece935 Mon Sep 17 00:00:00 2001 From: Dragica Draskic Date: Sun, 22 Feb 2026 15:19:20 +0100 Subject: [PATCH 18/24] feat: Add API key validation and ingest key handling in Honeycomb client Introduces the pingV1WithKey function to validate both configuration and ingest keys against the Honeycomb API. Enhances the EnsureIngestKey method to perform a ping check on the ingest key, improving error handling and ensuring the key's validity before reuse. This change strengthens the integration's reliability and error reporting. Signed-off-by: Dragica Draskic --- pkg/integrations/honeycomb/client.go | 42 +++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/pkg/integrations/honeycomb/client.go b/pkg/integrations/honeycomb/client.go index ff0d50bb43..8c9cc73519 100644 --- a/pkg/integrations/honeycomb/client.go +++ b/pkg/integrations/honeycomb/client.go @@ -185,6 +185,34 @@ func (c *Client) pingV1WithConfigKey() (int, []byte, error) { return code, b, err } +// pingV1WithKey pings /1/auth with the given API key to validate it works. +// Honeycomb accepts both configuration and ingest keys for this endpoint. +func (c *Client) pingV1WithKey(key string) (int, []byte, error) { + key = strings.TrimSpace(key) + if key == "" { + return 0, nil, fmt.Errorf("key is empty") + } + u, _ := url.Parse(c.BaseURL) + u.Path = "/1/auth" + req, err := http.NewRequest(http.MethodGet, u.String(), nil) + if err != nil { + return 0, nil, err + } + req.Header.Set("X-Honeycomb-Team", key) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + b, code, err := c.do(req) + return code, b, err +} + +func (c *Client) pingV1WithIngestKey() (int, []byte, error) { + ingestKey, err := c.getSecretValue(secretNameIngestKey) + if err != nil { + return 0, nil, err + } + return c.pingV1WithKey(ingestKey) +} + type listEnvironmentsResponse struct { Data []struct { ID string `json:"id"` @@ -337,7 +365,19 @@ func (c *Client) EnsureConfigurationKey(teamSlug string) error { // when sending events. If a valid key already exists, it is reused. func (c *Client) EnsureIngestKey(teamSlug string) error { if c.hasSecret(secretNameIngestKey) { - return nil + code, body, err := c.pingV1WithIngestKey() + if err == nil && code >= 200 && code < 300 { + return nil + } + + if err != nil { + return fmt.Errorf("ingest key v1 ping failed: %w", err) + } + if code == http.StatusUnauthorized || code == http.StatusForbidden { + _ = c.integrationCtx.SetSecret(secretNameIngestKey, []byte{}) + } else { + return fmt.Errorf("existing ingest key failed v1 ping (http %d): %s", code, string(body)) + } } teamSlug = strings.TrimSpace(teamSlug) From 2c6cb9e72bc9b6da3a205e55d8e0cc27f42eba70 Mon Sep 17 00:00:00 2001 From: Dragica Draskic Date: Sun, 22 Feb 2026 15:23:16 +0100 Subject: [PATCH 19/24] refactor: Replace ingest header retrieval method in Honeycomb client Updates the CreateEvent method to use getSecretValue for retrieving the ingest key instead of the deprecated getIngestHeaderValue method. This change simplifies the code by removing the unused method and enhances the overall clarity of the client implementation. Signed-off-by: Dragica Draskic --- pkg/integrations/honeycomb/client.go | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/pkg/integrations/honeycomb/client.go b/pkg/integrations/honeycomb/client.go index 8c9cc73519..1f74f42fd4 100644 --- a/pkg/integrations/honeycomb/client.go +++ b/pkg/integrations/honeycomb/client.go @@ -684,7 +684,7 @@ func (c *Client) CreateEvent(datasetSlug string, fields map[string]any) error { return fmt.Errorf("dataset is required") } - ingestHeader, err := c.getIngestHeaderValue() + ingestHeader, err := c.getSecretValue(secretNameIngestKey) if err != nil { return err } @@ -725,22 +725,6 @@ func (c *Client) CreateEvent(datasetSlug string, fields map[string]any) error { return fmt.Errorf("honeycomb create event failed (status %d): %s", resp.StatusCode, string(b)) } -func (c *Client) getIngestHeaderValue() (string, error) { - secrets, err := c.integrationCtx.GetSecrets() - if err != nil { - return "", err - } - for _, s := range secrets { - if s.Name == secretNameIngestKey { - v := strings.TrimSpace(string(s.Value)) - if v != "" { - return v, nil - } - } - } - return "", fmt.Errorf("ingest key not found (expected secret %q)", secretNameIngestKey) -} - func (c *Client) getSecretValue(name string) (string, error) { secrets, err := c.integrationCtx.GetSecrets() if err != nil { From ed095d83738f82101f106920ce40188e7e08b554 Mon Sep 17 00:00:00 2001 From: Dragica Draskic Date: Sun, 22 Feb 2026 15:35:05 +0100 Subject: [PATCH 20/24] chore: Sync generated Honeycomb component docs Signed-off-by: Dragica Draskic --- docs/components/Honeycomb.mdx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/components/Honeycomb.mdx b/docs/components/Honeycomb.mdx index c952956b9f..1d70ff9f5e 100644 --- a/docs/components/Honeycomb.mdx +++ b/docs/components/Honeycomb.mdx @@ -4,14 +4,14 @@ title: "Honeycomb" Monitor observability alerts and send events to Honeycomb datasets +import { CardGrid, LinkCard } from "@astrojs/starlight/components"; + ## Triggers -import { CardGrid, LinkCard } from "@astrojs/starlight/components"; - ## Actions @@ -24,8 +24,8 @@ Connect Honeycomb to SuperPlane using a Management Key. **Required configuration:** - **Site**: US (api.honeycomb.io) or EU (api.eu1.honeycomb.io) based on your account region. -- **Management Key**: Found in Honeycomb under Team Settings > API Keys. Must be in format :. -- **Team Slug**: Your team identifier, visible in the Honeycomb URL: honeycomb.io/. +- **Management Key**: Found in Honeycomb under Team Settings > API Keys. Must be in format <keyID>:<secret>. +- **Team Slug**: Your team identifier, visible in the Honeycomb URL: honeycomb.io/<team-slug>. - **Environment Slug**: The environment containing your datasets (e.g. "production"). Found under Team Settings > Environments. SuperPlane will automatically validate your credentials and manage all necessary Honeycomb resources — webhook recipients for triggers and ingest keys for actions — so no manual setup is required. @@ -37,7 +37,7 @@ SuperPlane will automatically validate your credentials and manage all necessary Starts a workflow execution when a Honeycomb Trigger fires. **Configuration:** -- **Dataset Slug**: The slug of the dataset that contains your Honeycomb trigger. Found in the dataset URL: honeycomb.io//datasets/. +- **Dataset Slug**: The slug of the dataset that contains your Honeycomb trigger. Found in the dataset URL: honeycomb.io/<team>/datasets/<dataset-slug>. - **Alert Name**: The exact name of the Honeycomb trigger to listen to (case-insensitive). Found in your dataset under Triggers. **How it works:** From 4025907f1458cce0f89f1aa1ec1512eaf835c2cd Mon Sep 17 00:00:00 2001 From: Dragica Draskic Date: Sun, 22 Feb 2026 16:15:00 +0100 Subject: [PATCH 21/24] refactor: Simplify trigger field handling in Honeycomb client Introduces a new function, stripTriggerForUpdate, to centralize the removal of read-only and conflicting fields from trigger payloads before sending updates to the Honeycomb API. This change reduces code duplication in the UpdateTrigger and RemoveRecipientFromTrigger methods, enhancing maintainability and clarity. Additionally, improves error handling in the CreateEvent method by providing a more descriptive error message when the ingest key is not found. Signed-off-by: Dragica Draskic --- pkg/integrations/honeycomb/client.go | 42 +++++++++++----------------- 1 file changed, 17 insertions(+), 25 deletions(-) diff --git a/pkg/integrations/honeycomb/client.go b/pkg/integrations/honeycomb/client.go index 1f74f42fd4..2a7688b8bb 100644 --- a/pkg/integrations/honeycomb/client.go +++ b/pkg/integrations/honeycomb/client.go @@ -502,6 +502,19 @@ func (c *Client) GetTrigger(datasetSlug, triggerID string) (map[string]any, erro return obj, nil } +// stripTriggerForUpdate removes read-only and conflicting fields from a trigger +// payload so it can be sent to the Honeycomb update API. +func stripTriggerForUpdate(trigger map[string]any) { + if _, hasQueryID := trigger["query_id"]; hasQueryID { + delete(trigger, "query") + } + delete(trigger, "id") + delete(trigger, "dataset_slug") + delete(trigger, "created_at") + delete(trigger, "updated_at") + delete(trigger, "triggered") +} + func (c *Client) UpdateTrigger(datasetSlug, triggerID string, trigger map[string]any) error { body, _ := json.Marshal(trigger) req, err := c.newReqV1(http.MethodPut, fmt.Sprintf("/1/triggers/%s/%s", url.PathEscape(datasetSlug), url.PathEscape(triggerID)), bytes.NewReader(body)) @@ -545,19 +558,7 @@ func (c *Client) EnsureRecipientOnTrigger(datasetSlug, triggerID, recipientID st "target": "SuperPlane", }) trigger["recipients"] = recipientsSlice - - // Honeycomb rejects requests with both query and query_id - if _, hasQueryID := trigger["query_id"]; hasQueryID { - delete(trigger, "query") - } - - // Remove read-only fields rejected by Honeycomb - delete(trigger, "id") - delete(trigger, "dataset_slug") - delete(trigger, "created_at") - delete(trigger, "updated_at") - delete(trigger, "triggered") - + stripTriggerForUpdate(trigger) return c.UpdateTrigger(datasetSlug, triggerID, trigger) } @@ -665,16 +666,7 @@ func (c *Client) RemoveRecipientFromTrigger(datasetSlug, triggerID, recipientID } } trigger["recipients"] = filtered - - if _, hasQueryID := trigger["query_id"]; hasQueryID { - delete(trigger, "query") - } - delete(trigger, "id") - delete(trigger, "dataset_slug") - delete(trigger, "created_at") - delete(trigger, "updated_at") - delete(trigger, "triggered") - + stripTriggerForUpdate(trigger) return c.UpdateTrigger(datasetSlug, triggerID, trigger) } @@ -685,8 +677,8 @@ func (c *Client) CreateEvent(datasetSlug string, fields map[string]any) error { } ingestHeader, err := c.getSecretValue(secretNameIngestKey) - if err != nil { - return err + if err != nil || strings.TrimSpace(ingestHeader) == "" { + return fmt.Errorf("ingest key not found (expected secret %q)", secretNameIngestKey) } u, _ := url.Parse(c.BaseURL) From d93e515f8207802593c896c145cf0321f43d547f Mon Sep 17 00:00:00 2001 From: Dragica Draskic Date: Sun, 22 Feb 2026 16:32:57 +0100 Subject: [PATCH 22/24] feat: Add metadata setting in OnAlertFired setup Enhances the OnAlertFired setup function by adding a step to set metadata for the trigger ID. This change improves the integration's ability to track alert triggers and provides better context for subsequent operations. Additionally, it includes error handling for the metadata setting process to ensure robustness. Signed-off-by: Dragica Draskic --- pkg/integrations/honeycomb/on_alert_fired.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pkg/integrations/honeycomb/on_alert_fired.go b/pkg/integrations/honeycomb/on_alert_fired.go index a2f60b4af9..e1486cc4cd 100644 --- a/pkg/integrations/honeycomb/on_alert_fired.go +++ b/pkg/integrations/honeycomb/on_alert_fired.go @@ -126,6 +126,10 @@ func (t *OnAlertFired) Setup(ctx core.TriggerContext) error { return fmt.Errorf("trigger with name %q not found in dataset %q", cfg.AlertName, cfg.DatasetSlug) } + if err := ctx.Metadata.Set(OnAlertFiredNodeMetadata{TriggerID: triggerID}); err != nil { + return fmt.Errorf("failed to set metadata: %w", err) + } + if err := ctx.Integration.RequestWebhook(map[string]any{ "datasetSlug": cfg.DatasetSlug, "triggerIds": []string{triggerID}, @@ -133,7 +137,6 @@ func (t *OnAlertFired) Setup(ctx core.TriggerContext) error { return fmt.Errorf("failed to request webhook: %w", err) } - _ = ctx.Metadata.Set(OnAlertFiredNodeMetadata{TriggerID: triggerID}) return nil } From 78c85abc741bf9a3be2420145c73b9f47fa96c25 Mon Sep 17 00:00:00 2001 From: Dragica Draskic Date: Sun, 22 Feb 2026 17:00:16 +0100 Subject: [PATCH 23/24] feat: Add v1 ping check after ingest key creation in Honeycomb client Signed-off-by: Dragica Draskic --- pkg/integrations/honeycomb/client.go | 8 ++++++++ pkg/integrations/honeycomb/honeycomb_test.go | 8 +++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/pkg/integrations/honeycomb/client.go b/pkg/integrations/honeycomb/client.go index 2a7688b8bb..22cc736224 100644 --- a/pkg/integrations/honeycomb/client.go +++ b/pkg/integrations/honeycomb/client.go @@ -446,6 +446,14 @@ func (c *Client) EnsureIngestKey(teamSlug string) error { return fmt.Errorf("failed to store ingest key secret: %w", err) } + code2, body2, err2 := c.pingV1WithIngestKey() + if err2 != nil { + return fmt.Errorf("v1 ping failed after creating ingest key: %w", err2) + } + if code2 < 200 || code2 >= 300 { + return fmt.Errorf("created ingest key but v1 ping failed (http %d): %s", code2, string(body2)) + } + return nil } diff --git a/pkg/integrations/honeycomb/honeycomb_test.go b/pkg/integrations/honeycomb/honeycomb_test.go index 713cfb358d..5ca020583f 100644 --- a/pkg/integrations/honeycomb/honeycomb_test.go +++ b/pkg/integrations/honeycomb/honeycomb_test.go @@ -198,6 +198,10 @@ func Test__Honeycomb__Sync(t *testing.T) { StatusCode: http.StatusCreated, Body: io.NopCloser(strings.NewReader(ingestKeyBody)), }, + { + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{}`)), // <-- NEW ping response + }, }, } @@ -209,7 +213,9 @@ func Test__Honeycomb__Sync(t *testing.T) { require.NoError(t, err) assert.Equal(t, "ready", integrationCtx.State) - assert.Len(t, httpCtx.Requests, 6) + + // sada je 7 requestova jer postoji dodatni ping + assert.Len(t, httpCtx.Requests, 7) cfgSecret, ok := integrationCtx.Secrets[secretNameConfigurationKey] require.True(t, ok) From f726814caefd587f5120163a974de14b7b105e46 Mon Sep 17 00:00:00 2001 From: Dragica Draskic Date: Sun, 22 Feb 2026 17:25:41 +0100 Subject: [PATCH 24/24] chore: remove comment from test Signed-off-by: Dragica Draskic --- pkg/integrations/honeycomb/honeycomb_test.go | 1 - 1 file changed, 1 deletion(-) diff --git a/pkg/integrations/honeycomb/honeycomb_test.go b/pkg/integrations/honeycomb/honeycomb_test.go index 5ca020583f..817bdcfca5 100644 --- a/pkg/integrations/honeycomb/honeycomb_test.go +++ b/pkg/integrations/honeycomb/honeycomb_test.go @@ -214,7 +214,6 @@ func Test__Honeycomb__Sync(t *testing.T) { require.NoError(t, err) assert.Equal(t, "ready", integrationCtx.State) - // sada je 7 requestova jer postoji dodatni ping assert.Len(t, httpCtx.Requests, 7) cfgSecret, ok := integrationCtx.Secrets[secretNameConfigurationKey]