feat: Add rootly onEvent trigger#3150
Conversation
Signed-off-by: devroy10 <roychinwuba@gmail.com>
Signed-off-by: devroy10 <roychinwuba@gmail.com>
Signed-off-by: devroy10 <roychinwuba@gmail.com>
|
This is the actual nature of the rootly incident_event payload. It contains the event details and in addition,
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 |
This comment was marked as outdated.
This comment was marked as outdated.
|
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>
|
Added video demos showing the functionality
new-webhook.MOV
no-trigger.MOV
|
🤖 Professional AI Solution (Claude Sonnet 4.6)Reward Address: |
OverviewThis PR adds a Backend:
Frontend:
Backend
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,
},
};
}
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;
}
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 });
}
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` |
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
rootly.onEventtrigger with webhook setup and signature verification.rootly.onEventwith incident + event context.findOrCreateWebhookEndpointand 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 Error401or422reuse/updatelogic.Configuration
Supports optional filtering by
Frontend Implementation
Documentation