Skip to content

Commit a534732

Browse files
authored
feat: send test webhook (#4737)
* feat: send test webhook * chore: lint
1 parent 95a4c68 commit a534732

File tree

3 files changed

+275
-11
lines changed

3 files changed

+275
-11
lines changed

valhalla/jawn/src/controllers/public/webhookController.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { Result, err, ok } from "../../packages/common/result";
1414
import { type JawnAuthenticatedRequest } from "../../types/request";
1515
import crypto from "crypto";
1616
import { dbExecute } from "../../lib/shared/db/dbExecute";
17+
import { sendTestWebhook } from "../../lib/clients/webhookSender";
1718

1819
export interface WebhookData {
1920
destination: string;
@@ -140,4 +141,44 @@ export class WebhookController extends Controller {
140141
return ok(null);
141142
}
142143
}
144+
145+
@Post("/{webhookId}/test")
146+
public async testWebhook(
147+
@Path() webhookId: string,
148+
@Request() request: JawnAuthenticatedRequest
149+
): Promise<Result<{ success: boolean; message: string }, string>> {
150+
// Fetch the webhook configuration
151+
const webhookResult = await dbExecute<{
152+
id: string;
153+
destination: string;
154+
config: string;
155+
hmac_key: string;
156+
}>(
157+
`SELECT id, destination, config, hmac_key
158+
FROM webhooks
159+
WHERE id = $1 AND org_id = $2`,
160+
[webhookId, request.authParams.organizationId]
161+
);
162+
163+
if (webhookResult.error || !webhookResult.data || webhookResult.data.length === 0) {
164+
this.setStatus(404);
165+
return err("Webhook not found");
166+
}
167+
168+
const webhook = webhookResult.data[0];
169+
170+
// Send test webhook with mock data
171+
const testResult = await sendTestWebhook(webhook);
172+
173+
if (testResult.error) {
174+
this.setStatus(500);
175+
return err(testResult.error);
176+
}
177+
178+
this.setStatus(200);
179+
return ok({
180+
success: true,
181+
message: testResult.data || "Test webhook sent successfully"
182+
});
183+
}
143184
}

valhalla/jawn/src/lib/clients/webhookSender.ts

Lines changed: 172 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { Database } from "../db/database.types";
22
import { createHmac } from "crypto";
3-
import { PromiseGenericResult, ok } from "../../packages/common/result";
3+
import { PromiseGenericResult, ok, err } from "../../packages/common/result";
44
import { WebhookConfig } from "../shared/types";
5+
import { randomUUID } from "crypto";
56

67
export type WebhookPayload = {
78
payload: {
@@ -168,3 +169,173 @@ export async function sendToWebhook(
168169

169170
return ok(`Successfully sent to webhook`);
170171
}
172+
173+
// Generate mock data for testing webhooks
174+
function generateMockWebhookData(): WebhookData {
175+
const requestId = randomUUID();
176+
const timestamp = Math.floor(Date.now() / 1000);
177+
178+
// Mock OpenAI chat completion request
179+
const requestBody = {
180+
model: "gpt-4o",
181+
messages: [
182+
{
183+
role: "user",
184+
content: "test message"
185+
}
186+
]
187+
};
188+
189+
// Mock OpenAI chat completion response
190+
const responseBody = {
191+
id: `chatcmpl-${randomUUID().substring(0, 29)}`,
192+
object: "chat.completion",
193+
created: timestamp,
194+
model: "gpt-4o-2024-08-06",
195+
choices: [
196+
{
197+
index: 0,
198+
message: {
199+
role: "assistant",
200+
content: "Hey! Not much, just here to help you out. What's up with you?",
201+
refusal: null,
202+
annotations: []
203+
},
204+
logprobs: null,
205+
finish_reason: "stop"
206+
}
207+
],
208+
usage: {
209+
prompt_tokens: 13,
210+
completion_tokens: 17,
211+
total_tokens: 30,
212+
prompt_tokens_details: {
213+
cached_tokens: 0,
214+
audio_tokens: 0
215+
},
216+
completion_tokens_details: {
217+
reasoning_tokens: 0,
218+
audio_tokens: 0,
219+
accepted_prediction_tokens: 0,
220+
rejected_prediction_tokens: 0
221+
}
222+
},
223+
service_tier: "default",
224+
system_fingerprint: `fp_${randomUUID().substring(0, 14)}`
225+
};
226+
227+
// Generate mock S3 URL with AWS signature
228+
const s3Url = `https://s3.us-west-2.amazonaws.com/request-response-storage/organizations/${randomUUID()}/requests/${requestId}/request_response_body?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=MOCKAWSCREDENTIAL%2F${new Date().toISOString().split('T')[0].replace(/-/g, '')}%2Fus-west-2%2Fs3%2Faws4_request&X-Amz-Date=${new Date().toISOString().replace(/[:-]/g, '').split('.')[0]}Z&X-Amz-Expires=86400&X-Amz-Security-Token=MockSecurityToken&X-Amz-Signature=mocksignature123456789&X-Amz-SignedHeaders=host&x-amz-checksum-mode=ENABLED&x-id=GetObject`;
229+
230+
return {
231+
request_id: requestId,
232+
request_body: JSON.stringify(requestBody),
233+
response_body: JSON.stringify(responseBody),
234+
request_response_url: s3Url,
235+
model: "gpt-4o-2024-08-06",
236+
provider: "OPENAI",
237+
metadata: {
238+
cost: 0.00020250000000000002,
239+
promptTokens: 13,
240+
completionTokens: 17,
241+
totalTokens: 30,
242+
latencyMs: 930
243+
}
244+
};
245+
}
246+
247+
// Test webhook sender without delay
248+
export async function sendTestWebhook(
249+
webhook: {
250+
id: string;
251+
destination: string;
252+
config: string | any;
253+
hmac_key: string;
254+
}
255+
): PromiseGenericResult<string> {
256+
try {
257+
const hmacKey = webhook.hmac_key ?? "";
258+
let config: WebhookConfig;
259+
260+
// Handle both string and object configs (database might return either)
261+
if (typeof webhook.config === 'string') {
262+
try {
263+
config = (JSON.parse(webhook.config) as WebhookConfig) || {};
264+
} catch (parseError) {
265+
return err(`Failed to parse webhook config: ${parseError instanceof Error ? parseError.message : 'Invalid JSON'}`);
266+
}
267+
} else {
268+
config = (webhook.config as WebhookConfig) || {};
269+
}
270+
271+
const includeData = config.includeData !== false;
272+
273+
if (
274+
!webhook.destination ||
275+
typeof webhook.destination !== "string" ||
276+
!webhook.destination.startsWith("https://")
277+
) {
278+
return err(`Invalid destination URL. Must start with https://`);
279+
}
280+
281+
// Generate mock data
282+
const mockData = generateMockWebhookData();
283+
284+
// Create webhook payload based on includeData setting
285+
const webHookPayloadObj: WebhookData = {
286+
request_id: mockData.request_id,
287+
request_body: mockData.request_body,
288+
response_body: mockData.response_body,
289+
};
290+
291+
// Add additional data if includeData is true
292+
if (includeData) {
293+
webHookPayloadObj.request_response_url = mockData.request_response_url;
294+
webHookPayloadObj.model = mockData.model;
295+
webHookPayloadObj.provider = mockData.provider;
296+
webHookPayloadObj.metadata = mockData.metadata;
297+
}
298+
299+
const webHookPayload = JSON.stringify(webHookPayloadObj);
300+
301+
// Generate HMAC signature
302+
const hmac = createHmac("sha256", hmacKey);
303+
hmac.update(webHookPayload);
304+
const hash = hmac.digest("hex");
305+
306+
// Set a shorter timeout for test webhooks (10 seconds)
307+
const controller = new AbortController();
308+
const timeoutId = setTimeout(() => controller.abort(), 10 * 1000);
309+
310+
try {
311+
const response = await fetch(webhook.destination, {
312+
method: "POST",
313+
body: webHookPayload,
314+
headers: {
315+
"Content-Type": "application/json",
316+
"Helicone-Signature": hash,
317+
},
318+
signal: controller.signal,
319+
});
320+
321+
if (!response.ok) {
322+
throw new Error(
323+
`Webhook test failed with status ${response.status}: ${response.statusText}`
324+
);
325+
}
326+
} finally {
327+
clearTimeout(timeoutId);
328+
}
329+
} catch (error: unknown) {
330+
if (error instanceof Error && error.name === "AbortError") {
331+
return err("Test webhook request timed out after 10 seconds");
332+
}
333+
return err(
334+
`Test webhook failed: ${
335+
error instanceof Error ? error.message : String(error)
336+
}`
337+
);
338+
}
339+
340+
return ok(`Test webhook sent successfully`);
341+
}

web/components/templates/webhooks/webhooksPage.tsx

Lines changed: 62 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
EyeIcon,
44
EyeSlashIcon,
55
PlusIcon,
6+
BeakerIcon,
67
} from "@heroicons/react/24/outline";
78
import { useMutation, useQuery } from "@tanstack/react-query";
89
import { useEffect, useState } from "react";
@@ -45,6 +46,7 @@ const WebhooksPage = (props: WebhooksPageProps) => {
4546
undefined,
4647
);
4748
const [showChangelogBanner, setShowChangelogBanner] = useState(true);
49+
const [testingWebhook, setTestingWebhook] = useState<string | null>(null);
4850

4951
const [visibleHmacKeys, setVisibleHmacKeys] = useState<
5052
Record<string, boolean>
@@ -149,6 +151,33 @@ const WebhooksPage = (props: WebhooksPageProps) => {
149151
},
150152
});
151153

154+
const testWebhook = useMutation({
155+
mutationFn: async (id: string) => {
156+
const jawn = getJawnClient(org?.currentOrg?.id);
157+
return jawn.POST(`/v1/webhooks/{webhookId}/test`, {
158+
params: {
159+
path: {
160+
webhookId: id,
161+
},
162+
},
163+
});
164+
},
165+
onSuccess: (data) => {
166+
const response = data as any;
167+
if (response?.data?.success) {
168+
setNotification("Test webhook sent successfully!", "success");
169+
} else {
170+
setNotification(response?.data?.message || "Test webhook sent", "info");
171+
}
172+
},
173+
onError: (error: Error) => {
174+
setNotification(`Test failed: ${error.message}`, "error");
175+
},
176+
onSettled: () => {
177+
setTestingWebhook(null);
178+
},
179+
});
180+
152181
const handleAddWebhook = () => {
153182
setAddWebhookOpen(true);
154183
};
@@ -377,16 +406,39 @@ const WebhooksPage = (props: WebhooksPageProps) => {
377406
</div>
378407
</TableCell>
379408
<TableCell>
380-
<Button
381-
variant="destructive"
382-
size="sm"
383-
className="ml-2 text-white"
384-
onClick={() => {
385-
deleteWebhook.mutate(webhook.id);
386-
}}
387-
>
388-
Delete
389-
</Button>
409+
<div className="flex gap-2">
410+
<Button
411+
variant="outline"
412+
size="sm"
413+
onClick={() => {
414+
setTestingWebhook(webhook.id);
415+
testWebhook.mutate(webhook.id);
416+
}}
417+
disabled={testingWebhook === webhook.id}
418+
>
419+
{testingWebhook === webhook.id ? (
420+
<>
421+
<div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
422+
<span className="ml-1">Testing...</span>
423+
</>
424+
) : (
425+
<>
426+
<BeakerIcon className="mr-1 h-4 w-4" />
427+
Test
428+
</>
429+
)}
430+
</Button>
431+
<Button
432+
variant="destructive"
433+
size="sm"
434+
className="text-white"
435+
onClick={() => {
436+
deleteWebhook.mutate(webhook.id);
437+
}}
438+
>
439+
Delete
440+
</Button>
441+
</div>
390442
</TableCell>
391443
</TableRow>
392444
))}

0 commit comments

Comments
 (0)