Skip to content

Comments

feat: Add rootly onEvent trigger#3150

Open
devroy10 wants to merge 4 commits intosuperplanehq:mainfrom
devroy10:rootly-event
Open

feat: Add rootly onEvent trigger#3150
devroy10 wants to merge 4 commits intosuperplanehq:mainfrom
devroy10:rootly-event

Conversation

@devroy10
Copy link
Contributor

@devroy10 devroy10 commented Feb 17, 2026

Description

Closes #2820

This PR implements the Rootly On Event trigger so SuperPlane workflows can start on Rootly incident timeline events. It sets up a managed webhook and renders event context clearly in the UI.

Video Demo

rootly-on-event-1.mp4

Backend Implementation

  • Added rootly.onEvent trigger with webhook setup and signature verification.
  • Parses Rootly webhook payloads and emits rootly.onEvent with incident + event context.
  • Introduces a new function findOrCreateWebhookEndpoint and releavant helpers in the rootly webhook handler to handle webhook sharing by merging requested event types and comparing configs via superset logic. The previous implementation did not make use of deterministic unique name creation for superplane webhook and as such, attempts to update already created rootly webhooks or create new ones resulted in Error 401 or 422
  • Also updated the Merge function in the webhook handler to support, the new webhook reuse/update logic.

Configuration

Supports optional filtering by

  • Incident status
  • Severity
  • Service
  • Team
  • Visibility
  • Event Kind
  • Event Source

Frontend Implementation

  • Added Rootly On Event renderer with timeline event title/subtitle and metadata badges.
  • Displays event in the root event view.
  • Uses Rootly icon/color styling for consistency in trigger cards.

Documentation

  • Auto-generated component documentation via relevant make command

Signed-off-by: devroy10 <roychinwuba@gmail.com>
Signed-off-by: devroy10 <roychinwuba@gmail.com>
Signed-off-by: devroy10 <roychinwuba@gmail.com>
@devroy10
Copy link
Contributor Author

devroy10 commented Feb 17, 2026

This is the actual nature of the rootly incident_event payload. It contains the event details and in addition, only the parent incident id. This effectively means that in order to support filtering by parent incident properties such as team, service, severity etc. This integration would require an

  • an extra HTTP call, to fetch the parent incidents properties
  • after which and filter by them,
  • before rendering on the canvas.

In the core trigger.go, it was mentioned to avoid making extra API calls while handling webhooks, so I'm bringing this notice here, to decide on implementation details before continuing
Screencast from 2026-02-17 20-29-01.webm

{
  "event": {
    "id": "5a924bb6-5597-46f2-9b28-6ab3a20d29e5",
    "type": "incident_event.created",
    "issued_at": "2026-02-17T11:18:35.881-08:00"
  },
  "data": {
    "id": "10d70b62-f9df-45ee-a476-191d0be7bdb5",
    "event": "new event note added, t1",
    "event_raw": "new event note added, t1",
    "kind": "event",
    "source": "web",
    "visibility": "internal",
    "occurred_at": "2026-02-17T11:18:35.817-08:00",
    "created_at": "2026-02-17T11:18:35.817-08:00",
    "updated_at": "2026-02-17T11:18:35.817-08:00",
    "incident_id": "64c39fde-1626-4f78-874e-9db91c0639d3"
  }
}

@AleksandarCole AleksandarCole added bounty This issue has a bounty open pr:stage-1/3 Needs to pass basic review. labels Feb 18, 2026
@devroy10

This comment was marked as outdated.

@devroy10 devroy10 mentioned this pull request Feb 18, 2026
6 tasks
@devroy10 devroy10 marked this pull request as ready for review February 19, 2026 15:55
@cursor
Copy link

cursor bot commented Feb 19, 2026

You have run out of free Bugbot PR reviews for this billing cycle. This will reset on March 12.

To receive reviews on all of your PRs, visit the Cursor dashboard to activate Pro and start your 14-day free trial.

- add new On Event trigger filters (status, severity, service, team, source, kind)
- fetch incident details for incident-level filters
- include event_type in payload and normalize severity parsing
- update UI mapper and docs for new filters/output
- adjust tests and example payloads

Signed-off-by: devroy10 <roychinwuba@gmail.com>
@devroy10
Copy link
Contributor Author

devroy10 commented Feb 20, 2026

Added video demos showing the functionality

  • Creating a new onEvent trigger for the first time, creates a new Rootly webhook with the requested types
new-webhook.MOV
  • An incident event not matching all filters does not trigger the component
no-trigger.MOV
  • An incident event matching all filters triggers the component and emits a payload

@ipezygj
Copy link

ipezygj commented Feb 22, 2026

🤖 Professional AI Solution (Claude Sonnet 4.6)

# Rootly OnEvent Trigger Implementation

## Backend Implementation

### 1. Trigger Definition

```go
// internal/integrations/rootly/triggers/on_event.go
package triggers

import (
	"context"
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"net/http"
	"strings"
	"time"

	"github.com/superplane/superplane/internal/integrations/rootly/client"
	"github.com/superplane/superplane/internal/integrations/rootly/webhook"
	"github.com/superplane/superplane/internal/trigger"
	"github.com/superplane/superplane/pkg/models"
	"go.uber.org/zap"
)

const (
	TriggerTypeOnEvent      = "rootly.onEvent"
	WebhookHeaderSignature  = "X-Rootly-Signature"
	WebhookHeaderTimestamp  = "X-Rootly-Timestamp"
	SignatureVersion        = "v0"
	SignatureToleranceSeconds = 300 // 5 minutes
)

// OnEventTriggerConfig defines the configuration for the rootly.onEvent trigger.
type OnEventTriggerConfig struct {
	// Optional filters
	IncidentStatus []string `json:"incidentStatus,omitempty" yaml:"incidentStatus,omitempty"`
	Severity       []string `json:"severity,omitempty"       yaml:"severity,omitempty"`
	Services       []string `json:"services,omitempty"       yaml:"services,omitempty"`
	Teams          []string `json:"teams,omitempty"          yaml:"teams,omitempty"`
	Visibility     []string `json:"visibility,omitempty"     yaml:"visibility,omitempty"`
	EventKind      []string `json:"eventKind,omitempty"      yaml:"eventKind,omitempty"`
	EventSource    []string `json:"eventSource,omitempty"    yaml:"eventSource,omitempty"`
}

// OnEventPayload represents the parsed Rootly webhook payload.
type OnEventPayload struct {
	Incident RootlyIncident `json:"incident"`
	Event    RootlyEvent    `json:"event"`
}

// RootlyIncident contains incident context from the webhook payload.
type RootlyIncident struct {
	ID              string            `json:"id"`
	Title           string            `json:"title"`
	Status          string            `json:"status"`
	Severity        string            `json:"severity"`
	Visibility      string            `json:"visibility"`
	Services        []RootlyService   `json:"services"`
	Teams           []RootlyTeam      `json:"teams"`
	CreatedAt       time.Time         `json:"created_at"`
	UpdatedAt       time.Time         `json:"updated_at"`
	URL             string            `json:"url"`
	Summary         string            `json:"summary"`
	Labels          map[string]string `json:"labels"`
	EnvironmentName string            `json:"environment_name"`
}

// RootlyEvent contains timeline event context from the webhook payload.
type RootlyEvent struct {
	ID          string                 `json:"id"`
	Kind        string                 `json:"kind"`
	Source      string                 `json:"source"`
	Summary     string                 `json:"summary"`
	OccurredAt  time.Time              `json:"occurred_at"`
	CreatedAt   time.Time              `json:"created_at"`
	Data        map[string]interface{} `json:"data"`
	Actor       *RootlyActor           `json:"actor,omitempty"`
}

// RootlyActor represents who or what triggered the event.
type RootlyActor struct {
	Type  string `json:"type"`
	ID    string `json:"id"`
	Name  string `json:"name"`
	Email string `json:"email,omitempty"`
}

// RootlyService represents a service attached to an incident.
type RootlyService struct {
	ID   string `json:"id"`
	Name string `json:"name"`
	Slug string `json:"slug"`
}

// RootlyTeam represents a team attached to an incident.
type RootlyTeam struct {
	ID   string `json:"id"`
	Name string `json:"name"`
	Slug string `json:"slug"`
}

// RootlyWebhookEnvelope is the top-level structure of the Rootly webhook body.
type RootlyWebhookEnvelope struct {
	Data struct {
		Type       string          `json:"type"`
		ID         string          `json:"id"`
		Attributes json.RawMessage `json:"attributes"`
	} `json:"data"`
}

// OnEventTrigger implements the rootly.onEvent trigger.
type OnEventTrigger struct {
	logger         *zap.Logger
	rootlyClient   client.RootlyClient
	webhookManager *webhook.Manager
	registry       trigger.Registry
}

// NewOnEventTrigger constructs a new OnEventTrigger.
func NewOnEventTrigger(
	logger *zap.Logger,
	rootlyClient client.RootlyClient,
	webhookManager *webhook.Manager,
	registry trigger.Registry,
) *OnEventTrigger {
	return &OnEventTrigger{
		logger:         logger.Named("rootly.onEvent"),
		rootlyClient:   rootlyClient,
		webhookManager: webhookManager,
		registry:       registry,
	}
}

// Type returns the trigger type identifier.
func (t *OnEventTrigger) Type() string {
	return TriggerTypeOnEvent
}

// Setup registers or updates the Rootly webhook endpoint for the given workflow.
func (t *OnEventTrigger) Setup(ctx context.Context, wf *models.Workflow, rawConfig json.RawMessage) error {
	cfg, err := parseConfig(rawConfig)
	if err != nil {
		return fmt.Errorf("rootly.onEvent: parse config: %w", err)
	}

	requestedEventTypes := resolveEventTypes(cfg)

	endpoint, err := t.webhookManager.FindOrCreateWebhookEndpoint(ctx, &webhook.FindOrCreateRequest{
		WorkflowID:          wf.ID,
		RequestedEventTypes: requestedEventTypes,
		Config:              cfg,
	})
	if err != nil {
		return fmt.Errorf("rootly.onEvent: setup webhook: %w", err)
	}

	t.logger.Info("rootly.onEvent webhook endpoint ready",
		zap.String("workflow_id", wf.ID),
		zap.String("endpoint_id", endpoint.ID),
		zap.Strings("event_types", requestedEventTypes),
	)

	return nil
}

// Teardown removes the workflow's association with the Rootly webhook endpoint.
func (t *OnEventTrigger) Teardown(ctx context.Context, wf *models.Workflow) error {
	return t.webhookManager.DisassociateWorkflow(ctx, wf.ID, TriggerTypeOnEvent)
}

// HandleWebhook processes an inbound Rootly webhook request and emits trigger events.
func (t *OnEventTrigger) HandleWebhook(
	ctx context.Context,
	w http.ResponseWriter,
	r *http.Request,
	secret string,
) {
	body, err := readAndVerifySignature(r, secret)
	if err != nil {
		t.logger.Warn("rootly.onEvent: signature verification failed", zap.Error(err))
		http.Error(w, "unauthorized", http.StatusUnauthorized)
		return
	}

	payload, err := parseWebhookPayload(body)
	if err != nil {
		t.logger.Error("rootly.onEvent: parse payload", zap.Error(err))
		http.Error(w, "bad request", http.StatusBadRequest)
		return
	}

	workflows, err := t.registry.ListWorkflowsByTrigger(ctx, TriggerTypeOnEvent)
	if err != nil {
		t.logger.Error("rootly.onEvent: list workflows", zap.Error(err))
		http.Error(w, "internal server error", http.StatusInternalServerError)
		return
	}

	for _, wf := range workflows {
		cfg, err := loadWorkflowConfig(wf)
		if err != nil {
			t.logger.Warn("rootly.onEvent: load workflow config",
				zap.String("workflow_id", wf.ID),
				zap.Error(err),
			)
			continue
		}

		if !matchesFilters(payload, cfg) {
			continue
		}

		event := buildTriggerEvent(payload, wf)
		if err := t.registry.EmitTriggerEvent(ctx, event); err != nil {
			t.logger.Error("rootly.onEvent: emit trigger event",
				zap.String("workflow_id", wf.ID),
				zap.Error(err),
			)
		}
	}

	w.WriteHeader(http.StatusOK)
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

func parseConfig(raw json.RawMessage) (*OnEventTriggerConfig, error) {
	if len(raw) == 0 {
		return &OnEventTriggerConfig{}, nil
	}
	var cfg OnEventTriggerConfig
	if err := json.Unmarshal(raw, &cfg); err != nil {
		return nil, err
	}
	return &cfg, nil
}

func resolveEventTypes(cfg *OnEventTriggerConfig) []string {
	if len(cfg.EventKind) > 0 {
		return cfg.EventKind
	}
	// Default: subscribe to all incident timeline events.
	return []string{"incident_event"}
}

func readAndVerifySignature(r *http.Request, secret string) ([]byte, error) {
	tsHeader := r.Header.Get(WebhookHeaderTimestamp)
	sigHeader := r.Header.Get(WebhookHeaderSignature)

	if tsHeader == "" || sigHeader == "" {
		return nil, fmt.Errorf("missing signature headers")
	}

	ts, err := parseTimestamp(tsHeader)
	if err != nil {
		return nil, fmt.Errorf("invalid timestamp: %w", err)
	}

	if time.Since(ts) > SignatureToleranceSeconds*time.Second {
		return nil, fmt.Errorf("timestamp too old: replay attack protection triggered")
	}

	body, err := readBody(r)
	if err != nil {
		return nil, fmt.Errorf("read body: %w", err)
	}

	expected := computeSignature(secret, tsHeader, body)
	if !hmac.Equal([]byte(expected), []byte(sigHeader)) {
		return nil, fmt.Errorf("signature mismatch")
	}

	return body, nil
}

func computeSignature(secret, timestamp string, body []byte) string {
	payload := fmt.Sprintf("%s:%s:%s", SignatureVersion, timestamp, string(body))
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(payload))
	return fmt.Sprintf("%s=%s", SignatureVersion, hex.EncodeToString(mac.Sum(nil)))
}

func parseTimestamp(ts string) (time.Time, error) {
	unix, err := parseInt64(ts)
	if err != nil {
		return time.Time{}, err
	}
	return time.Unix(unix, 0), nil
}

func parseInt64(s string) (int64, error) {
	var v int64
	_, err := fmt.Sscanf(s, "%d", &v)
	return v, err
}

func readBody(r *http.Request) ([]byte, error) {
	if r.Body == nil {
		return nil, fmt.Errorf("empty body")

Reward Address: 0xa32ca744f86a91eaf567e2be4902f64bc33c2813

@ipezygj
Copy link

ipezygj commented Feb 22, 2026

Overview

This PR adds a rootly.onEvent trigger that allows SuperPlane workflows to be initiated by Rootly incident timeline events. Here's what was implemented:

Backend:

  • New rootly.onEvent trigger with webhook lifecycle management
  • findOrCreateWebhookEndpoint helper using deterministic naming + superset logic to safely reuse/update webhooks
  • Signature verification and payload parsing for Rootly webhook events
  • Filtering support for incident status, severity, service, team, visibility, event kind, and source

Frontend:

  • Renderer for the rootly.onEvent trigger displaying timeline event metadata
  • Badge-based display for event context
  • Consistent Rootly branding

Backend

trigger/rootly/onEvent.ts

import { IntegrationTaskKey } from "~/types";
import {
  TriggerMetadata,
  RegisterTriggerSource,
  TriggerInstance,
} from "../types";
import { rootlyWebhookHandler } from "./webhookHandler";
import { findOrCreateWebhookEndpoint } from "./webhookHelpers";

export const ROOTLY_ON_EVENT_ID = "rootly.onEvent" as IntegrationTaskKey;

export const onEvent: TriggerMetadata = {
  id: ROOTLY_ON_EVENT_ID,
  name: "On Event",
  description: "Triggers when a Rootly incident timeline event occurs",
  icon: "rootly",
  color: "#E84141",
  properties: [
    {
      id: "incidentStatus",
      name: "Incident Status",
      type: "string[]",
      required: false,
    },
    {
      id: "severity",
      name: "Severity",
      type: "string[]",
      required: false,
    },
    {
      id: "service",
      name: "Service",
      type: "string[]",
      required: false,
    },
    {
      id: "team",
      name: "Team",
      type: "string[]",
      required: false,
    },
    {
      id: "visibility",
      name: "Visibility",
      type: "string",
      required: false,
    },
    {
      id: "eventKind",
      name: "Event Kind",
      type: "string[]",
      required: false,
    },
    {
      id: "eventSource",
      name: "Event Source",
      type: "string[]",
      required: false,
    },
  ],
};

export async function registerRootlyOnEventTrigger(
  source: RegisterTriggerSource
): Promise<TriggerInstance> {
  const webhookEndpoint = await findOrCreateWebhookEndpoint({
    integrationId: source.integrationId,
    requestedEventTypes: ["incident_event"],
    webhookUrl: source.webhookUrl,
    secret: source.secret,
  });

  return {
    id: webhookEndpoint.id,
    metadata: {
      webhookEndpointId: webhookEndpoint.id,
      webhookEndpointName: webhookEndpoint.attributes.name,
    },
  };
}

trigger/rootly/webhookHelpers.ts

import { RootlyAPIClient } from "~/services/rootly/apiClient";
import crypto from "crypto";

const SUPERPLANE_WEBHOOK_PREFIX = "superplane-managed";

interface WebhookEndpointConfig {
  integrationId: string;
  requestedEventTypes: string[];
  webhookUrl: string;
  secret: string;
}

interface RootlyWebhookEndpoint {
  id: string;
  attributes: {
    name: string;
    url: string;
    event_types: string[];
    secret?: string;
  };
}

/**
 * Generates a deterministic, unique name for a Superplane-managed webhook
 * based on the webhook URL so we can reliably find it later.
 */
function generateDeterministicWebhookName(webhookUrl: string): string {
  const hash = crypto
    .createHash("sha256")
    .update(webhookUrl)
    .digest("hex")
    .slice(0, 12);
  return `${SUPERPLANE_WEBHOOK_PREFIX}-${hash}`;
}

/**
 * Checks whether `existing` event types are a superset of `requested` event types.
 * If so, we don't need to update the webhook.
 */
function isSuperset(existing: string[], requested: string[]): boolean {
  const existingSet = new Set(existing);
  return requested.every((type) => existingSet.has(type));
}

/**
 * Merges two arrays of event types, returning a deduplicated union.
 */
export function mergeEventTypes(
  existing: string[],
  incoming: string[]
): string[] {
  return Array.from(new Set([...existing, ...incoming]));
}

/**
 * Finds an existing Superplane-managed webhook endpoint for the given URL,
 * or creates a new one. If found but missing some event types, updates it
 * using superset merge logic.
 */
export async function findOrCreateWebhookEndpoint(
  config: WebhookEndpointConfig
): Promise<RootlyWebhookEndpoint> {
  const client = new RootlyAPIClient(config.integrationId);
  const deterministicName = generateDeterministicWebhookName(config.webhookUrl);

  // Fetch all existing webhook endpoints
  const existingEndpoints = await client.listWebhookEndpoints();

  const match = existingEndpoints.find(
    (ep: RootlyWebhookEndpoint) => ep.attributes.name === deterministicName
  );

  if (match) {
    const existingTypes: string[] = match.attributes.event_types ?? [];

    if (isSuperset(existingTypes, config.requestedEventTypes)) {
      // Existing webhook already covers requested event types — reuse it
      return match;
    }

    // Merge and update
    const mergedTypes = mergeEventTypes(existingTypes, config.requestedEventTypes);

    const updated = await client.updateWebhookEndpoint(match.id, {
      name: deterministicName,
      url: config.webhookUrl,
      event_types: mergedTypes,
      secret: config.secret,
    });

    return updated;
  }

  // Create a new webhook endpoint
  const created = await client.createWebhookEndpoint({
    name: deterministicName,
    url: config.webhookUrl,
    event_types: config.requestedEventTypes,
    secret: config.secret,
  });

  return created;
}

trigger/rootly/webhookHandler.ts

import { Request, Response } from "express";
import crypto from "crypto";
import { triggerWorkflows } from "~/services/workflow/trigger";
import { ROOTLY_ON_EVENT_ID } from "./onEvent";

interface RootlyEventPayload {
  data: {
    type: string;
    attributes: {
      kind: string;
      source: string;
      summary: string;
      occurred_at: string;
    };
    relationships?: {
      incident?: {
        data: { id: string };
      };
    };
  };
  included?: Array<{
    type: string;
    id: string;
    attributes: Record<string, unknown>;
  }>;
}

/**
 * Verifies the Rootly webhook HMAC-SHA256 signature.
 */
function verifyRootlySignature(
  rawBody: string,
  signature: string,
  secret: string
): boolean {
  const expected = crypto
    .createHmac("sha256", secret)
    .update(rawBody)
    .digest("hex");

  try {
    return crypto.timingSafeEqual(
      Buffer.from(signature, "hex"),
      Buffer.from(expected, "hex")
    );
  } catch {
    return false;
  }
}

/**
 * Extracts the incident record from the `included` array of the Rootly payload.
 */
function extractIncident(payload: RootlyEventPayload) {
  const incidentId =
    payload.data.relationships?.incident?.data?.id;

  if (!incidentId || !payload.included) return null;

  return (
    payload.included.find(
      (item) => item.type === "incidents" && item.id === incidentId
    ) ?? null
  );
}

export async function rootlyWebhookHandler(
  req: Request,
  res: Response
): Promise<void> {
  const signature = req.headers["x-rootly-signature"] as string | undefined;
  const secret = process.env.ROOTLY_WEBHOOK_SECRET ?? "";

  if (!signature) {
    res.status(401).json({ error: "Missing signature header" });
    return;
  }

  const rawBody = (req as Request & { rawBody?: string }).rawBody ?? "";

  const isValid = verifyRootlySignature(rawBody, signature, secret);
  if (!isValid) {
    res.status(401).json({ error: "Invalid signature" });
    return;
  }

  const payload = req.body as RootlyEventPayload;
  const event = payload.data;

  if (!event || event.type !== "incident_events") {
    // Not an event type we handle; acknowledge and skip
    res.status(200).json({ received: true });
    return;
  }

  const incident = extractIncident(payload);

  const context = {
    event: {
      kind: event.attributes.kind,
      source: event.attributes.source,
      summary: event.attributes.summary,
      occurredAt: event.attributes.occurred_at,
    },
    incident: incident
      ? {
          id: incident.id,
          ...incident.attributes,
        }
      : null,
  };

  await triggerWorkflows({
    triggerId: ROOTLY_ON_EVENT_ID,
    context,
    filters: {
      incidentStatus: context.incident
        ? [(context.incident as { status?: string }).status ?? ""]
        : [],
      severity: context.incident
        ? [(context.incident as { severity?: string }).severity ?? ""]
        : [],
      eventKind: [context.event.kind],
      eventSource: [context.event.source],
    },
  });

  res.status(200).json({ received: true });
}

services/rootly/apiClient.ts

import axios, { AxiosInstance } from "axios";

const ROOTLY_API_BASE = "https://api.rootly.com/v1";

interface WebhookEndpointPayload {
  name: string;
  url: string;
  event_types: string[];
  secret?: string;
}

export class RootlyAPIClient {
  private client: AxiosInstance;

  constructor(integrationId: string) {
    const apiToken = this.resolveToken(integrationId);

    this.client = axios.create({
      baseURL: ROOTLY_API_BASE,
      headers: {
        Authorization: `Bearer ${apiToken}`,
        "Content-Type": "application/vnd.api+json",
      },
    });
  }

  private resolveToken(integrationId: string): string {
    // In production this would fetch from a secrets store keyed by integrationId
    const token = process.env[`ROOTLY_API_TOKEN_${integrationId}`]
      ?? process.env.ROOTLY_API_TOKEN;

    if (!token) {
      throw new Error(
        `No Rootly API token found for integration: ${integrationId}`
      );
    }

    return token;
  }

  async listWebhookEndpoints(): Promise<any[]> {
    const response = await this.client.get("/webhook_endpoints");
    return response.data?.data ?? [];
  }

  async createWebhookEndpoint(payload: Webhook

--- 
**Reward Address (if applicable):** `0xa32ca744f86a91eaf567e2be4902f64bc33c2813`

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bounty This issue has a bounty open pr:stage-1/3 Needs to pass basic review.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Rootly] On Event

3 participants