feat(payment): observability for store subscription webhooks (#4704)
* feat(payment): observability for store subscription webhooks Add monitoring for the App Store / Google Play webhooks so store-side subscription changes are observable on Cloudflare, where stored Workers Logs are head-sampled at 1% and would miss almost all low-volume webhook events. - Add iap/telemetry.ts: every webhook invocation emits a structured log line (streamed in full by `wrangler tail`) and a Cloudflare Analytics Engine data point (100% capture, independent of log sampling). Writes no-op off the Worker runtime, mirroring the getCloudflareContext guard in deepl/translate.ts. - Instrument both webhook routes to record outcome (handled, skipped, rejected, error), notification type, status, reason, and latency on every return path. - Add GET /api/cron/iap-reconcile: a CRON_SECRET-protected sweep that counts drift (rows still active while their store expiry has passed = a missed webhook) in both IAP tables and records a reconcile metric. Detection-only; never mutates state. - Add the IAP_WEBHOOK_AE Analytics Engine binding to wrangler.toml. New configuration: CRON_SECRET (reconcile auth) and an iap_webhooks Analytics Engine dataset bound as IAP_WEBHOOK_AE. The reconcile route is triggered on a schedule (a Cloudflare Cron Trigger worker that fetches the URL, or any external scheduler) with an Authorization bearer header. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(payment): move reconciliation to a dedicated cron Worker Replace the public CRON_SECRET-protected /api/cron/iap-reconcile route with a dedicated Cloudflare Cron Worker. A Cron Trigger invokes the worker's scheduled() handler directly, so there is no public HTTP surface and no shared request secret to manage - the strongest option on Cloudflare (OpenNext's generated worker only exports `fetch`, so the main worker cannot host a scheduled() handler). - Add workers/iap-reconcile: a self-contained worker (own package.json, tsconfig, wrangler.toml) matching the existing workers/send-email convention, registered in pnpm-workspace.yaml. Hourly Cron Trigger; reads the IAP tables via the Supabase service role and records a drift metric to the shared iap_webhooks Analytics Engine dataset. - Reconcile logic lives in workers/iap-reconcile/src/reconcile.ts and is unit-tested from the app suite. - Remove the public route and its test; drop the now-unused recordIapReconcile from iap/telemetry.ts (webhook telemetry unchanged). Configuration: set SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY as secrets on the worker and deploy it with `wrangler deploy` from its directory. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,29 +1,58 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { handleAppleNotification } from '@/libs/payment/iap/apple/notifications';
|
||||
import { recordIapWebhook } from '@/libs/payment/iap/telemetry';
|
||||
|
||||
// App Store Server Notifications V2 endpoint. Configure this URL in App Store
|
||||
// Connect (Production and Sandbox). The payload is signed by Apple and verified
|
||||
// inside `handleAppleNotification`, so no separate authentication is needed.
|
||||
export async function POST(request: Request) {
|
||||
const startedAt = Date.now();
|
||||
|
||||
let signedPayload: unknown;
|
||||
try {
|
||||
const body = await request.json();
|
||||
signedPayload = body?.signedPayload;
|
||||
} catch {
|
||||
recordIapWebhook({
|
||||
provider: 'apple',
|
||||
outcome: 'rejected',
|
||||
reason: 'invalid_body',
|
||||
durationMs: Date.now() - startedAt,
|
||||
});
|
||||
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (typeof signedPayload !== 'string' || !signedPayload) {
|
||||
recordIapWebhook({
|
||||
provider: 'apple',
|
||||
outcome: 'rejected',
|
||||
reason: 'missing_payload',
|
||||
durationMs: Date.now() - startedAt,
|
||||
});
|
||||
return NextResponse.json({ error: 'Missing signedPayload' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await handleAppleNotification(signedPayload);
|
||||
recordIapWebhook({
|
||||
provider: 'apple',
|
||||
outcome: result.handled ? 'handled' : 'skipped',
|
||||
notificationType: result.notificationType,
|
||||
status: result.status,
|
||||
reason: result.reason,
|
||||
durationMs: Date.now() - startedAt,
|
||||
});
|
||||
return NextResponse.json({ received: true, ...result });
|
||||
} catch (error) {
|
||||
// Respond 500 so Apple retries transient failures (e.g. a database hiccup).
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
console.error('Apple notification error:', message);
|
||||
recordIapWebhook({
|
||||
provider: 'apple',
|
||||
outcome: 'error',
|
||||
reason: message,
|
||||
durationMs: Date.now() - startedAt,
|
||||
});
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { handleGoogleNotification } from '@/libs/payment/iap/google/notifications';
|
||||
import { recordIapWebhook } from '@/libs/payment/iap/telemetry';
|
||||
|
||||
// Google Play Real-Time Developer Notifications (RTDN) endpoint, delivered via a
|
||||
// Cloud Pub/Sub push subscription. The push URL must include the shared secret
|
||||
// (`?token=...`) matching GOOGLE_RTDN_VERIFICATION_TOKEN; the notification state
|
||||
// itself is re-verified against the Play Developer API in the handler.
|
||||
export async function POST(request: Request) {
|
||||
const startedAt = Date.now();
|
||||
|
||||
const expectedToken = process.env['GOOGLE_RTDN_VERIFICATION_TOKEN'];
|
||||
if (expectedToken) {
|
||||
const token = new URL(request.url).searchParams.get('token');
|
||||
if (token !== expectedToken) {
|
||||
recordIapWebhook({
|
||||
provider: 'google',
|
||||
outcome: 'rejected',
|
||||
reason: 'invalid_token',
|
||||
durationMs: Date.now() - startedAt,
|
||||
});
|
||||
return NextResponse.json({ error: 'Invalid verification token' }, { status: 401 });
|
||||
}
|
||||
} else {
|
||||
@@ -21,22 +30,48 @@ export async function POST(request: Request) {
|
||||
const body = await request.json();
|
||||
messageData = body?.message?.data;
|
||||
} catch {
|
||||
recordIapWebhook({
|
||||
provider: 'google',
|
||||
outcome: 'rejected',
|
||||
reason: 'invalid_body',
|
||||
durationMs: Date.now() - startedAt,
|
||||
});
|
||||
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Pub/Sub may deliver an empty message (e.g. during endpoint validation).
|
||||
// Acknowledge it so it is not retried.
|
||||
if (typeof messageData !== 'string' || !messageData) {
|
||||
recordIapWebhook({
|
||||
provider: 'google',
|
||||
outcome: 'skipped',
|
||||
reason: 'empty_message',
|
||||
durationMs: Date.now() - startedAt,
|
||||
});
|
||||
return NextResponse.json({ received: true, handled: false, reason: 'empty_message' });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await handleGoogleNotification(messageData);
|
||||
recordIapWebhook({
|
||||
provider: 'google',
|
||||
outcome: result.handled ? 'handled' : 'skipped',
|
||||
notificationType: result.notificationType,
|
||||
status: result.status,
|
||||
reason: result.reason,
|
||||
durationMs: Date.now() - startedAt,
|
||||
});
|
||||
return NextResponse.json({ received: true, ...result });
|
||||
} catch (error) {
|
||||
// Respond 500 so Pub/Sub retries transient failures.
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
console.error('Google notification error:', message);
|
||||
recordIapWebhook({
|
||||
provider: 'google',
|
||||
outcome: 'error',
|
||||
reason: message,
|
||||
durationMs: Date.now() - startedAt,
|
||||
});
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user