Skip to content

Commit bc06480

Browse files
committed
Improves Event Grid validation and debugging
Enhances subscription validation coverage by handling both standard and header-based flows. Adds a diagnostics endpoint to inspect recent webhook requests. Updates UI and CSS to display expanded event data, improving troubleshooting and user experience.
1 parent 855ed24 commit bc06480

File tree

5 files changed

+168
-23
lines changed

5 files changed

+168
-23
lines changed

server/src/index.ts

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -54,13 +54,41 @@ app.post("/api/webhook/:userPath", (req: Request, res: Response) => {
5454
// Event Grid sends an array of events in the body
5555
const events = Array.isArray(req.body) ? req.body : [req.body];
5656

57-
// Subscription validation event
58-
const validationEvent = events.find((e: any) => e && e.eventType === 'Microsoft.EventGrid.SubscriptionValidationEvent');
57+
// Save last request for diagnostics
58+
try {
59+
lastWebhookDiagnostics.set(userPath, { headers: req.headers, body: events });
60+
} catch (err) {
61+
// ignore
62+
}
63+
64+
// Diagnostic: log aeg-event-type header when present (helps Azure debugging)
65+
try {
66+
const aeg = String(req.header('aeg-event-type') || '');
67+
if (aeg) console.log(`aeg-event-type: ${aeg}`);
68+
} catch (err) {
69+
// ignore
70+
}
71+
72+
// Azure Event Grid subscription validation can arrive in two common shapes:
73+
// 1) Standard validation event: an array with eventType === 'Microsoft.EventGrid.SubscriptionValidationEvent'
74+
// 2) A special header 'aeg-event-type: SubscriptionValidation' with the events array
75+
// Handle both cases robustly.
76+
const validationEvent = events.find((e: any) => e && (e.eventType === 'Microsoft.EventGrid.SubscriptionValidationEvent' || e.eventType === 'Microsoft.EventGridSubscriptionValidationEvent'));
5977
if (validationEvent) {
6078
const data = validationEvent.data || {};
6179
console.log(`EventGrid subscription validation for ${userPath}:`, data);
62-
// Reply with validationResponse per Event Grid requirement
63-
return res.json({ validationResponse: data.validationCode });
80+
return res.status(200).json({ validationResponse: data.validationCode });
81+
}
82+
83+
// Also handle the header-based SubscriptionValidation flow
84+
const aegHeader = (req.header('aeg-event-type') || '').toString();
85+
if (aegHeader === 'SubscriptionValidation' && Array.isArray(events) && events.length > 0) {
86+
const maybe = events[0] as any;
87+
const code = maybe && maybe.data && (maybe.data.validationCode || maybe.data.validationcode || maybe.data.ValidationCode);
88+
if (code) {
89+
console.log(`EventGrid header-based validation for ${userPath}:`, code);
90+
return res.status(200).json({ validationResponse: code });
91+
}
6492
}
6593

6694
// Normal events: log and ack
@@ -96,7 +124,13 @@ app.post("/api/webhook/:userPath", (req: Request, res: Response) => {
96124
// Notify any SSE clients connected to this webhook path
97125
try {
98126
for (const e of events) {
99-
const payload = { id: e.id, eventType: e.eventType, timestamp: e.eventTime || new Date().toISOString() };
127+
const payload = {
128+
id: e.id,
129+
eventType: e.eventType,
130+
timestamp: e.eventTime || new Date().toISOString(),
131+
data: e.data,
132+
raw: e,
133+
};
100134
sendSseToPath(userPath, payload);
101135
}
102136
} catch (err) {
@@ -122,6 +156,9 @@ type EventRecord = {
122156
const EVENT_STORE_CAP = 200;
123157
const eventStore = new Map<string, EventRecord[]>();
124158

159+
// For debugging: store last request headers and body per path
160+
const lastWebhookDiagnostics = new Map<string, { headers: any; body: any }>();
161+
125162
// SSE clients per webhook path
126163
const sseClients = new Map<string, Set<Response>>();
127164

@@ -147,6 +184,14 @@ app.get('/api/webhook/:userPath/events', (_req: Request, res: Response) => {
147184
return res.json({ count: list.length, events: list });
148185
});
149186

187+
// Diagnostic endpoint to inspect last webhook request for a path
188+
app.get('/__diag/webhook/:userPath', (_req: Request, res: Response) => {
189+
const userPath = _req.params.userPath;
190+
const diag = lastWebhookDiagnostics.get(userPath) || null;
191+
const stored = eventStore.get(userPath) || [];
192+
return res.json({ lastRequest: diag, storedCount: stored.length });
193+
});
194+
150195
// Server-Sent Events stream for a webhook path.
151196
app.get('/api/webhook/:userPath/stream', (req: Request, res: Response) => {
152197
const userPath = req.params.userPath;
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
.webhook-events {
2+
margin-top: 16px;
3+
}
4+
.webhook-events-header {
5+
display: flex;
6+
align-items: center;
7+
gap: 8px;
8+
margin-bottom: 8px;
9+
}
10+
.webhook-events-status {
11+
color: gray;
12+
}
13+
.webhook-events-status.live {
14+
color: green;
15+
}
16+
.webhook-events-list {
17+
max-height: 360px;
18+
overflow: auto;
19+
border: 1px solid #eee;
20+
padding: 8px;
21+
}
22+
.webhook-event-item {
23+
padding: 6px 8px;
24+
border-bottom: 1px solid #f1f1f1;
25+
}
26+
.webhook-event-meta {
27+
font-size: 12px;
28+
color: #666;
29+
}
30+
.webhook-event-type {
31+
font-weight: 600;
32+
}
33+
.webhook-event-pre {
34+
margin-top: 8px;
35+
white-space: pre-wrap;
36+
background: #fafafa;
37+
padding: 8px;
38+
border-radius: 4px;
39+
}
40+
41+
.webhook-events-ul {
42+
list-style: none;
43+
padding: 0;
44+
margin: 0;
45+
}
46+
47+
.webhook-event-header {
48+
display: flex;
49+
justify-content: space-between;
50+
align-items: center;
51+
}

src/components/WebhookEventsPanel.tsx

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
import React from 'react';
2+
import './WebhookEventsPanel.css';
23

34
type EventItem = {
45
id?: string;
56
eventType: string;
67
timestamp: string;
8+
data?: any;
9+
raw?: any;
10+
expanded?: boolean;
711
};
812

913
interface Props {
@@ -25,7 +29,7 @@ export const WebhookEventsPanel: React.FC<Props> = ({ userPath }) => {
2529
const j = await r.json();
2630
if (!cancelled && Array.isArray(j.events)) {
2731
// ensure newest-first
28-
setEvents(j.events.map((e: any) => ({ id: e.id, eventType: e.eventType, timestamp: e.timestamp })));
32+
setEvents(j.events.map((e: any) => ({ id: e.id, eventType: e.eventType, timestamp: e.timestamp, data: e.data, raw: e.raw })));
2933
}
3034
} catch (err) {
3135
console.warn('Failed to load events', err);
@@ -54,8 +58,8 @@ export const WebhookEventsPanel: React.FC<Props> = ({ userPath }) => {
5458
es.onmessage = (ev) => {
5559
try {
5660
const data = JSON.parse(ev.data);
57-
// Prepend newest-first
58-
setEvents(prev => [{ id: data.id, eventType: data.eventType, timestamp: data.timestamp }, ...prev]);
61+
// Prepend newest-first, include data/raw if present
62+
setEvents(prev => [{ id: data.id, eventType: data.eventType, timestamp: data.timestamp, data: data.data, raw: data.raw }, ...prev]);
5963
} catch (err) {
6064
console.warn('Invalid SSE payload', err);
6165
}
@@ -72,20 +76,32 @@ export const WebhookEventsPanel: React.FC<Props> = ({ userPath }) => {
7276
}, [userPath]);
7377

7478
return (
75-
<div style={{ marginTop: 16 }}>
76-
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
79+
<div className="webhook-events">
80+
<div className="webhook-events-header">
7781
<strong>Events</strong>
78-
<span style={{ color: connected ? 'green' : 'gray' }}>{connected ? 'live' : 'disconnected'}</span>
82+
<span className={`webhook-events-status ${connected ? 'live' : ''}`}>{connected ? 'live' : 'disconnected'}</span>
7983
</div>
80-
<div style={{ maxHeight: 360, overflow: 'auto', border: '1px solid #eee', padding: 8 }}>
84+
<div className="webhook-events-list">
8185
{events.length === 0 ? (
8286
<div>No events yet.</div>
8387
) : (
84-
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
88+
<ul className="webhook-events-ul">
8589
{events.map((e, idx) => (
86-
<li key={e.id || idx} style={{ padding: '6px 8px', borderBottom: '1px solid #f1f1f1' }}>
87-
<div style={{ fontSize: 12, color: '#666' }}>{new Date(e.timestamp).toLocaleString()}</div>
88-
<div style={{ fontWeight: 600 }}>{e.eventType}</div>
90+
<li key={e.id || idx} className="webhook-event-item">
91+
<div className="webhook-event-header">
92+
<div>
93+
<div className="webhook-event-meta">{new Date(e.timestamp).toLocaleString()}</div>
94+
<div className="webhook-event-type">{e.eventType}</div>
95+
</div>
96+
<div>
97+
<button onClick={() => {
98+
setEvents(prev => prev.map((it, i) => i === idx ? { ...it, expanded: !it.expanded } : it));
99+
}}>{e.expanded ? 'Hide' : 'Show'}</button>
100+
</div>
101+
</div>
102+
{e.expanded && (
103+
<pre className="webhook-event-pre">{JSON.stringify(e.raw || e.data || {}, null, 2)}</pre>
104+
)}
89105
</li>
90106
))}
91107
</ul>

src/components/WebhookView.css

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
.webhook-view {
2+
padding: 24px;
3+
}
4+
.webhook-title {
5+
margin: 0 0 8px 0;
6+
}
7+
.webhook-loading {
8+
color: #666;
9+
}
10+
.webhook-error {
11+
color: #c92a2a;
12+
}
13+
.webhook-url-row {
14+
display: flex;
15+
gap: 8px;
16+
align-items: center;
17+
max-width: 600px;
18+
}
19+
.webhook-url-input {
20+
flex: 1;
21+
padding: 8px;
22+
border: 1px solid #ddd;
23+
border-radius: 4px;
24+
}
25+
.webhook-copy-button {
26+
padding: 8px 12px;
27+
background: #0078d4;
28+
color: #fff;
29+
border: none;
30+
border-radius: 4px;
31+
cursor: pointer;
32+
}

src/components/WebhookView.tsx

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import React from 'react';
22
import { useQuery, useMutation } from 'urql';
33
import { GET_USER_PROPERTIES_QUERY, APP_SCRIPT_APPLICATION, SET_USER_PROPERTIES_MUTATION, SetUserPropertiesVariables, SetUserPropertiesData } from '../graphql/userProperties';
44
import WebhookEventsPanel from './WebhookEventsPanel';
5+
import './WebhookView.css';
56

67
export const WebhookView: React.FC = () => {
78
const [result] = useQuery({
@@ -63,16 +64,16 @@ export const WebhookView: React.FC = () => {
6364
}, [result.fetching, result.error, webhookPath, localWebhook, setProperties]);
6465

6566
return (
66-
<div style={{ padding: 24 }}>
67-
<h3>My Webhook URL</h3>
68-
{result.fetching && <p>Loading...</p>}
69-
{result.error && <p style={{ color: 'red' }}>Error loading properties</p>}
67+
<div className="webhook-view">
68+
<h3 className="webhook-title">My Webhook URL</h3>
69+
{result.fetching && <p className="webhook-loading">Loading...</p>}
70+
{result.error && <p className="webhook-error">Error loading properties</p>}
7071
{!result.fetching && !webhookPath && !localWebhook && <p>Creating webhook path...</p>}
7172
{url && (
72-
<div style={{ display: 'flex', gap: 8, alignItems: 'center', maxWidth: 600 }}>
73+
<div className="webhook-url-row">
7374
<label htmlFor="webhook-url" className="visually-hidden">Webhook URL</label>
74-
<input id="webhook-url" readOnly value={url} style={{ flex: 1, padding: 8 }} />
75-
<button onClick={() => { navigator.clipboard?.writeText(url); }} style={{ padding: '8px 12px' }}>Copy</button>
75+
<input id="webhook-url" readOnly value={url} className="webhook-url-input" />
76+
<button onClick={() => { navigator.clipboard?.writeText(url); }} className="webhook-copy-button">Copy</button>
7677
</div>
7778
)}
7879

0 commit comments

Comments
 (0)