Skip to content

Commit b792219

Browse files
committed
improvement(webhooks): add trigger-age instrumentation + guard env decryption
- Add dispatch-latency / trigger-age instrumentation: capture webhook receipt time + Slack x-slack-request-timestamp at the route and log structured dispatchLatencyMs + triggerAgeMs before execution, surfacing the pre-execution latency that per-block timings cannot see (Slack trigger_id expires at 3s). - Guard the effective-env fetch in verifyProviderAuth: only fetch+decrypt when the handler verifies auth AND the providerConfig references env vars ({{VAR}}), avoiding a needless DB read/decrypt on the synchronous pre-ack path. The guard scope exactly matches resolveProviderConfigEnvVars, so resolution is identical.
1 parent d9da544 commit b792219

3 files changed

Lines changed: 72 additions & 11 deletions

File tree

apps/sim/app/api/webhooks/trigger/[path]/route.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,17 @@ async function handleWebhookPost(
6969
request: NextRequest,
7070
context: { params: Promise<{ path: string }> }
7171
): Promise<NextResponse> {
72+
const receivedAt = Date.now()
73+
/**
74+
* Slack signs every interactive request with the originating interaction time.
75+
* Capturing it lets the executor surface the true trigger_id age (the window
76+
* that expires at 3s) instead of only the in-workflow block timings.
77+
*/
78+
const slackRequestTimestamp = request.headers.get('x-slack-request-timestamp')
79+
const triggerTimestampMs = slackRequestTimestamp
80+
? Number(slackRequestTimestamp) * 1000
81+
: undefined
82+
7283
const requestId = generateRequestId()
7384
const parsed = await parseRequest(webhookTriggerPostContract, request, context)
7485
if (!parsed.success) return parsed.response
@@ -200,6 +211,8 @@ async function handleWebhookPost(
200211
actorUserId: preprocessResult.actorUserId,
201212
executionId: preprocessResult.executionId,
202213
correlation: preprocessResult.correlation,
214+
receivedAt,
215+
triggerTimestampMs: Number.isFinite(triggerTimestampMs) ? triggerTimestampMs : undefined,
203216
})
204217
responses.push(response)
205218
}

apps/sim/background/webhook-execution.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,10 @@ export type WebhookExecutionPayload = {
236236
blockId?: string
237237
workspaceId?: string
238238
credentialId?: string
239+
/** Epoch ms when the webhook HTTP request was first received (for dispatch-latency metrics). */
240+
webhookReceivedAt?: number
241+
/** Epoch ms of the originating provider interaction (e.g. Slack x-slack-request-timestamp). */
242+
triggerTimestampMs?: number
239243
}
240244

241245
export async function executeWebhookJob(payload: WebhookExecutionPayload) {
@@ -564,6 +568,24 @@ async function executeWebhookJobInternal(
564568

565569
const triggerInput = input || {}
566570

571+
/**
572+
* Surface the pre-execution latency that per-block timings cannot see: the
573+
* gap between webhook receipt and the first block running, and — for
574+
* trigger_id-bound providers like Slack — the true age of the interaction
575+
* against its 3s expiry window. Logged structured so it is queryable/alarmable.
576+
*/
577+
if (payload.webhookReceivedAt !== undefined || payload.triggerTimestampMs !== undefined) {
578+
const now = Date.now()
579+
logger.info(`[${requestId}] Webhook dispatch latency`, {
580+
workflowId: payload.workflowId,
581+
provider: payload.provider,
582+
dispatchLatencyMs:
583+
payload.webhookReceivedAt !== undefined ? now - payload.webhookReceivedAt : undefined,
584+
triggerAgeMs:
585+
payload.triggerTimestampMs !== undefined ? now - payload.triggerTimestampMs : undefined,
586+
})
587+
}
588+
567589
const snapshot = new ExecutionSnapshot(
568590
metadata,
569591
workflowRecord,

apps/sim/lib/webhooks/processor.ts

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ export interface WebhookProcessorOptions {
3939
actorUserId?: string
4040
executionId?: string
4141
correlation?: AsyncExecutionCorrelation
42+
/** Epoch ms when the webhook HTTP request was first received (for dispatch-latency metrics). */
43+
receivedAt?: number
44+
/** Epoch ms of the originating provider interaction (e.g. Slack x-slack-request-timestamp). */
45+
triggerTimestampMs?: number
4246
}
4347

4448
export interface WebhookPreprocessingResult {
@@ -406,6 +410,16 @@ function resolveEnvVars(value: string, envVars: Record<string, string>): string
406410
return resolveEnvVarReferences(value, envVars) as string
407411
}
408412

413+
/** True when any string value in the provider config contains an env-var reference (`{{VAR}}`). */
414+
function providerConfigReferencesEnvVars(config: Record<string, unknown>): boolean {
415+
for (const value of Object.values(config)) {
416+
if (typeof value === 'string' && value.includes('{{')) {
417+
return true
418+
}
419+
}
420+
return false
421+
}
422+
409423
function resolveProviderConfigEnvVars(
410424
config: Record<string, unknown>,
411425
envVars: Record<string, string>
@@ -432,22 +446,30 @@ export async function verifyProviderAuth(
432446
rawBody: string,
433447
requestId: string
434448
): Promise<NextResponse | null> {
449+
const handler = getProviderHandler(foundWebhook.provider)
450+
const rawProviderConfig = (foundWebhook.providerConfig as Record<string, unknown>) || {}
451+
452+
/**
453+
* Only fetch + decrypt the effective env when there is auth to verify AND the
454+
* provider config actually references env vars (`{{VAR}}`). This avoids a DB
455+
* read and decryption on the synchronous pre-ack path for the common case.
456+
*/
435457
let decryptedEnvVars: Record<string, string> = {}
436-
try {
437-
decryptedEnvVars = await getEffectiveDecryptedEnv(
438-
foundWorkflow.userId,
439-
foundWorkflow.workspaceId
440-
)
441-
} catch (error) {
442-
logger.error(`[${requestId}] Failed to fetch environment variables`, {
443-
error,
444-
})
458+
if (handler.verifyAuth && providerConfigReferencesEnvVars(rawProviderConfig)) {
459+
try {
460+
decryptedEnvVars = await getEffectiveDecryptedEnv(
461+
foundWorkflow.userId,
462+
foundWorkflow.workspaceId
463+
)
464+
} catch (error) {
465+
logger.error(`[${requestId}] Failed to fetch environment variables`, {
466+
error,
467+
})
468+
}
445469
}
446470

447-
const rawProviderConfig = (foundWebhook.providerConfig as Record<string, unknown>) || {}
448471
const providerConfig = resolveProviderConfigEnvVars(rawProviderConfig, decryptedEnvVars)
449472

450-
const handler = getProviderHandler(foundWebhook.provider)
451473
if (handler.verifyAuth) {
452474
const authResult = await handler.verifyAuth({
453475
webhook: foundWebhook,
@@ -611,6 +633,10 @@ export async function queueWebhookExecution(
611633
blockId: foundWebhook.blockId,
612634
workspaceId: foundWorkflow.workspaceId,
613635
...(credentialId ? { credentialId } : {}),
636+
...(options.receivedAt !== undefined ? { webhookReceivedAt: options.receivedAt } : {}),
637+
...(options.triggerTimestampMs !== undefined
638+
? { triggerTimestampMs: options.triggerTimestampMs }
639+
: {}),
614640
}
615641

616642
const isPolling = isPollingWebhookProvider(payload.provider)

0 commit comments

Comments
 (0)