Skip to content

Commit cd0e9ed

Browse files
feat: add optional titleEmoji webhook setting to drop the emoji prefix (#3467)
## Summary Closes #3466 I know #3466 hasn't been through feedback yet and CONTRIBUTING asks for `Accepting PR` first. I had already written and tested this before filing the issue, so I'm opening it now in case it helps the discussion. Happy to rework it or close it if you want a different direction. Alert notification titles always start with 🚨 or ✅. That reads well in Slack, but a destination that already says firing or resolved, such as a webhook feeding an email relay, gets the emoji in its subject line with no way to remove it. Following review, this is now a setting on the webhook rather than the alert: an optional boolean `titleEmoji`, where `false` drops the prefix for notifications sent to that webhook. Unset or `true` leaves titles unchanged. An alert that notifies several webhooks through `@webhook-` mentions gets the prefix per destination. - The title is built once without the emoji, and the prefix is added per channel when the dispatch jobs are queued, unless that webhook opts out. - The field follows `description` through the webhook model, the internal and v2 webhook APIs, `openapi.json` and the common-utils types. - The webhook form has a "Show state emoji in notification titles" checkbox, checked by default. - The MCP `saveWebhook` tool takes the field, since its update clears omitted settings. ### How to reproduce **Starting point:** `yarn dev`, with two webhook destinations and a saved-search alert that notifies both (one as its channel, one through an `@webhook-` mention in the message). **On `main`:** 1. Trigger the alert. Both notification titles start with 🚨, and no setting removes it. **On this branch:** 1. In Team Settings, edit the first webhook and untick "Show state emoji in notification titles". 2. Trigger the alert. The first webhook's title has no prefix, the second keeps 🚨, and the resolved notifications follow the same split. 3. `GET /api/v2/webhooks` returns `"titleEmoji": false` for the first webhook. `make dev-int FILE=checkAlerts` runs the two-webhook case. ### How to test on Vercel preview Open Team Settings, edit or create a webhook, and check that the new checkbox saves and reloads. ### References - Linear Issue: - Related PRs: #1339
1 parent 442d6df commit cd0e9ed

19 files changed

Lines changed: 470 additions & 137 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@hyperdx/api': minor
3+
'@hyperdx/app': minor
4+
'@hyperdx/common-utils': minor
5+
---
6+
7+
feat: add optional `titleEmoji` webhook option; `false` removes the emoji prefix from notification titles sent through that webhook

‎packages/api/openapi.json‎

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5827,6 +5827,11 @@
58275827
"description": "Webhook description, shown in the UI",
58285828
"example": "Sends critical alerts to the #incidents channel"
58295829
},
5830+
"titleEmoji": {
5831+
"type": "boolean",
5832+
"description": "Set to false to omit the state emoji prefix from notification titles sent through this webhook. Unset or true keeps the default prefix.",
5833+
"example": false
5834+
},
58305835
"updatedAt": {
58315836
"type": "string",
58325837
"format": "date-time",
@@ -5879,6 +5884,11 @@
58795884
"description": "Webhook description, shown in the UI",
58805885
"example": "Routes alerts to incident.io for on-call escalation"
58815886
},
5887+
"titleEmoji": {
5888+
"type": "boolean",
5889+
"description": "Set to false to omit the state emoji prefix from notification titles sent through this webhook. Unset or true keeps the default prefix.",
5890+
"example": false
5891+
},
58825892
"updatedAt": {
58835893
"type": "string",
58845894
"format": "date-time",
@@ -5931,6 +5941,11 @@
59315941
"description": "Webhook description, shown in the UI",
59325942
"example": "Forwards alert payloads to an external monitoring service"
59335943
},
5944+
"titleEmoji": {
5945+
"type": "boolean",
5946+
"description": "Set to false to omit the state emoji prefix from notification titles sent through this webhook. Unset or true keeps the default prefix.",
5947+
"example": false
5948+
},
59345949
"body": {
59355950
"type": "string",
59365951
"description": "Optional request body template",
@@ -6023,7 +6038,7 @@
60236038
"service",
60246039
"url"
60256040
],
6026-
"description": "Webhook create/update body. `body`, `headers`, and `queryParams`\nonly take effect for services that issue a templated HTTP request\n(`generic`, `incidentio`). The `slack` service posts a fixed payload\nto its incoming-webhook URL and ignores them, so supplying any of\nthese fields on a `slack` webhook is rejected. `headers` and\n`queryParams` are write-only — they are accepted here but never\nreturned by any read endpoint, so secrets such as auth tokens do\nnot leak. On\nupdate (PUT), omitted readable fields (`description`, `body`) are\ncleared, while omitted `headers`/`queryParams` are preserved —\nsend an explicit `{}` to clear them. Exception: if the destination\n(`url` or `service`) changes, omitted `headers`/`queryParams` are\ncleared rather than preserved, so stored secrets are never forwarded\nto a new destination; re-supply them for the new destination.\n",
6041+
"description": "Webhook create/update body. `body`, `headers`, and `queryParams`\nonly take effect for services that issue a templated HTTP request\n(`generic`, `incidentio`). The `slack` service posts a fixed payload\nto its incoming-webhook URL and ignores them, so supplying any of\nthese fields on a `slack` webhook is rejected. `headers` and\n`queryParams` are write-only — they are accepted here but never\nreturned by any read endpoint, so secrets such as auth tokens do\nnot leak. On\nupdate (PUT), omitted readable fields (`description`, `titleEmoji`, `body`) are\ncleared, while omitted `headers`/`queryParams` are preserved —\nsend an explicit `{}` to clear them. Exception: if the destination\n(`url` or `service`) changes, omitted `headers`/`queryParams` are\ncleared rather than preserved, so stored secrets are never forwarded\nto a new destination; re-supply them for the new destination.\n",
60276042
"properties": {
60286043
"name": {
60296044
"type": "string",
@@ -6054,6 +6069,11 @@
60546069
"description": "Webhook description, shown in the UI.",
60556070
"example": "Sends critical alerts to the #incidents channel"
60566071
},
6072+
"titleEmoji": {
6073+
"type": "boolean",
6074+
"description": "Set to false to omit the state emoji prefix from notification titles sent through this webhook. Unset or true keeps the default prefix.",
6075+
"example": false
6076+
},
60576077
"body": {
60586078
"type": "string",
60596079
"maxLength": 16384,
@@ -9178,7 +9198,7 @@
91789198
"/api/v2/webhooks/{id}": {
91799199
"put": {
91809200
"summary": "Update Webhook",
9181-
"description": "Replaces an existing webhook. Readable optional fields\n(`description`, `body`) are a full replace: omitting them clears\nthem. The write-only fields `headers` and `queryParams` are never\nreturned on read, so omitting them preserves the stored values;\nsend an explicit empty object (`{}`) to clear them. Exception: if the\ndestination (`url` or `service`) changes, omitted `headers`/\n`queryParams` are cleared rather than preserved so stored secrets are\nnever forwarded to a new destination.\n",
9201+
"description": "Replaces an existing webhook. Readable optional fields\n(`description`, `titleEmoji`, `body`) are a full replace: omitting them clears\nthem. The write-only fields `headers` and `queryParams` are never\nreturned on read, so omitting them preserves the stored values;\nsend an explicit empty object (`{}`) to clear them. Exception: if the\ndestination (`url` or `service`) changes, omitted `headers`/\n`queryParams` are cleared rather than preserved so stored secrets are\nnever forwarded to a new destination.\n",
91829202
"operationId": "updateWebhook",
91839203
"tags": [
91849204
"Webhooks"

‎packages/api/src/controllers/webhook.ts‎

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export interface WebhookInput {
1111
service: WebhookService;
1212
url: string;
1313
description?: string;
14+
titleEmoji?: boolean;
1415
queryParams?: Record<string, string>;
1516
headers?: Record<string, string>;
1617
body?: string;
@@ -22,7 +23,16 @@ export interface WebhookInput {
2223
*/
2324
export async function createWebhook(
2425
team: ObjectId | string,
25-
{ name, service, url, description, queryParams, headers, body }: WebhookInput,
26+
{
27+
name,
28+
service,
29+
url,
30+
description,
31+
titleEmoji,
32+
queryParams,
33+
headers,
34+
body,
35+
}: WebhookInput,
2636
) {
2737
validateWebhookConfig({ service, url, body });
2838

@@ -32,6 +42,7 @@ export async function createWebhook(
3242
service,
3343
url,
3444
description,
45+
titleEmoji,
3546
queryParams,
3647
headers,
3748
body,
@@ -53,7 +64,16 @@ export type UpdateWebhookResult =
5364
export async function updateWebhook(
5465
team: ObjectId | string,
5566
webhookId: string,
56-
{ name, service, url, description, queryParams, headers, body }: WebhookInput,
67+
{
68+
name,
69+
service,
70+
url,
71+
description,
72+
titleEmoji,
73+
queryParams,
74+
headers,
75+
body,
76+
}: WebhookInput,
5777
): Promise<UpdateWebhookResult> {
5878
const existing = await Webhook.findOne({ _id: webhookId, team });
5979
if (existing == null) {
@@ -71,6 +91,9 @@ export async function updateWebhook(
7191
if (description === undefined) unsetFields.description = 1;
7292
else setFields.description = description;
7393

94+
if (titleEmoji === undefined) unsetFields.titleEmoji = 1;
95+
else setFields.titleEmoji = titleEmoji;
96+
7497
if (body === undefined) unsetFields.body = 1;
7598
else setFields.body = body;
7699

‎packages/api/src/mcp/tools/alerts/saveWebhook.ts‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,14 @@ const mcpSaveWebhookSchema = z.object({
5959
'Description shown in the UI. On update this is a full replace: ' +
6060
'omitting it clears the stored description.',
6161
),
62+
titleEmoji: z
63+
.boolean()
64+
.optional()
65+
.describe(
66+
'Set to false to omit the state emoji prefix from notification titles ' +
67+
'sent through this webhook. On update this is a full replace: ' +
68+
'omitting it restores the default prefix.',
69+
),
6270
headers: z
6371
.record(z.string(), z.string())
6472
.optional()
@@ -105,8 +113,8 @@ export function registerSaveWebhook({
105113
'clickstack_save_alert. For the slack service the url host must end ' +
106114
'in slack.com and headers/queryParams/body are not supported. On ' +
107115
'update, every alert using this webhook sends its next notifications ' +
108-
'with the new settings, including a changed url. description and body ' +
109-
'are a full replace (omitting them clears them), while write-only ' +
116+
'with the new settings, including a changed url. description, titleEmoji and ' +
117+
'body are a full replace (omitting them clears them), while write-only ' +
110118
'headers/queryParams are preserved when omitted (send {} to clear); ' +
111119
'changing url or service clears omitted headers/queryParams. ' +
112120
'url, headers, and queryParams are never returned.',

‎packages/api/src/models/webhook.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ export interface IWebhook {
2323
updatedAt: Date;
2424
url?: string;
2525
description?: string;
26+
// false drops the state emoji prefix from notification titles
27+
titleEmoji?: boolean;
2628
// reminder to serialize/convert the Mongoose model instance to a plain javascript object when using
2729
// to strip the additional properties that are related to the Mongoose internal representation -> webhook.headers.toJSON()
2830
queryParams?: MongooseMap;
@@ -50,6 +52,10 @@ const WebhookSchema = new Schema<IWebhook>(
5052
type: String,
5153
required: false,
5254
},
55+
titleEmoji: {
56+
type: Boolean,
57+
required: false,
58+
},
5359
queryParams: {
5460
type: Map,
5561
of: String,

‎packages/api/src/routers/api/__tests__/webhooks.int.test.ts‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,44 @@ describe('webhooks router', () => {
121121
expect(webhooks[0].url).toBe(MOCK_WEBHOOK.url);
122122
});
123123

124+
it('POST / - stores titleEmoji false and GET / returns it', async () => {
125+
const { agent } = await getLoggedInAgent(server);
126+
127+
const response = await agent
128+
.post('/webhooks')
129+
.send({ ...MOCK_WEBHOOK, titleEmoji: false })
130+
.expect(200);
131+
132+
expect(response.body.data.titleEmoji).toBe(false);
133+
134+
const webhooks = await Webhook.find({});
135+
expect(webhooks).toHaveLength(1);
136+
expect(webhooks[0].titleEmoji).toBe(false);
137+
138+
const listResponse = await agent
139+
.get('/webhooks')
140+
.query({ service: WebhookService.Slack })
141+
.expect(200);
142+
143+
expect(listResponse.body.data).toHaveLength(1);
144+
expect(listResponse.body.data[0].titleEmoji).toBe(false);
145+
});
146+
147+
it('POST / - leaves titleEmoji unset when not provided', async () => {
148+
const { agent } = await getLoggedInAgent(server);
149+
150+
const response = await agent
151+
.post('/webhooks')
152+
.send(MOCK_WEBHOOK)
153+
.expect(200);
154+
155+
expect(response.body.data).not.toHaveProperty('titleEmoji');
156+
157+
const webhooks = await Webhook.find({});
158+
expect(webhooks).toHaveLength(1);
159+
expect(webhooks[0].titleEmoji).toBeUndefined();
160+
});
161+
124162
it('POST / - returns 400 when webhook with same URL already exists', async () => {
125163
const { agent, team } = await getLoggedInAgent(server);
126164

@@ -787,6 +825,25 @@ describe('webhooks router', () => {
787825
});
788826
});
789827

828+
it('sets titleEmoji on an existing webhook', async () => {
829+
const { agent, team } = await getLoggedInAgent(server);
830+
831+
const webhook = await Webhook.create({
832+
...MOCK_WEBHOOK,
833+
team: team._id,
834+
});
835+
836+
const response = await agent
837+
.put(`/webhooks/${webhook._id}`)
838+
.send({ ...MOCK_WEBHOOK, titleEmoji: false })
839+
.expect(200);
840+
841+
expect(response.body.data.titleEmoji).toBe(false);
842+
843+
const updatedWebhook = await Webhook.findById(webhook._id);
844+
expect(updatedWebhook?.titleEmoji).toBe(false);
845+
});
846+
790847
it('returns 404 when webhook does not exist', async () => {
791848
const { agent } = await getLoggedInAgent(server);
792849

‎packages/api/src/routers/api/webhooks.ts‎

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@ router.post(
167167
body: z.object({
168168
body: z.string().optional(),
169169
description: z.string().optional(),
170+
titleEmoji: z.boolean().optional(),
170171
headers: z
171172
.record(webhookHeaderNameSchema, webhookHeaderValueSchema)
172173
.optional(),
@@ -188,8 +189,16 @@ router.post(
188189
if (teamId == null) {
189190
return res.sendStatus(403);
190191
}
191-
const { name, service, url, description, queryParams, headers, body } =
192-
req.body;
192+
const {
193+
name,
194+
service,
195+
url,
196+
description,
197+
titleEmoji,
198+
queryParams,
199+
headers,
200+
body,
201+
} = req.body;
193202
// The unique index is on (team, service, name), so the pre-flight check
194203
// must query the same fields — otherwise a name+service collision slips
195204
// past this guard and surfaces as an uncaught duplicate-key 500 below.
@@ -203,6 +212,7 @@ router.post(
203212
service,
204213
url,
205214
description,
215+
titleEmoji,
206216
queryParams,
207217
headers,
208218
body,
@@ -233,6 +243,7 @@ router.put(
233243
body: z.object({
234244
body: z.string().optional(),
235245
description: z.string().optional(),
246+
titleEmoji: z.boolean().optional(),
236247
headers: z
237248
.record(webhookHeaderNameSchema, webhookHeaderValueSchema)
238249
.optional(),
@@ -254,8 +265,16 @@ router.put(
254265
if (teamId == null) {
255266
return res.sendStatus(403);
256267
}
257-
const { name, service, url, description, queryParams, headers, body } =
258-
req.body;
268+
const {
269+
name,
270+
service,
271+
url,
272+
description,
273+
titleEmoji,
274+
queryParams,
275+
headers,
276+
body,
277+
} = req.body;
259278
const { id } = req.params;
260279

261280
const existingWebhook = await Webhook.findOne({
@@ -323,6 +342,7 @@ router.put(
323342
service,
324343
url: resolvedUrl,
325344
description,
345+
titleEmoji,
326346
body,
327347
};
328348
const $unset: Record<string, 1> = {};

‎packages/api/src/routers/external-api/__tests__/webhooks.int.test.ts‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,27 @@ describe('External API v2 Webhooks', () => {
331331
.expect(401);
332332
});
333333

334+
it('should round-trip titleEmoji through create and list', async () => {
335+
const response = await authRequest('post', WEBHOOKS_BASE_URL)
336+
.send({ ...MOCK_SLACK_WEBHOOK, titleEmoji: false })
337+
.expect(200);
338+
expect(response.body.data.titleEmoji).toBe(false);
339+
340+
const stored = await Webhook.findById(response.body.data.id).lean();
341+
expect(stored?.titleEmoji).toBe(false);
342+
343+
const list = await authRequest('get', WEBHOOKS_BASE_URL).expect(200);
344+
expect(list.body.data[0].titleEmoji).toBe(false);
345+
});
346+
347+
it('should leave titleEmoji unset when not provided', async () => {
348+
const response = await authRequest('post', WEBHOOKS_BASE_URL)
349+
.send(MOCK_SLACK_WEBHOOK)
350+
.expect(200);
351+
352+
expect(response.body.data).not.toHaveProperty('titleEmoji');
353+
});
354+
334355
it('should create a Slack webhook and never return sensitive fields', async () => {
335356
const response = await authRequest('post', WEBHOOKS_BASE_URL)
336357
.send(MOCK_GENERIC_WEBHOOK)
@@ -635,6 +656,36 @@ describe('External API v2 Webhooks', () => {
635656
expect(stored?.description).toBeUndefined();
636657
});
637658

659+
it('should set titleEmoji on update and clear it when omitted', async () => {
660+
const created = await Webhook.create({
661+
...MOCK_SLACK_WEBHOOK,
662+
team: team._id,
663+
});
664+
const base = {
665+
name: MOCK_SLACK_WEBHOOK.name,
666+
service: WebhookService.Slack,
667+
url: MOCK_SLACK_WEBHOOK.url,
668+
};
669+
670+
const set = await authRequest(
671+
'put',
672+
`${WEBHOOKS_BASE_URL}/${created._id}`,
673+
)
674+
.send({ ...base, titleEmoji: false })
675+
.expect(200);
676+
expect(set.body.data.titleEmoji).toBe(false);
677+
expect((await Webhook.findById(created._id).lean())?.titleEmoji).toBe(
678+
false,
679+
);
680+
681+
await authRequest('put', `${WEBHOOKS_BASE_URL}/${created._id}`)
682+
.send(base)
683+
.expect(200);
684+
expect(
685+
(await Webhook.findById(created._id).lean())?.titleEmoji,
686+
).toBeUndefined();
687+
});
688+
638689
it('should preserve omitted queryParams when the destination is unchanged', async () => {
639690
const created = await Webhook.create({
640691
...MOCK_GENERIC_WEBHOOK,

0 commit comments

Comments
 (0)