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 @@ +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..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, };