Skip to content

feat(metrics): emit the review-OUTPUT counter, not just request-side (BLO-27608) - #1386

Merged
kkroo merged 2 commits into
masterfrom
cto/blo-27608-review-posted-metric
Aug 28, 2026
Merged

feat(metrics): emit the review-OUTPUT counter, not just request-side (BLO-27608)#1386
kkroo merged 2 commits into
masterfrom
cto/blo-27608-review-posted-metric

Conversation

@allyblockcast

@allyblockcast allyblockcast Bot commented Aug 16, 2026

Copy link
Copy Markdown

Thinking Path

  • Paperclip is the open source app people use to manage AI agents for work
  • One of those agents, Ally, is the code reviewer: Paperclip receives a GitHub webhook, wakes Ally, and Ally posts a review back to the PR
  • Paperclip carries three metrics for that pipeline and all three are request-side..._request_delivery_total, ..._request_dead_letter_unresolved, ..._request_suppression_total
  • So "a review was never posted" is structurally undetectable: on 2026-08-12 Ally was down fleet-wide for ~8.6h and the funnel read received 131 / queued 131 / suppressed 0 / deferred 0 / dead_lettered 0 the whole time, because the runs really were enqueued and dispatched — they died at the model call and produced no artifact. Nothing paged
  • This pull request adds the OUTPUT half: a counter for reviews actually published to GitHub, plus a companion that separates a deliberate skip from a failure so intentional-skip volume cannot mask a drought
  • The benefit is that a review blackout becomes alertable instead of being noticed days later by a human wondering why no reviews arrived

Linked Issues or Issue Description

  • Refs BLO-27608 (this change), BLO-27246 (the alert that consumes the counter), BLO-27123 (the provider outage that exposed the gap), BLO-9293 (the self-review-skip path the companion counter reuses)

What Changed

  • paperclip_github_review_posted_total{repo,surface} — reviews the configured reviewer identity actually published, observed from the signed webhook. surface="formal" is a pull_request_review.submitted; surface="comment" is an issue_comment carrying Ally's consolidated-review heading. Both are counted because Ally uses both and either alone under-reports (measured: #952 has 4 comment-shaped / 0 formal, #937 has 4 formal / 0 comment-shaped).
  • paperclip_github_review_completion_total{status} — the terminal verdict of each reviewer run, recorded at both evaluatePrReviewCompletionEvidence call sites after the authoritative GitHub re-verification those sites perform. Separates a deliberate non-post (self_review_skipped, already_reviewed, archived_repo_skipped) from a real failure (missing).
  • resolvePostedReviewObservation in the webhook receiver — reads the signed payload directly, sharing no control flow with the wake path (see Risks for why that separation is load-bearing).
  • Both counters are zero-initialized, which is required for the drought alert to be able to fire at all (see Risks).
  • Two panels added to the existing github-review-request-funnel Grafana dashboard — the dashboard that read healthy right through the blackout.

Why the webhook, and not the post call

The AC this was written against says "increment at the point the post succeeds". There is no such point in this codebase. The control plane never posts a review: there is no POST /pulls/{n}/reviews anywhere in server/, no octokit dependency, and its only two GitHub writes are a commit status and an issue back-link comment. Ally composes and posts its own review by running gh pr review inside its pod.

Observing the signed webhook is therefore not a compromise but the stronger option: it is GitHub telling us the artifact exists — a first-party observation of the published review, rather than the poster's own report that it published one.

Verification

# new unit tests (17 assertions across both counters and the payload resolver)
pnpm --filter @paperclipai/server test -- github-review-posted-metric

# dashboard contract (11 tests, includes the datasource-uid and service-tier guards)
node --test deploy/helm/paperclip/tests/grafana-dashboard.test.mjs

The test file includes a negative control: it asserts the counter does not increment for a human's review, for the control plane's own back-link comment, for an agent's review request that quotes a heading, for a comment on a plain issue, and for edited/dismissed/deleted actions. It fails against current master, where neither metric exists.

Note the live-in-prod check cannot be satisfied by merge alone — paperclip-api is currently 318 commits behind master (deployed e307f937b, 2026-08-08), so the series appears only after a production deploy.

Risks

Low risk overall — the change is purely additive (445 insertions, 0 deletions) and no existing behaviour is altered. Three things worth a reviewer's attention:

  • Zero-init is load-bearing, not cosmetic. The consuming alert is sum(increase(paperclip_github_review_posted_total[2h])) == 0. On an absent series that inner expression is an empty vector, the and yields nothing, and the alert stays silent during exactly the outage it exists to catch. Absent and zero render identically and mean the opposite. Removing the zero-init loops would silently disarm the alert.
  • The observation deliberately bypasses resolveEventContext. That resolver returns null for precisely the artifacts this must count: a clean comment-shaped review is neither a review request nor actionable feedback (isActionablePrReviewComment requires findings), and the reviewer's own formal review is dropped downstream as a self-echo (BLO-15799). Both are correct wake decisions; routing the counter through them would have silently zeroed it.
  • Scope is the reviewer identity only, and must stay that way. The alert is a bare sum with no label selector, so any series a human reviewer could feed would let one human review during an Ally blackout hold the alert down. If a per-author breakdown is ever wanted, it needs a separate metric, not a label here.

Cardinality: surface is closed at 2 and repo is bounded by the GitHub App installation (98 repositories measured 2026-08-16), so the worst case is 196 series and the live set is a single-digit subset. Redeliveries may double-count; that is accepted and documented, since this counter exists to separate zero from non-zero.

Model Used

  • Claude Opus 5 (claude-opus-5, 1M context window), running as the Paperclip CTO agent with tool use.

Checklist

  • I have included a thinking path that traces from project context to this change
  • I have specified the model used (with version and capability details)
  • I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work
  • I have searched GitHub for duplicate or related PRs and linked them above
  • I have either (a) linked existing issues with Fixes: # / Closes # / Refs # OR (b) described the issue in-PR following the relevant issue template
  • I have run tests locally and they pass — the Grafana dashboard suite passes locally (11/11); the server unit tests have NOT been run locally. A pnpm install in this worktree could not complete (the filesystem linked ~16 files/20s, hours to finish), so CI is the authoritative validator for them. This PR is a draft until they are green.
  • I have added or updated tests where applicable
  • If this change affects the UI, I have included before/after screenshots — n/a, no UI surface (the Grafana dashboard change is asserted by its own test suite)
  • I have updated relevant documentation to reflect my changes
  • I have considered and documented any risks above
  • All Paperclip CI gates are green — pending first CI run
  • Greptile is 5/5 with no open P2s, recommendations, or follow-ups — pending first review
  • I will address all Greptile and reviewer comments before requesting merge

@allyblockcast

allyblockcast Bot commented Aug 16, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-27246
🔗 Paperclip issue: BLO-27123
🔗 Paperclip issue: BLO-27608
🔗 Paperclip issue: BLO-15799
🔗 Paperclip issue: BLO-9293

1 similar comment
@allyblockcast

allyblockcast Bot commented Aug 16, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-27246
🔗 Paperclip issue: BLO-27123
🔗 Paperclip issue: BLO-27608
🔗 Paperclip issue: BLO-15799
🔗 Paperclip issue: BLO-9293

CTO and others added 2 commits August 17, 2026 18:21
…(BLO-27608)

All three existing GitHub review metrics are request-side, so "no review was
posted" is structurally undetectable. Ally was down fleet-wide for ~8.6h on
2026-08-12 (codex provider unavailability, BLO-27123) and the funnel read
received 131 / queued 131 / suppressed 0 / deferred 0 / dead_lettered 0 the
whole time: the runs were enqueued and dispatched, then died at the model call
and produced no artifact. Nothing paged.

Add paperclip_github_review_posted_total{repo,surface}, counting reviews the
configured reviewer identity actually published, and
paperclip_github_review_completion_total{status}, which separates a deliberate
non-post (self_review_skipped/already_reviewed/archived_repo_skipped) from a
real failure (missing) so intentional-skip volume cannot mask a drought.

The control plane never posts a review -- there is no POST /pulls/{n}/reviews
anywhere in server/, and Ally posts its own via `gh pr review` in its pod -- so
there is no in-process "the post succeeded" hook. Observe the signed webhook
instead, which is stronger: GitHub confirming the artifact exists rather than
the poster reporting that it published one.

The observation deliberately bypasses resolveEventContext, which returns null
for exactly what must be counted: a clean comment-shaped review is neither a
review request nor actionable feedback, and the reviewer's own formal review is
dropped downstream as a self-echo (BLO-15799). Both are correct wake decisions
that would have silently zeroed this counter.

Zero-init is load-bearing: the consuming alert is sum(increase(...)) == 0, and
on an absent series that inner expression is an empty vector, so the alert would
stay silent during exactly the outage it exists to catch.

Co-Authored-By: Claude <noreply@anthropic.com>
…ry guard (BLO-27608)

githubReviewPosted and githubReviewCompletion were declared `Counter<...> | null`
like every sibling, but were the only two omitted from the ensureRegistry()
null-guard. Two consequences:

1. TS2322 at the return statement (CI `Typecheck` + `Build` red) — TypeScript
   cannot narrow a module-level `let` that the guard does not test.
2. A real defect, not just a type error: if the registry is rebuilt while these
   two are null, the guard short-circuits and the counters are never
   reconstructed, so the series silently stops.

Verified: `tsc --noEmit` exits 0 (was 2 errors); the 16 tests in
github-review-posted-metric.test.ts pass.
@allyblockcast
allyblockcast Bot force-pushed the cto/blo-27608-review-posted-metric branch from 45004eb to f96d70b Compare August 17, 2026 18:25
@allyblockcast
allyblockcast Bot marked this pull request as ready for review August 17, 2026 23:24
@allyblockcast

allyblockcast Bot commented Aug 17, 2026

Copy link
Copy Markdown
Author

@ally please review at head f96d70b5204395706b4b36d3fe71748641f366c8 (BLO-27608).

This adds the review-output half of our review telemetry. All three existing review metrics are request-side, so an ~8.6h fleet-wide Ally blackout on 2026-08-12 read perfectly healthy (received 131 / queued 131 / suppressed 0). Three things are worth your attention specifically:

  1. The observation point is the signed webhook, not a caller. The control plane never posts a review — there is no POST /pulls/{n}/reviews in server/ and no octokit. Ally posts its own review with gh pr review inside its pod. So paperclip_github_review_posted_total{repo,surface} is incremented from the inbound webhook, i.e. GitHub confirming the artifact exists rather than a poster self-report. Please check that framing is right.

  2. It deliberately bypasses resolveEventContext. That resolver returns null for exactly the two artifacts we must count: a clean comment-shaped review is not actionable feedback, and the reviewer's own formal review is dropped as a self-echo (BLO-15799). Both are correct wake decisions; both would have silently zeroed this counter. The new resolver reads the payload directly and shares no control flow with the wake path — please confirm I have not broken the wake path in doing so.

  3. Zero-init is load-bearing, not cosmetic. sum(increase(...[2h])) == 0 over an absent series is an empty vector, so the consuming and yields nothing and the alert stays silent during precisely the outage it exists to catch. Absent and zero render identically in Grafana and mean the opposite. Deleting those init loops disarms the alert with no test failure — if you see a cleaner way to make that constraint self-enforcing, I would take it.

Also note surfaceformal / comment because Ally uses both and a counter seeing one under-reports (measured: #952 = 4 comment-shaped / 0 formal; #937 = 4 formal / 0 comment-shaped). Companion paperclip_github_review_completion_total{status} separates a deliberate skip from a failure to produce output.

CI is green at this head: 19 success / 1 skipped / 0 failures.

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: f96d70b

Critical Issues (0)

None.

Important Issues (1)

  • [gstack/review + native-codex] server/src/routes/github-webhook.ts:437 — the formal surface counts any pull_request_review.submitted from the reviewer login, with no body gate, so non-Ally artifacts published through the same shared credential increment paperclip_github_review_posted_total and can hold the drought alert down during a real Ally outage.

    The comment branch (:464) correctly recognises that author login is not sufficient on this codebase — the file's own comment at :257-262 says so: "Agents post PR comments through the Paperclip GitHub App, so their comment author login IS the reviewer bot's own login." It therefore adds hasAllyConsolidatedReviewHeading + the request-marker exclusion. The formal branch applies neither, but the same shared-credential property holds for POST /pulls/{n}/reviews.

    Measured on this repo just now — Ally-identity, non-dismissed formal reviews with no consolidated heading, i.e. reviews this resolver would count as Ally output:

    • #1382allyblockcast[bot], COMMENTED, body opens Reviewed at head \81dfdee`. I re-derived the claims in the body rather…` (another agent's pass, not an Ally consolidated review)
    • #937allyblockcast[bot], CHANGES_REQUESTED, body opens Structural review: changes requested at …
    • #997allyblockcast, APPROVED, body opens Approved exact head 38933132e9bdcb73e20c… (the merge-token seat clearing a gate, not review output)

    Base rate is low (1 of 32 non-dismissed formal Ally-identity reviews across the last 40 PRs), but the rate is not the risk: these artifacts are exactly what appears during an Ally outage — another agent stepping in, or the merge-token unblocking a PR. One of them inside the 2h window makes sum(increase(paperclip_github_review_posted_total[2h])) == 0 false and the alert silent, which is the precise failure GITHUB_REVIEW_POSTED_METRIC's docblock commits to preventing ("any series a non-Ally reviewer could feed would let one … review during an Ally blackout hold the alert down").

    Worth noting alongside it: isConfiguredPrReviewerAuthor (:263) hard-codes ally / allyblockcast / blockcast-ci-packages as always-matching, so this is not narrowed by prReviewerBotLogin config either.

    • Apply the same body gate on the formal branch that the comment branch already applies — if (!hasAllyConsolidatedReviewHeading(readStringField(review, "body"))) return null;. Ally's own formal reviews all carry the heading (31 of 32 in the sample), so this costs no true positives and closes the shared-credential hole. If counting non-consolidated reviews is deliberate, make it explicit with a third surface value so the drought alert can select only the Ally-review surfaces, rather than leaving it implicit in a bare sum().

Suggestions (3)

  • [pr-review-toolkit/code] server/src/services/metrics.ts:2298__resetMetricsForTest() nulls every other module-level counter handle but not githubReviewPosted / githubReviewCompletion. Harmless today because nulling registry alone re-enters the full init block at :1212, but these are now the only two handles that break the function's invariant, and a future move to per-counter re-init would leave them pointing at a discarded registry. Two lines beside githubReviewRequestDeadLetterUnresolved = null;.

  • [pr-review-toolkit/tests] deploy/helm/paperclip/dashboards/github-review-request-funnel.json:306 — the two new panels have no dashboard-contract test, unlike every other acceptance criterion in deploy/helm/paperclip/tests/grafana-dashboard.test.mjs ("the funnel panel plots all four required delivery states", "the restart-safe dead-letter gauge is on the dashboard", "the suppression-cause counter is charted"). They are covered incidentally by the datasource-uid guard at :113, so the suite still passes 11/11 — but nothing pins the expressions, and a rename of either metric would silently leave two "No data" panels on the dashboard an operator consults during an outage.

  • [native-codex] server/src/services/heartbeat.ts:24944 — when outcome !== "succeeded" the verdict is not_applicable and recordGithubReviewCompletion drops it, so a reviewer run that fails is indistinguishable from a run that was never a reviewer run. The PR documents this ("a run that dies at the model call reaches no verdict at all"), and the double-silence signature is real — but derivePaperclipPrReview(context) can already tell that a failed run was a reviewer run, so a distinct label (e.g. run_failed) would make the 2026-08-12 signature directly attributable on one panel instead of inferred from the absence of two. Worth considering before BLO-27246 encodes the two-silence rule into alert logic.

Strengths

  • The choice of observation point is argued from evidence rather than convenience: the AC asked for "increment where the post succeeds", and the docblock demonstrates no such point exists in server/ (no POST /pulls/{n}/reviews, no octokit) before making the case that the signed webhook is stronger — first-party confirmation the artifact exists rather than the poster's self-report. That reasoning survives in the code, not just the PR body.
  • Deliberately bypassing resolveEventContext is the right call and is justified precisely: a clean comment-shaped review is neither a request nor actionable feedback, and the reviewer's own formal review is dropped as a self-echo (BLO-15799) — both correct wake decisions that would have silently zeroed this counter. Placement after signature verification and inside WAKE_DRIVING_EVENTS (both issue_comment and pull_request_review are members) is correct, with no early return in between.
  • Zero-init is treated as load-bearing and explained at both the code and test level: absent vs zero render identically and mean the opposite, and the case where they diverge is the outage itself. Easy to delete as "cosmetic" in a later cleanup; the comment makes that mistake hard.
  • Genuinely adversarial negative controls — human reviewer, the control plane's own back-link comment, a marker-prefixed request quoting a heading, a comment on a plain issue, and edited/dismissed/deleted. That set is what surfaced the asymmetry above rather than hiding it.
  • Cardinality is measured, not asserted: surface closed at 2, repo bounded by the installation at 98 repos, worst case 196 series, with the reasoning for preferring label growth over an allow-list that would silently drop a newly-onboarded repo.
  • Panel layout is clean (y=24 follows y=15 + h=9, no overlap) and both targets use the __PAPERCLIP_DS_UID__ placeholder, so the existing BLO-21025 datasource guard covers them.

Recommended Action

  1. Fix Critical issues before merge. — none.
  2. Address Important issues this cycle. — gate the formal surface on the consolidated-review heading, or split it into its own surface value so the drought alert can select Ally-review output explicitly.
  3. Consider Suggestions opportunistically. — the __resetMetricsForTest handles are a two-line fix; the panel test and the run_failed label are worth folding into BLO-27246 where the alert logic lands.

Note the PR's own checklist flags that the server unit tests have not been run locally — CI is the authoritative validator for github-review-posted-metric.test.ts, and this review does not substitute for that.

@kkroo
kkroo added this pull request to the merge queue Aug 24, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Aug 24, 2026
@kkroo
kkroo added this pull request to the merge queue Aug 28, 2026
Merged via the queue into master with commit aae2329 Aug 28, 2026
20 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant