Skip to content

Commit 5e9cc7e

Browse files
observability: synthetic monitors + silent-failure alerts for billing/funnel/pods
Adds NR Synthetics monitors + NR Alert conditions so the class of silent production failures surfaced this session gets caught automatically: a dead paid funnel, an unhandled Razorpay webhook event, and a charged-but-not- upgraded customer — all of which broke prod with nothing alerting. Synthetic monitors (newrelic/synthetics/, applied by synthetics-apply.sh): - instant-api healthz ping — SIMPLE /healthz, 5m - instanode marketing home ping — SIMPLE instanode.dev/, 5m - instanode dashboard login page — BROWSER /app SPA mount, 5m - instant-api openapi.json ping — SIMPLE /openapi.json, 15m - instant-api healthz scripted — SCRIPT_API: asserts ok=true, non-placeholder commit_id, migration_status=ok, build_time <= 30d (build-SHA freshness — catches stale-image deploys) Alert conditions (newrelic/alerts/, applied by apply.sh onto the existing 'instant-api alerts' policy) — all key on signals the app already emits, no new app code: - billing-charge-undeliverable CRIT audit_kind=billing.charge_undeliverable, any occurrence - billing-webhook-unhandled-event WARN message=billing.webhook.unhandled_event, any occurrence - billing-reconciler-orphan-corrected WARN instant_billing_reconciler_orphan_corrected_total > 0 / 15m - entitlement-drift-corrected WARN/CRIT instant_entitlement_drift_corrected_total > 3/20 per 1h - zero-upgrades-7d CRIT subscription.upgraded == 0 sustained 7d (dead funnel) - checkout-failure-spike WARN/CRIT checkout ERROR logs > 3/15 per 1h - api-healthz-down WARN/CRIT SyntheticCheck result=FAILED - backend-service-no-logs CRIT api/worker/provisioner zero logs 10m Elevated api 5xx is already covered by the pre-existing api-5xx-rate-high condition — no duplicate added. synthetics-apply.sh is a companion to apply.sh (separate NerdGraph mutation family for synthetic monitors); both are idempotent by name. Full monitor + alert table, thresholds, and apply commands in newrelic/synthetics/SYNTHETICS.md. Definitions only — not applied to NR (no API key available in this environment); operator applies via ./apply.sh + ./synthetics-apply.sh against account 7958263. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 355ee8e commit 5e9cc7e

17 files changed

Lines changed: 728 additions & 0 deletions

newrelic/README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,3 +144,17 @@ Alert conditions (4):
144144
- `instant-api — p95 latency > 500ms (5m)`
145145
- `instant-api — NATS connection failures`
146146
- `instant-worker — no jobs processed in 10m`
147+
148+
## Synthetic monitoring + silent-failure alerting
149+
150+
Synthetic monitors (NR Synthetics) live in `synthetics/` and are applied by
151+
`synthetics-apply.sh` (companion to `apply.sh`, separate because synthetic
152+
monitors use a different family of NerdGraph mutations). Eight new alert
153+
conditions in `alerts/` catch silent billing/funnel/pod failures keyed on
154+
signals the app already emits — `billing.charge_undeliverable`,
155+
`billing.webhook.unhandled_event`, `instant_billing_reconciler_orphan_corrected_total`,
156+
`instant_entitlement_drift_corrected_total`, `subscription.upgraded`,
157+
checkout error logs, `SyntheticCheck` results, and per-service log heartbeat.
158+
159+
See `synthetics/SYNTHETICS.md` for the full monitor + alert table, thresholds,
160+
rationale, and apply commands.
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
{
2+
"name": "instant-api — /healthz synthetic failing (api pod down / unreachable)",
3+
"type": "NRQL",
4+
"description": "Page when the api-healthz synthetic monitor records a failed check — the api is down, unreachable, or its /healthz endpoint is returning non-2xx. Keyed on the SyntheticCheck event the New Relic Synthetics ping + scripted-API monitors emit (result = 'FAILED'). Catches: api pod crash loop missed by k8s readiness probes, ingress/TLS breakage, full-cluster outage. CRITICAL on 2 consecutive failed checks (~10m at the 5-min monitor period) to ride out a single transient blip. Source: SyntheticCheck events from monitors 'instant-api healthz ping' and 'instant-api healthz scripted (build-SHA freshness)' — see newrelic/synthetics/. If the NR k8s integration is later installed, add a companion K8sContainerSample condition for pod-restart-count.",
5+
"enabled": true,
6+
"nrql": {
7+
"query": "SELECT count(*) FROM SyntheticCheck WHERE monitorName LIKE 'instant-api healthz%' AND result = 'FAILED'"
8+
},
9+
"terms": [
10+
{
11+
"priority": "CRITICAL",
12+
"operator": "ABOVE_OR_EQUALS",
13+
"threshold": 2,
14+
"thresholdDuration": 600,
15+
"thresholdOccurrences": "ALL"
16+
},
17+
{
18+
"priority": "WARNING",
19+
"operator": "ABOVE_OR_EQUALS",
20+
"threshold": 1,
21+
"thresholdDuration": 300,
22+
"thresholdOccurrences": "ALL"
23+
}
24+
],
25+
"signal": {
26+
"aggregationWindow": 300,
27+
"aggregationMethod": "EVENT_FLOW",
28+
"aggregationDelay": 180,
29+
"fillOption": "STATIC",
30+
"fillValue": 0
31+
},
32+
"expiration": {
33+
"expirationDuration": 1800,
34+
"openViolationOnExpiration": true,
35+
"closeViolationsOnExpiration": false
36+
},
37+
"violationTimeLimitSeconds": 86400
38+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
{
2+
"name": "instant — backend service emitted zero logs in 10m (pod down: api / worker / provisioner)",
3+
"type": "NRQL",
4+
"description": "Page when any of the three backend services (api, worker, provisioner) emits ZERO log records for 10 minutes — a per-service liveness check that catches a crashed/evicted pod, a deadlocked process, or a k8s rollout that never came healthy, even when the k8s readiness probe was satisfied. Faceted by service so the violation names which pod went dark. Every backend binary logs continuously under normal operation (request logs, River job middleware, gRPC handler logs), so a 10-minute silence on any one service is a real outage. Source: Log records, FACET service. Complements api-healthz-down (which checks the api from outside via Synthetics) by covering worker + provisioner, which have no public HTTP surface to ping. fillValue STATIC 0 ensures a fully silent stream is treated as zero, not no-data.",
5+
"enabled": true,
6+
"nrql": {
7+
"query": "SELECT count(*) FROM Log WHERE service IN ('api', 'worker', 'provisioner') FACET service"
8+
},
9+
"terms": [
10+
{
11+
"priority": "CRITICAL",
12+
"operator": "BELOW",
13+
"threshold": 1,
14+
"thresholdDuration": 600,
15+
"thresholdOccurrences": "ALL"
16+
}
17+
],
18+
"signal": {
19+
"aggregationWindow": 60,
20+
"aggregationMethod": "EVENT_FLOW",
21+
"aggregationDelay": 120,
22+
"fillOption": "STATIC",
23+
"fillValue": 0
24+
},
25+
"expiration": {
26+
"expirationDuration": 1200,
27+
"openViolationOnExpiration": true,
28+
"closeViolationsOnExpiration": false
29+
},
30+
"violationTimeLimitSeconds": 86400
31+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
{
2+
"name": "instant-api — billing.charge_undeliverable [customer charged, upgrade NOT delivered]",
3+
"type": "NRQL",
4+
"description": "HIGHEST SEVERITY. Page on ANY occurrence of a billing.charge_undeliverable audit event — a Razorpay subscription.charged webhook confirmed a real card charge that the platform could NOT translate into a delivered upgrade (team unresolvable, or resolved plan tier not in plans.yaml). Each occurrence is a real customer who paid and did not get what they paid for; an operator must reconcile the charge in the Razorpay dashboard (refund or hand-grant the tier). This is the make-good worklist signal. Source: audit_log row audit_kind = 'billing.charge_undeliverable' written by emitChargeUndeliverableAudit in api/internal/handlers/billing.go, paired with a loud slog.Error. The audit row surfaces in NR as a Log record with the audit_kind attribute (see api/internal/models/audit_log.go — the field is deliberately 'audit_kind', not 'kind'). Threshold is ABOVE 0 so a single charged-but-not-upgraded customer pages immediately.",
5+
"enabled": true,
6+
"nrql": {
7+
"query": "SELECT count(*) FROM Log WHERE service = 'api' AND audit_kind = 'billing.charge_undeliverable'"
8+
},
9+
"terms": [
10+
{
11+
"priority": "CRITICAL",
12+
"operator": "ABOVE",
13+
"threshold": 0,
14+
"thresholdDuration": 300,
15+
"thresholdOccurrences": "AT_LEAST_ONCE"
16+
}
17+
],
18+
"signal": {
19+
"aggregationWindow": 60,
20+
"aggregationMethod": "EVENT_FLOW",
21+
"aggregationDelay": 120,
22+
"fillOption": "STATIC",
23+
"fillValue": 0
24+
},
25+
"expiration": {
26+
"expirationDuration": 3600,
27+
"openViolationOnExpiration": false,
28+
"closeViolationsOnExpiration": true
29+
},
30+
"violationTimeLimitSeconds": 86400
31+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
{
2+
"name": "instant-api — billing reconciler orphan-corrected > 0 (15m) [paid customer slipped past primary path]",
3+
"type": "NRQL",
4+
"description": "Warn when the worker's billing reconciler orphan sweep has to correct a paid customer the primary webhook path missed. instant_billing_reconciler_orphan_corrected_total counts teams the reconciler elevated because they paid at Razorpay but had no persisted subscription_id (the pending_checkouts row never got its subscription_id written — a webhook-handling or DB-write gap). The reconciler is the safety net; every correction it makes means a customer would have been charged-but-not-upgraded if the net were not there. A rising count says the PRIMARY path (synchronous webhook → upgrade) is dropping payments and should be investigated. Source: worker/internal/metrics/metrics.go BillingReconcilerOrphanCorrected counter, scraped into NR as a Metric. Threshold ABOVE 0 over 15m (one+ reconciler tick); WARNING because the reconciler already self-corrected — the customer IS upgraded — but the primary-path gap needs a root-cause fix.",
5+
"enabled": true,
6+
"nrql": {
7+
"query": "SELECT sum(instant_billing_reconciler_orphan_corrected_total) FROM Metric WHERE service = 'worker'"
8+
},
9+
"terms": [
10+
{
11+
"priority": "WARNING",
12+
"operator": "ABOVE",
13+
"threshold": 0,
14+
"thresholdDuration": 900,
15+
"thresholdOccurrences": "AT_LEAST_ONCE"
16+
}
17+
],
18+
"signal": {
19+
"aggregationWindow": 300,
20+
"aggregationMethod": "EVENT_FLOW",
21+
"aggregationDelay": 180,
22+
"fillOption": "STATIC",
23+
"fillValue": 0
24+
},
25+
"expiration": {
26+
"expirationDuration": 1800,
27+
"openViolationOnExpiration": false,
28+
"closeViolationsOnExpiration": true
29+
},
30+
"violationTimeLimitSeconds": 86400
31+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
{
2+
"name": "instant-api — billing.webhook.unhandled_event [Razorpay event silently swallowed]",
3+
"type": "NRQL",
4+
"description": "Warn on ANY occurrence of a billing.webhook.unhandled_event WARN log. The Razorpay webhook dispatcher's `default:` case used to silently swallow event types the handler did not recognise — a coverage gap that hid, for example, recurring-payment events the platform never acted on. The default case now logs at WARN with message = 'billing.webhook.unhandled_event' and the event_type / event_id attributes (api/internal/handlers/billing.go ~line 1132). This alert makes that WARN visible: any unhandled Razorpay event type means a webhook coverage gap to triage — either a new event we should handle, or noise to explicitly ignore. Threshold ABOVE 0 (single occurrence) — these are rare and each one is a real gap. Not CRITICAL because a swallowed event is not always customer-impacting, but it must never go unnoticed again.",
5+
"enabled": true,
6+
"nrql": {
7+
"query": "SELECT count(*) FROM Log WHERE service = 'api' AND message = 'billing.webhook.unhandled_event'"
8+
},
9+
"terms": [
10+
{
11+
"priority": "WARNING",
12+
"operator": "ABOVE",
13+
"threshold": 0,
14+
"thresholdDuration": 300,
15+
"thresholdOccurrences": "AT_LEAST_ONCE"
16+
}
17+
],
18+
"signal": {
19+
"aggregationWindow": 60,
20+
"aggregationMethod": "EVENT_FLOW",
21+
"aggregationDelay": 120,
22+
"fillOption": "STATIC",
23+
"fillValue": 0
24+
},
25+
"expiration": {
26+
"expirationDuration": 3600,
27+
"openViolationOnExpiration": false,
28+
"closeViolationsOnExpiration": true
29+
},
30+
"violationTimeLimitSeconds": 86400
31+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
{
2+
"name": "instant-api — checkout failure / subscription.pending spike (1h)",
3+
"type": "NRQL",
4+
"description": "Warn when POST /api/v1/billing/checkout is failing — the symptom that the prod paid funnel was broken this session ('seller does not support recurring payments': Razorpay rejected the subscription create, so checkouts errored or hung in subscription.pending and never charged). Catches a checkout that breaks BEFORE any charge webhook would fire — earlier in the funnel than billing.charge_undeliverable, which only sees post-charge failures. Signal: ERROR-level Log records from the checkout handler (message contains 'checkout'). A sustained non-trivial error rate means the buy button is broken for real customers. WARNING at >3/h, CRITICAL at >15/h — low absolute thresholds because at current volume even a handful of failed checkouts is most of the funnel. Source: api/internal/handlers/billing.go checkout handler error logs (service='api'). Pairs with the billing-dunning dashboard's 'Checkout failure rate by error.code' widget for the per-code breakdown.",
5+
"enabled": true,
6+
"nrql": {
7+
"query": "SELECT count(*) FROM Log WHERE service = 'api' AND level = 'ERROR' AND message LIKE '%checkout%'"
8+
},
9+
"terms": [
10+
{
11+
"priority": "WARNING",
12+
"operator": "ABOVE",
13+
"threshold": 3,
14+
"thresholdDuration": 3600,
15+
"thresholdOccurrences": "ALL"
16+
},
17+
{
18+
"priority": "CRITICAL",
19+
"operator": "ABOVE",
20+
"threshold": 15,
21+
"thresholdDuration": 3600,
22+
"thresholdOccurrences": "ALL"
23+
}
24+
],
25+
"signal": {
26+
"aggregationWindow": 300,
27+
"aggregationMethod": "EVENT_FLOW",
28+
"aggregationDelay": 120,
29+
"fillOption": "STATIC",
30+
"fillValue": 0
31+
},
32+
"expiration": {
33+
"expirationDuration": 3600,
34+
"openViolationOnExpiration": false,
35+
"closeViolationsOnExpiration": true
36+
},
37+
"violationTimeLimitSeconds": 86400
38+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
{
2+
"name": "instant-api — entitlement drift-corrected rising (1h) [resources running on wrong tier limits]",
3+
"type": "NRQL",
4+
"description": "Warn when the worker's entitlement reconciler is correcting infra entitlement drift — resources whose live connection cap / storage limit no longer matches what their team's tier should grant. instant_entitlement_drift_corrected_total counts resources the reconciler actually re-graded back to the correct limits (a Postgres ALTER USER / Redis maxmemory change really happened). A sustained non-zero rate means upgrades/downgrades are NOT propagating their entitlement changes synchronously — paying customers may be stuck on lower limits, or downgraded customers retaining higher limits. The reconciler self-heals, but a rising count points at a broken primary entitlement-propagation path (ElevateResourceTiersByTeam / provisioner RegradeResource). Source: worker/internal/metrics/metrics.go EntitlementDriftCorrected counter, scraped into NR as a Metric. WARNING — the reconciler corrects it, but the gap needs root-cause work. Threshold ABOVE 3 corrections in a rolling 1h window so steady-state noise (one stray row) does not page.",
5+
"enabled": true,
6+
"nrql": {
7+
"query": "SELECT sum(instant_entitlement_drift_corrected_total) FROM Metric WHERE service = 'worker'"
8+
},
9+
"terms": [
10+
{
11+
"priority": "WARNING",
12+
"operator": "ABOVE",
13+
"threshold": 3,
14+
"thresholdDuration": 3600,
15+
"thresholdOccurrences": "ALL"
16+
},
17+
{
18+
"priority": "CRITICAL",
19+
"operator": "ABOVE",
20+
"threshold": 20,
21+
"thresholdDuration": 3600,
22+
"thresholdOccurrences": "ALL"
23+
}
24+
],
25+
"signal": {
26+
"aggregationWindow": 300,
27+
"aggregationMethod": "EVENT_FLOW",
28+
"aggregationDelay": 180,
29+
"fillOption": "STATIC",
30+
"fillValue": 0
31+
},
32+
"expiration": {
33+
"expirationDuration": 7200,
34+
"openViolationOnExpiration": false,
35+
"closeViolationsOnExpiration": true
36+
},
37+
"violationTimeLimitSeconds": 86400
38+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
{
2+
"name": "instant-api — zero successful upgrades in 7 days [dead paid funnel]",
3+
"type": "NRQL",
4+
"description": "Page when NOT A SINGLE successful paid upgrade has been recorded for a sustained 7-day window. This is the dead-funnel catch: it would have fired the day the prod paid funnel went silent ('seller does not support recurring payments' — checkouts created, no charge webhook ever arrived, nothing else alerted). 'Success' = a subscription.upgraded audit event, which fires only when a Razorpay subscription.charged webhook actually moves a team to a higher tier (api/internal/models/audit_kinds.go AuditKindSubscriptionUpgraded). A low-baseline static threshold is used rather than a NR Baseline condition because early-stage upgrade volume is too sparse for anomaly math — at this stage ANY 7-day stretch with zero upgrades is itself the anomaly worth a human looking. thresholdDuration is 7 days (604800s); fillValue STATIC 0 so a totally silent stream still counts as zero rather than producing no data. RE-TUNE: once steady upgrade volume exists, replace with a Baseline condition keyed on '< Nx expected'.",
5+
"enabled": true,
6+
"nrql": {
7+
"query": "SELECT count(*) FROM Log WHERE service = 'api' AND audit_kind = 'subscription.upgraded'"
8+
},
9+
"terms": [
10+
{
11+
"priority": "CRITICAL",
12+
"operator": "BELOW_OR_EQUALS",
13+
"threshold": 0,
14+
"thresholdDuration": 604800,
15+
"thresholdOccurrences": "ALL"
16+
}
17+
],
18+
"signal": {
19+
"aggregationWindow": 3600,
20+
"aggregationMethod": "EVENT_FLOW",
21+
"aggregationDelay": 600,
22+
"fillOption": "STATIC",
23+
"fillValue": 0
24+
},
25+
"expiration": {
26+
"expirationDuration": 86400,
27+
"openViolationOnExpiration": true,
28+
"closeViolationsOnExpiration": false
29+
},
30+
"violationTimeLimitSeconds": 259200
31+
}

0 commit comments

Comments
 (0)