diff --git a/docs/components/Honeycomb.mdx b/docs/components/Honeycomb.mdx
new file mode 100644
index 0000000000..1d70ff9f5e
--- /dev/null
+++ b/docs/components/Honeycomb.mdx
@@ -0,0 +1,121 @@
+---
+title: "Honeycomb"
+---
+
+Monitor observability alerts and send events to Honeycomb datasets
+
+import { CardGrid, LinkCard } from "@astrojs/starlight/components";
+
+## Triggers
+
+
+
+
+
+## Actions
+
+
+
+
+
+## Instructions
+
+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>.
+- **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.
+
+
+
+## On Alert Fired
+
+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>.
+- **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.
+
+### 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 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
+
+### 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..22cc736224
--- /dev/null
+++ b/pkg/integrations/honeycomb/client.go
@@ -0,0 +1,815 @@
+package honeycomb
+
+import (
+ "bytes"
+ "crypto/rand"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+
+ "github.com/superplanehq/superplane/pkg/core"
+)
+
+const (
+ secretNameIngestKey = "honeycomb_ingest_key"
+ secretNameConfigurationKey = "honeycomb_configuration_key"
+)
+
+type Client struct {
+ BaseURL string
+ ManagementKey string
+ http core.HTTPContext
+ integrationCtx core.IntegrationContext
+}
+
+func NewClient(httpCtx core.HTTPContext, ctx core.IntegrationContext) (*Client, error) {
+ siteAny, err := ctx.GetConfig("site")
+ if err != nil {
+ siteAny = []byte("api.honeycomb.io")
+ }
+ site := strings.TrimSpace(string(siteAny))
+ if site == "" {
+ site = "api.honeycomb.io"
+ }
+
+ baseURL := site
+ if !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") {
+ 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{
+ 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
+}
+
+// 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 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
+ }
+
+ 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 nil, 0, err
+ }
+ defer resp.Body.Close()
+
+ 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
+}
+
+// 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"`
+ 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))
+ }
+
+ var parsed listEnvironmentsResponse
+ if err := json.Unmarshal(body, &parsed); err != nil {
+ return "", fmt.Errorf("failed to parse environments: %w", err)
+ }
+
+ 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("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 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 {
+ 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) {
+ 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)
+ 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)
+ }
+
+ 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
+}
+
+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
+}
+
+// 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))
+ 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
+ stripTriggerForUpdate(trigger)
+ 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
+ stripTriggerForUpdate(trigger)
+ return c.UpdateTrigger(datasetSlug, triggerID, trigger)
+}
+
+func (c *Client) CreateEvent(datasetSlug string, fields map[string]any) error {
+ datasetSlug = strings.TrimSpace(datasetSlug)
+ if datasetSlug == "" {
+ return fmt.Errorf("dataset is required")
+ }
+
+ ingestHeader, err := c.getSecretValue(secretNameIngestKey)
+ if err != nil || strings.TrimSpace(ingestHeader) == "" {
+ return fmt.Errorf("ingest key not found (expected secret %q)", secretNameIngestKey)
+ }
+
+ 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", ingestHeader)
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Accept", "application/json")
+
+ // 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))
+ }
+
+ 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))
+}
+
+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
new file mode 100644
index 0000000000..ac403a350e
--- /dev/null
+++ b/pkg/integrations/honeycomb/create_event.go
@@ -0,0 +1,165 @@
+package honeycomb
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "strings"
+
+ "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"`
+ Fields 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 dataset"
+}
+
+func (c *CreateEvent) Icon() string {
+ return "honeycomb"
+}
+
+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}
+}
+
+func (c *CreateEvent) Configuration() []configuration.Field {
+ return []configuration.Field{
+ {
+ Name: "dataset",
+ Label: "Dataset",
+ Type: configuration.FieldTypeString,
+ Required: true,
+ Description: "Dataset slug",
+ },
+ {
+ 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("invalid configuration: %w", err)
+ }
+
+ cfg.Dataset = strings.TrimSpace(cfg.Dataset)
+ if cfg.Dataset == "" {
+ return errors.New("dataset 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.Fields), &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 err
+ }
+
+ var fields map[string]any
+ 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 err
+ }
+
+ if err := client.CreateEvent(cfg.Dataset, fields); err != nil {
+ return err
+ }
+
+ output := map[string]any{
+ "status": "sent",
+ "dataset": cfg.Dataset,
+ "fields": fields,
+ }
+
+ return ctx.ExecutionState.Emit(
+ core.DefaultOutputChannel.Name,
+ "honeycomb.event.created",
+ []any{output},
+ )
+}
+
+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..df149a0a47
--- /dev/null
+++ b/pkg/integrations/honeycomb/create_event_test.go
@@ -0,0 +1,238 @@
+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("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{
+ "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 managementKey -> error", func(t *testing.T) {
+ integrationCtx := &contexts.IntegrationContext{
+ Configuration: map[string]any{
+ "site": "api.honeycomb.io",
+ },
+ }
+
+ err := component.Execute(core.ExecutionContext{
+ Integration: integrationCtx,
+ Configuration: map[string]any{
+ "dataset": "test-dataset",
+ "fields": `{"key":"value"}`,
+ },
+ HTTP: &contexts.HTTPContext{},
+ })
+
+ 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) {
+ httpCtx := &contexts.HTTPContext{
+ Responses: []*http.Response{
+ {
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(`{}`)),
+ },
+ },
+ }
+
+ 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")},
+ },
+ }
+
+ 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-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.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{
+ "managementKey": "keyid:secret",
+ "site": "api.honeycomb.io",
+ },
+ Secrets: map[string]core.IntegrationSecret{
+ secretNameIngestKey: {Name: secretNameIngestKey, Value: []byte("test-ingest-key")},
+ },
+ }
+
+ 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-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"`)
+
+ 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/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..f82af7fe3e
--- /dev/null
+++ b/pkg/integrations/honeycomb/honeycomb.go
@@ -0,0 +1,173 @@
+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"
+)
+
+func init() {
+ registry.RegisterIntegrationWithWebhookHandler("honeycomb", &Honeycomb{}, &HoneycombWebhookHandler{})
+}
+
+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"
+}
+
+func (h *Honeycomb) Label() string {
+ return "Honeycomb"
+}
+
+func (h *Honeycomb) Icon() string {
+ return "honeycomb"
+}
+
+func (h *Honeycomb) Description() string {
+ return "Monitor observability alerts and send events to Honeycomb datasets"
+}
+
+func (h *Honeycomb) Instructions() string {
+ return `
+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/.
+- **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.
+`
+}
+
+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: "managementKey",
+ Label: "Management Key",
+ Type: configuration.FieldTypeString,
+ 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,
+ },
+ }
+}
+
+func (h *Honeycomb) Components() []core.Component {
+ return []core.Component{
+ &CreateEvent{},
+ }
+}
+
+func (h *Honeycomb) Triggers() []core.Trigger {
+ return []core.Trigger{
+ &OnAlertFired{},
+ }
+}
+
+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.ValidateManagementKey(cfg.TeamSlug); err != nil {
+ return err
+ }
+
+ if err := client.EnsureConfigurationKey(cfg.TeamSlug); err != nil {
+ return err
+ }
+
+ if err := client.EnsureIngestKey(cfg.TeamSlug); err != nil {
+ return err
+ }
+
+ ctx.Integration.Ready()
+ 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..817bdcfca5
--- /dev/null
+++ b/pkg/integrations/honeycomb/honeycomb_test.go
@@ -0,0 +1,227 @@
+package honeycomb
+
+import (
+ "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"
+)
+
+func Test__Honeycomb__Sync(t *testing.T) {
+ h := &Honeycomb{}
+
+ 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{},
+ })
+
+ require.ErrorContains(t, err, "site is required")
+ })
+
+ t.Run("missing managementKey -> error", func(t *testing.T) {
+ integrationCtx := &contexts.IntegrationContext{
+ Configuration: map[string]any{
+ "site": "api.honeycomb.io",
+ "teamSlug": "myteam",
+ "environmentSlug": "production",
+ },
+ }
+
+ err := h.Sync(core.SyncContext{
+ Configuration: integrationCtx.Configuration,
+ Integration: integrationCtx,
+ HTTP: &contexts.HTTPContext{},
+ })
+
+ require.ErrorContains(t, err, "managementKey is required")
+ })
+
+ t.Run("missing teamSlug -> error", func(t *testing.T) {
+ integrationCtx := &contexts.IntegrationContext{
+ Configuration: map[string]any{
+ "site": "api.honeycomb.io",
+ "managementKey": "keyid:secret",
+ "environmentSlug": "production",
+ },
+ }
+
+ err := h.Sync(core.SyncContext{
+ Configuration: integrationCtx.Configuration,
+ Integration: integrationCtx,
+ HTTP: &contexts.HTTPContext{},
+ })
+
+ require.ErrorContains(t, err, "teamSlug is required")
+ })
+
+ 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": "api.honeycomb.io",
+ "managementKey": "no-colon-here",
+ "teamSlug": "myteam",
+ "environmentSlug": "production",
+ },
+ }
+
+ err := h.Sync(core.SyncContext{
+ Configuration: integrationCtx.Configuration,
+ Integration: integrationCtx,
+ HTTP: &contexts.HTTPContext{},
+ })
+
+ require.ErrorContains(t, err, "managementKey must be in format :")
+ })
+
+ 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":"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": "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)),
+ },
+ {
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(strings.NewReader(`{}`)), // <-- NEW ping response
+ },
+ },
+ }
+
+ err := h.Sync(core.SyncContext{
+ Configuration: integrationCtx.Configuration,
+ Integration: integrationCtx,
+ HTTP: httpCtx,
+ })
+
+ require.NoError(t, err)
+ assert.Equal(t, "ready", integrationCtx.State)
+
+ assert.Len(t, httpCtx.Requests, 7)
+
+ 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
new file mode 100644
index 0000000000..e1486cc4cd
--- /dev/null
+++ b/pkg/integrations/honeycomb/on_alert_fired.go
@@ -0,0 +1,228 @@
+package honeycomb
+
+import (
+ "crypto/subtle"
+ "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 {
+ DatasetSlug string `json:"datasetSlug" mapstructure:"datasetSlug"`
+ AlertName string `json:"alertName" mapstructure:"alertName"`
+}
+
+type OnAlertFiredNodeMetadata struct {
+ TriggerID string `json:"triggerId"`
+}
+
+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 a Honeycomb Trigger fires"
+}
+
+func (t *OnAlertFired) Icon() string {
+ return "honeycomb"
+}
+
+func (t *OnAlertFired) Color() string {
+ return "yellow"
+}
+
+func (t *OnAlertFired) Documentation() string {
+ return `
+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.
+
+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",
+ Type: configuration.FieldTypeString,
+ 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)
+ }
+
+ 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
+ }
+
+ client, err := NewClient(ctx.HTTP, ctx.Integration)
+ if err != nil {
+ return fmt.Errorf("failed to create client: %w", err)
+ }
+
+ teamAny, err := ctx.Integration.GetConfig("teamSlug")
+ if err == nil && 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)
+ if err != nil {
+ 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
+ }
+ }
+
+ if triggerID == "" {
+ 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},
+ }); err != nil {
+ return fmt.Errorf("failed to request webhook: %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, err
+ }
+
+ secretBytes, err := ctx.Webhook.GetSecret()
+ if err != nil {
+ return http.StatusInternalServerError, err
+ }
+ secret := strings.TrimSpace(string(secretBytes))
+
+ provided := strings.TrimSpace(ctx.Headers.Get("X-Honeycomb-Webhook-Token"))
+ if provided == "" {
+ 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 subtle.ConstantTimeCompare([]byte(provided), []byte(secret)) != 1 {
+ 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)}
+ }
+
+ 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, err
+ }
+
+ return http.StatusOK, nil
+}
+
+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 id, ok := payload["trigger_id"].(string); ok {
+ return strings.EqualFold(strings.TrimSpace(id), want)
+ }
+
+ if tr, ok := payload["trigger"].(map[string]any); ok {
+ if id, ok := tr["id"].(string); ok {
+ return strings.EqualFold(strings.TrimSpace(id), 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..4fee4507db
--- /dev/null
+++ b/pkg/integrations/honeycomb/on_alert_fired_test.go
@@ -0,0 +1,189 @@
+package honeycomb
+
+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"
+)
+
+func Test__OnAlertFired__Setup(t *testing.T) {
+ trigger := OnAlertFired{}
+
+ t.Run("missing datasetSlug -> error", func(t *testing.T) {
+ err := trigger.Setup(core.TriggerContext{
+ Integration: &contexts.IntegrationContext{},
+ Metadata: &contexts.MetadataContext{},
+ Configuration: map[string]any{
+ "alertName": "High Error Rate",
+ },
+ })
+ require.ErrorContains(t, err, "datasetSlug is required")
+ })
+
+ 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")
+ })
+
+ 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)
+ })
+}
+
+func Test__OnAlertFired__HandleWebhook(t *testing.T) {
+ trigger := &OnAlertFired{}
+
+ validConfig := map[string]any{
+ "datasetSlug": "production",
+ "alertName": "High Error Rate",
+ }
+
+ body := []byte(`{"id":"trigger-abc","name":"High Error Rate","status":"TRIGGERED"}`)
+
+ 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{},
+ })
+ 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-xx")
+
+ code, err := trigger.HandleWebhook(core.WebhookRequestContext{
+ Headers: h,
+ Body: body,
+ Configuration: validConfig,
+ Webhook: &contexts.WebhookContext{Secret: "test-secret"},
+ Events: &contexts.EventContext{},
+ Metadata: &contexts.MetadataContext{},
+ })
+ assert.Equal(t, http.StatusForbidden, code)
+ assert.ErrorContains(t, err, "invalid webhook token")
+ })
+
+ 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(`not valid json`),
+ Configuration: validConfig,
+ Webhook: &contexts.WebhookContext{Secret: "test-secret"},
+ Events: events,
+ Metadata: &contexts.MetadataContext{},
+ })
+ assert.Equal(t, http.StatusOK, code)
+ assert.NoError(t, err)
+ assert.Equal(t, 1, events.Count())
+ })
+
+ 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,
+ Body: body,
+ Configuration: validConfig,
+ Webhook: &contexts.WebhookContext{Secret: "test-secret"},
+ Events: events,
+ Metadata: meta,
+ })
+ require.Equal(t, http.StatusOK, code)
+ require.NoError(t, err)
+ assert.Equal(t, 1, events.Count())
+ assert.Equal(t, "honeycomb.alert.fired", events.Payloads[0].Type)
+ })
+
+ t.Run("valid token, triggerID does not match -> no emit", func(t *testing.T) {
+ h := http.Header{}
+ 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{
+ Headers: h,
+ Body: body,
+ Configuration: validConfig,
+ Webhook: &contexts.WebhookContext{Secret: "test-secret"},
+ Events: events,
+ Metadata: meta,
+ })
+ assert.Equal(t, http.StatusOK, code)
+ assert.NoError(t, err)
+ assert.Equal(t, 0, events.Count())
+ })
+
+ t.Run("valid token, no metadata -> emits without filter", 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,
+ Metadata: &contexts.MetadataContext{},
+ })
+ assert.Equal(t, http.StatusOK, code)
+ assert.NoError(t, err)
+ assert.Equal(t, 1, events.Count())
+ })
+
+ t.Run("bearer authorization header -> emits", func(t *testing.T) {
+ h := http.Header{}
+ 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: "test-secret"},
+ Events: events,
+ Metadata: meta,
+ })
+ 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
new file mode 100644
index 0000000000..c5310132ec
--- /dev/null
+++ b/pkg/integrations/honeycomb/webhook_handler.go
@@ -0,0 +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) {
+ 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) {
+ 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) {
+ 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 {
+ 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/pkg/server/server.go b/pkg/server/server.go
index 40728a2079..3000a859f3 100644
--- a/pkg/server/server.go
+++ b/pkg/server/server.go
@@ -50,6 +50,7 @@ import (
_ "github.com/superplanehq/superplane/pkg/integrations/grafana"
_ "github.com/superplanehq/superplane/pkg/integrations/harness"
_ "github.com/superplanehq/superplane/pkg/integrations/hetzner"
+ _ "github.com/superplanehq/superplane/pkg/integrations/honeycomb"
_ "github.com/superplanehq/superplane/pkg/integrations/jfrog_artifactory"
_ "github.com/superplanehq/superplane/pkg/integrations/jira"
_ "github.com/superplanehq/superplane/pkg/integrations/openai"
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 @@
+
\ 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..09e957ef92
--- /dev/null
+++ b/web_src/src/pages/workflowv2/mappers/honeycomb/create_event.ts
@@ -0,0 +1,138 @@
+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;
+ /** Stored as raw JSON string from backend; may be object when from execution output */
+ fields?: string | Record;
+}
+
+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": formatFieldsForDisplay(data?.fields as string | Record | undefined),
+ };
+ },
+
+ 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 });
+ } else {
+ metadata.push({ icon: "database", label: "Uses integration 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: formatFieldsForDisplay(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 ?? "");
+ }
+}
+
+/** 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);
+}
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..00eb977ef9
--- /dev/null
+++ b/web_src/src/pages/workflowv2/mappers/honeycomb/index.ts
@@ -0,0 +1,17 @@
+import { ComponentBaseMapper, TriggerRenderer, EventStateRegistry } from "../types";
+import { buildActionStateRegistry } from "../utils";
+
+import { createEventMapper } from "./create_event";
+import { onAlertFiredTriggerRenderer } from "./on_alert_fired";
+
+export const componentMappers: Record = {
+ createEvent: createEventMapper,
+};
+
+export const triggerRenderers: Record = {
+ onAlertFired: onAlertFiredTriggerRenderer,
+};
+
+export const eventStateRegistry: Record = {
+ 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
new file mode 100644
index 0000000000..8cbf38cbcc
--- /dev/null
+++ b/web_src/src/pages/workflowv2/mappers/honeycomb/on_alert_fired.ts
@@ -0,0 +1,86 @@
+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";
+
+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): { title: string; subtitle: string } => {
+ const eventData = context.event?.data as OnAlertFiredEventData;
+
+ return {
+ title: eventData?.name ?? "Alert Fired",
+ subtitle: eventData?.status ?? "",
+ };
+ },
+
+ 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): TriggerProps => {
+ const { node, definition, lastEvent } = context;
+ const configuration = node.configuration as unknown as OnAlertFiredConfiguration;
+ const metadataItems = [];
+
+ if (configuration?.datasetSlug) {
+ metadataItems.push({
+ icon: "database",
+ label: configuration.datasetSlug,
+ });
+ }
+
+ if (configuration?.alertName) {
+ metadataItems.push({
+ icon: "bell",
+ label: configuration.alertName,
+ });
+ }
+
+ 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;
+ },
+};
diff --git a/web_src/src/pages/workflowv2/mappers/index.ts b/web_src/src/pages/workflowv2/mappers/index.ts
index 12327de26a..db643e4d87 100644
--- a/web_src/src/pages/workflowv2/mappers/index.ts
+++ b/web_src/src/pages/workflowv2/mappers/index.ts
@@ -156,6 +156,12 @@ import {
triggerRenderers as dockerhubTriggerRenderers,
eventStateRegistry as dockerhubEventStateRegistry,
} from "./dockerhub";
+
+import {
+ componentMappers as honeycombComponentMappers,
+ triggerRenderers as honeycombTriggerRenderers,
+ eventStateRegistry as honeycombEventStateRegistry,
+} from "./honeycomb/index";
import {
componentMappers as gcpComponentMappers,
customFieldRenderers as gcpCustomFieldRenderers,
@@ -229,6 +235,7 @@ const appMappers: Record> = {
jfrogArtifactory: jfrogArtifactoryComponentMappers,
statuspage: statuspageComponentMappers,
dockerhub: dockerhubComponentMappers,
+ honeycomb: honeycombComponentMappers,
harness: harnessComponentMappers,
servicenow: servicenowComponentMappers,
};
@@ -262,6 +269,7 @@ const appTriggerRenderers: Record> = {
jfrogArtifactory: jfrogArtifactoryTriggerRenderers,
statuspage: statuspageTriggerRenderers,
dockerhub: dockerhubTriggerRenderers,
+ honeycomb: honeycombTriggerRenderers,
harness: harnessTriggerRenderers,
servicenow: servicenowTriggerRenderers,
};
@@ -294,6 +302,7 @@ const appEventStateRegistries: Record
gitlab: gitlabEventStateRegistry,
jfrogArtifactory: jfrogArtifactoryEventStateRegistry,
dockerhub: dockerhubEventStateRegistry,
+ honeycomb: honeycombEventStateRegistry,
harness: harnessEventStateRegistry,
servicenow: servicenowEventStateRegistry,
};
diff --git a/web_src/src/ui/BuildingBlocksSidebar/index.tsx b/web_src/src/ui/BuildingBlocksSidebar/index.tsx
index 8303404f32..a864e85fb1 100644
--- a/web_src/src/ui/BuildingBlocksSidebar/index.tsx
+++ b/web_src/src/ui/BuildingBlocksSidebar/index.tsx
@@ -50,6 +50,7 @@ import renderIcon from "@/assets/icons/integrations/render.svg";
import dockerIcon from "@/assets/icons/integrations/docker.svg";
import awsSqsIcon from "@/assets/icons/integrations/aws.sqs.svg";
import hetznerIcon from "@/assets/icons/integrations/hetzner.svg";
+import honeycombIcon from "@/assets/icons/integrations/honeycomb.svg";
import jfrogArtifactoryIcon from "@/assets/icons/integrations/jfrog-artifactory.svg";
import harnessIcon from "@/assets/icons/integrations/harness.svg";
import servicenowIcon from "@/assets/icons/integrations/servicenow.svg";
@@ -454,6 +455,7 @@ function CategorySection({
ecs: awsEcsIcon,
sns: awsSnsIcon,
},
+ honeycomb: honeycombIcon,
gcp: gcpIcon,
};
@@ -547,6 +549,7 @@ function CategorySection({
ecs: awsEcsIcon,
sns: awsSnsIcon,
},
+ honeycomb: honeycombIcon,
gcp: gcpIcon,
};
const appLogo = nameParts[0] ? appLogoMap[nameParts[0]] : undefined;
diff --git a/web_src/src/ui/componentSidebar/integrationIcons.tsx b/web_src/src/ui/componentSidebar/integrationIcons.tsx
index 57e3083ea5..30fd7e576e 100644
--- a/web_src/src/ui/componentSidebar/integrationIcons.tsx
+++ b/web_src/src/ui/componentSidebar/integrationIcons.tsx
@@ -38,6 +38,7 @@ import prometheusIcon from "@/assets/icons/integrations/prometheus.svg";
import renderIcon from "@/assets/icons/integrations/render.svg";
import dockerIcon from "@/assets/icons/integrations/docker.svg";
import hetznerIcon from "@/assets/icons/integrations/hetzner.svg";
+import honeycombIcon from "@/assets/icons/integrations/honeycomb.svg";
import jfrogArtifactoryIcon from "@/assets/icons/integrations/jfrog-artifactory.svg";
import harnessIcon from "@/assets/icons/integrations/harness.svg";
import servicenowIcon from "@/assets/icons/integrations/servicenow.svg";
@@ -74,6 +75,7 @@ export const INTEGRATION_APP_LOGO_MAP: Record = {
prometheus: prometheusIcon,
render: renderIcon,
dockerhub: dockerIcon,
+ honeycomb: honeycombIcon,
gcp: gcpIcon,
harness: harnessIcon,
servicenow: servicenowIcon,
@@ -125,6 +127,7 @@ export const APP_LOGO_MAP: Record> = {
ecs: awsEcsIcon,
sns: awsSnsIcon,
},
+ honeycomb: honeycombIcon,
gcp: gcpIcon,
};