Skip to content

Commit d432c8b

Browse files
feat(metrics): count Redis webhook dedup hits with backend label (#3544)
Expose isWebhookDeliveryDuplicate/rememberWebhookDelivery helpers and wire server dedup through them so redeliveries increment gittensory_webhook_dedup_total{backend="redis"} without shifting selfhost env-reference line numbers. Closes #2075 Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 03f8926 commit d432c8b

3 files changed

Lines changed: 80 additions & 11 deletions

File tree

src/selfhost/redis-cache.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,38 @@
44
// ID after a successful processing attempt, the server can return 204 immediately on retries
55
// without re-queuing the job. The self-host review runtime requires REDIS_URL.
66
import type { Redis } from "ioredis";
7+
import { incr } from "./metrics";
8+
9+
const WEBHOOK_DELIVERY_CACHE_PREFIX = "delivery:";
10+
11+
export function webhookDeliveryCacheKey(deliveryId: string): string {
12+
return `${WEBHOOK_DELIVERY_CACHE_PREFIX}${deliveryId}`;
13+
}
14+
15+
/** Returns true when this GitHub webhook delivery ID was already processed (Redis dedup hit).
16+
* Increments `gittensory_webhook_dedup_total{backend="redis"}` on a hit. Does NOT mark the
17+
* delivery — the caller marks only after a successful response (#2506 / #2572). */
18+
export async function isWebhookDeliveryDuplicate(cache: RedisCache, deliveryId: string): Promise<boolean> {
19+
try {
20+
const seen = await cache.get(webhookDeliveryCacheKey(deliveryId));
21+
if (seen) {
22+
incr("gittensory_webhook_dedup_total", { backend: "redis" });
23+
return true;
24+
}
25+
return false;
26+
} catch {
27+
return false;
28+
}
29+
}
30+
31+
/** Best-effort: record a successfully processed webhook delivery for Redis dedup. */
32+
export async function rememberWebhookDelivery(cache: RedisCache, deliveryId: string, ttlSeconds = 300): Promise<void> {
33+
try {
34+
await cache.set(webhookDeliveryCacheKey(deliveryId), "1", ttlSeconds);
35+
} catch {
36+
// best-effort — never block the response on a cache write failure
37+
}
38+
}
739

840
export function createRedisCache(redis: Redis) {
941
return {

src/server.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -481,7 +481,7 @@ async function main(): Promise<void> {
481481
const { Redis } = await import("ioredis");
482482
const redisClient = new Redis(redisUrl);
483483
const { createRedisRateLimiter } = await import("./selfhost/redis-ratelimit");
484-
const { createRedisCache, assertSelfhostTransientCacheOwnershipRelease } = await import("./selfhost/redis-cache");
484+
const { createRedisCache, assertSelfhostTransientCacheOwnershipRelease, isWebhookDeliveryDuplicate, rememberWebhookDelivery } = await import("./selfhost/redis-cache");
485485
const rateLimiter = createRedisRateLimiter(redisClient);
486486
const webhookCache = createRedisCache(redisClient);
487487
assertSelfhostTransientCacheOwnershipRelease(webhookCache);
@@ -706,7 +706,7 @@ async function main(): Promise<void> {
706706
"gittensory_orb_events_exported_total",
707707
"gittensory_orb_export_errors_total",
708708
])
709-
incr(c, undefined, 0);
709+
incr(c, c === "gittensory_webhook_dedup_total" ? { backend: "redis" } : undefined, 0);
710710
// Seed gittensory_http_requests_total per status class so the breakdown panel has every series from the
711711
// first scrape (keeping the metric consistently labeled — never mix labeled and unlabeled samples).
712712
for (const status of ["2xx", "3xx", "4xx", "5xx"])
@@ -886,18 +886,18 @@ async function main(): Promise<void> {
886886
? request.headers.get("x-github-delivery")
887887
: null;
888888
if (deliveryId) {
889-
const seen = await webhookCache!.get(`delivery:${deliveryId}`);
890-
if (seen) {
891-
incr("gittensory_webhook_dedup_total");
889+
// Redis dedup hit — return 204 before enqueue (#1216).
890+
// Metric: gittensory_webhook_dedup_total{backend="redis"} (#2075).
891+
if (await isWebhookDeliveryDuplicate(webhookCache!, deliveryId)) {
892892
return finish(new Response(null, { status: 204 }));
893893
}
894894
}
895895
const response = await worker.fetch(request, env, ctx);
896896
if (deliveryId && response.ok) {
897-
// Best-effort — never block the response on a cache write failure
898-
void webhookCache!
899-
.set(`delivery:${deliveryId}`, "1", 300)
900-
.catch(() => undefined);
897+
// Best-effort — never block the response on a cache write failure.
898+
void rememberWebhookDelivery(webhookCache!, deliveryId).catch(
899+
() => undefined,
900+
);
901901
}
902902
return finish(response);
903903
} finally {

test/unit/selfhost-redis-cache.test.ts

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
import type { Redis } from "ioredis";
2-
import { describe, expect, it } from "vitest";
3-
import { assertSelfhostTransientCacheOwnershipRelease, createRedisCache } from "../../src/selfhost/redis-cache";
2+
import { afterEach, describe, expect, it } from "vitest";
3+
import {
4+
assertSelfhostTransientCacheOwnershipRelease,
5+
createRedisCache,
6+
isWebhookDeliveryDuplicate,
7+
rememberWebhookDelivery,
8+
webhookDeliveryCacheKey,
9+
} from "../../src/selfhost/redis-cache";
10+
import { renderMetrics, resetMetrics } from "../../src/selfhost/metrics";
411

512
/** Minimal in-memory stand-in for the ioredis methods the cache uses. Emulates real Redis SET NX
613
* semantics (refuse + return null when NX is requested and the key already exists) so a test
@@ -97,3 +104,33 @@ describe("createRedisCache (#1216 webhook dedup cache)", () => {
97104
expect(() => assertSelfhostTransientCacheOwnershipRelease(createRedisCache(fakeRedis()))).not.toThrow();
98105
});
99106
});
107+
108+
describe("isWebhookDeliveryDuplicate (#2075)", () => {
109+
afterEach(() => resetMetrics());
110+
111+
it("returns false and does not increment on a first-time delivery", async () => {
112+
const cache = createRedisCache(fakeRedis());
113+
await expect(isWebhookDeliveryDuplicate(cache, "delivery-1")).resolves.toBe(false);
114+
expect(await renderMetrics()).not.toContain('gittensory_webhook_dedup_total{backend="redis"}');
115+
});
116+
117+
it("returns true and increments gittensory_webhook_dedup_total{backend=\"redis\"} when already seen", async () => {
118+
const cache = createRedisCache(fakeRedis());
119+
await cache.set(webhookDeliveryCacheKey("delivery-2"), "1", 300);
120+
await expect(isWebhookDeliveryDuplicate(cache, "delivery-2")).resolves.toBe(true);
121+
expect(await renderMetrics()).toContain('gittensory_webhook_dedup_total{backend="redis"} 1');
122+
});
123+
124+
it("returns false without incrementing when Redis get throws", async () => {
125+
const brokenRedis = { async get() { throw new Error("connection refused"); } } as unknown as Redis;
126+
const cache = createRedisCache(brokenRedis);
127+
await expect(isWebhookDeliveryDuplicate(cache, "delivery-3")).resolves.toBe(false);
128+
expect(await renderMetrics()).not.toContain('gittensory_webhook_dedup_total{backend="redis"}');
129+
});
130+
131+
it("rememberWebhookDelivery stores the delivery key for later dedup", async () => {
132+
const cache = createRedisCache(fakeRedis());
133+
await rememberWebhookDelivery(cache, "delivery-4");
134+
expect(await cache.get(webhookDeliveryCacheKey("delivery-4"))).toBe("1");
135+
});
136+
});

0 commit comments

Comments
 (0)