Skip to content

Commit 4ba07a7

Browse files
sahrizviclaude
andcommitted
fix(review): address consensus review findings on #1064
Major: - `countByCategory` used an object literal plus `in`, so every `Object.prototype` member passed the allowlist: a finding categorised `toString` both minted a dimension and evaluated `<native function> + 1` into a `Record<string, number>`. Now `Object.create(null)` + `Object.hasOwn`. Zod makes this unreachable today; the guard exists for when validation is bypassed. - `review_post_outcome` had no stated cardinality and three paths that skipped it. The contract is now written down — exactly one per **completed** review, so absence means the review failed rather than that an event was lost — and enforced with a latch plus a `finally` rather than by control flow that only looked exhaustive. `not_requested` moved ahead of the `--output` write and the stdout render; a new `not_attempted` bucket covers a run that dies between the completed review and the post attempt; a throwing `resolveGitHubTarget()` reports `target_unresolved`. Minor: - `classifyReviewFailure` dropped the `message.includes("git diff")` fallback its own docstring disclaimed. It was unreachable for the real git path — `execFile` always sets `cmd`, and its message begins `Command failed: `, so the `cmd` check returns first. - Added the adversarial prototype-key case to the guard test; the existing ordinary-string case cannot reach it. - Added coverage that a throwing `Telemetry.track` cannot propagate out of either emitter, which is what the two empty `catch` blocks promise. Nits: - Deleted the e2e's 500 ms sleep. `proc.exited` already implies the flush landed: the CLI awaits `shutdown()` → `flush()` → the sink's HTTP response. - `ALTIMATE_TELEMETRY_DISABLED` restored conditionally; unconditional assignment wrote the literal string `"undefined"` when the variable was originally absent. - `postStartedAt` moved inside `if (args.post)`, and the `if`/`if` pair is now a single branch. - Corrected the e2e comment claiming the object-valued `by_category` follows the house convention — the sibling map-shaped fields stringify at the call site. Also covers three of the review's flagged test gaps: `stale_manifest` / `degraded` field mapping, and CLI-level control flow through a real process — a new e2e drives `--post` with an unwritable `--output` and asserts exactly one post outcome. Both new unit tests and the new e2e were mutation-checked against their fixes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018SLUQF3xgZHsGZHSjxe7vb
1 parent 3266b6f commit 4ba07a7

6 files changed

Lines changed: 238 additions & 48 deletions

File tree

docs/docs/reference/telemetry.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ We collect the following categories of events:
4848
| `validator_check` | A completion-gate validator ran on session end — validator name, `ok` boolean, step, retry count, `enforced` flag (false in shadow mode), and structured `details` (model counts, elapsed time, concurrency limit — no SQL or model content). Only emitted when `ALTIMATE_VALIDATORS_ENABLED=1` or `ALTIMATE_VALIDATORS_SHADOW=1`. See [Validators](../data-engineering/validators.md). |
4949
| `validator_retries_exhausted` | A session terminated with unresolved validator failures after exhausting the synthetic-retry budget — names of the failing validators (no failure body content). |
5050
| `review_run` | A dbt/SQL review completed or failed — `invocation` (`cli` for `altimate-code review`, `tool` for the `dbt_pr_review` tool), status, duration, and on success the verdict, the pre-gating verdict, mode, risk tier, and finding counts by severity and by category. No file paths, model or column names, finding titles or bodies, SQL, diff content, or repository/branch/PR names. |
51-
| `review_post_outcome` | Whether a review was published to GitHub — `not_requested`, `target_unresolved`, `full`, `partial`, or `summary_failed`, plus duration. No repository, PR, or comment content. |
51+
| `review_post_outcome` | Whether a review was published to GitHub — `not_requested`, `not_attempted`, `target_unresolved`, `full`, `partial`, or `summary_failed`, plus duration. Exactly one per **completed** review: a review that failed emits `review_run: failed` and no post event, so absence means the review failed rather than that an event was lost. `not_attempted` is publication requested but never reached (a bad `--output` path, a stdout write error). No repository, PR, or comment content. |
5252

5353
### Notes on the review events
5454

packages/opencode/src/altimate/review/telemetry.ts

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,14 @@ export type ReviewInvocation = "cli" | "tool"
2424
* malformed category would otherwise become a new dimension.
2525
*/
2626
function countByCategory(findings: Finding[]): Record<string, number> {
27-
const counts: Record<string, number> = {}
27+
// Prototype-less, and membership tested with Object.hasOwn: `{}` plus `in` accepted every
28+
// Object.prototype member, so a finding categorised `toString` both minted a dimension and
29+
// evaluated `<native function> + 1` into a Record<string, number>. Zod makes that unreachable
30+
// today, but this guard exists precisely for the case where validation was bypassed.
31+
const counts: Record<string, number> = Object.create(null)
2832
for (const category of ReviewCategory.options) counts[category] = 0
2933
for (const finding of findings) {
30-
if (finding.category in counts) counts[finding.category] += 1
34+
if (Object.hasOwn(counts, finding.category)) counts[finding.category] += 1
3135
}
3236
return counts
3337
}
@@ -41,15 +45,19 @@ function countByCategory(findings: Finding[]): Record<string, number> {
4145
* rather than inventing buckets that can never occur.
4246
*
4347
* Matching is on the fixed prefix the config loader throws with, and on the spawn identity of the
44-
* git child process — not broad substring matching over the message, which would drift the moment
45-
* anything is reworded.
48+
* git child process (`err.cmd`, set by `execFile`) — not broad substring matching over the
49+
* message, which would drift the moment anything is reworded. A `message.includes("git diff")`
50+
* fallback used to sit below the `cmd` check; it was unreachable for the real git path (execFile
51+
* always sets `cmd`, and its message begins "Command failed: ") and contradicted this paragraph.
52+
*
53+
* The `Failed to load` prefix is itself string matching. It is accurate against the config loader
54+
* today; a typed error at the throw site is what would make it robust.
4655
*/
4756
export function classifyReviewFailure(err: unknown): "config_error" | "git_error" | "error" {
4857
const message = err instanceof Error ? err.message : String(err)
4958
if (message.startsWith("Failed to load")) return "config_error"
5059
const cmd = (err as { cmd?: unknown } | undefined)?.cmd
5160
if (typeof cmd === "string" && /(^|[\\/\s])git(\s|$)/.test(cmd)) return "git_error"
52-
if (message.startsWith("git ") || message.includes("git diff")) return "git_error"
5361
return "error"
5462
}
5563

@@ -113,9 +121,16 @@ export function emitReviewRun(input: {
113121
}
114122
}
115123

116-
/** Emitted on the CLI path only — the tool does not publish. */
124+
/**
125+
* Emitted on the CLI path only — the tool does not publish.
126+
*
127+
* CONTRACT: exactly one of these per *completed* review, never more and never fewer. A review that
128+
* threw never reached a publication phase, so it gets `review_run: failed` and no post event —
129+
* absence therefore means "the review failed", not "telemetry was lost". The caller enforces the
130+
* once-ness with a latch plus a `finally`; see cli/cmd/review.ts.
131+
*/
117132
export function emitReviewPostOutcome(input: {
118-
outcome: "not_requested" | "target_unresolved" | "full" | "partial" | "summary_failed"
133+
outcome: "not_requested" | "not_attempted" | "target_unresolved" | "full" | "partial" | "summary_failed"
119134
durationMs: number
120135
sessionID: string
121136
}): void {

packages/opencode/src/altimate/telemetry/index.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -777,7 +777,11 @@ export namespace Telemetry {
777777
/** `partial` covers every "not fully posted as attempted" state PostResult can express —
778778
* inline comments fell back, a post error was recorded, or no review id came back. The
779779
* shape cannot distinguish finer outcomes than that. */
780-
outcome: "not_requested" | "target_unresolved" | "full" | "partial" | "summary_failed"
780+
/** `not_attempted`: publication was requested, but the invocation died between the
781+
* completed review and the post attempt (a bad `--output` path, a stdout write error).
782+
* Emitted from the caller's `finally` so a completed review always carries exactly one
783+
* post outcome. */
784+
outcome: "not_requested" | "not_attempted" | "target_unresolved" | "full" | "partial" | "summary_failed"
781785
duration_ms: number
782786
}
783787
// altimate_change end

packages/opencode/src/cli/cmd/review.ts

Lines changed: 65 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -104,54 +104,83 @@ export const ReviewCommand = cmd({
104104
}
105105
emitReviewRun({ invocation: "cli", durationMs: Date.now() - startedAt, sessionID: "", envelope: env })
106106

107-
if (args.output) await fs.writeFile(args.output as string, JSON.stringify(env, null, 2))
108-
109-
// Primary output → stdout (pipeable). Diagnostics below → stderr via UI.
110-
if (args.json) {
111-
process.stdout.write(JSON.stringify(env, null, 2) + "\n")
112-
} else {
113-
process.stdout.write(renderSummary(env) + "\n")
107+
// altimate_change start — publication is its own event: it happens after the review is
108+
// computed and can partially succeed, so it must not fold into review_run.
109+
//
110+
// CONTRACT: exactly one review_post_outcome per COMPLETED review. A review that threw
111+
// returned above with `review_run: failed` and never reached a publication phase, so the
112+
// absence of a post event means "the review failed" and never "telemetry was lost".
113+
//
114+
// Enforced by a latch plus the finally below rather than by the control flow being
115+
// obviously exhaustive — it was not. The `not_requested` emit used to sit AFTER the
116+
// `--output` write and the stdout render, so a bad `--output` path produced a completed
117+
// review with no post event at all, indistinguishable from a dropped event.
118+
let postOutcomeEmitted = false
119+
function emitPostOnce(outcome: Parameters<typeof emitReviewPostOutcome>[0]["outcome"], durationMs: number) {
120+
if (postOutcomeEmitted) return
121+
postOutcomeEmitted = true
122+
emitReviewPostOutcome({ outcome, durationMs, sessionID: "" })
114123
}
115124

116-
// altimate_change — publication is its own event: it happens after the review is computed
117-
// and can partially succeed, so it must not fold into review_run.
118-
const postStartedAt = Date.now()
119-
const postDuration = () => Date.now() - postStartedAt
120-
if (!args.post) {
121-
emitReviewPostOutcome({ outcome: "not_requested", durationMs: 0, sessionID: "" })
122-
}
123-
if (args.post) {
124-
const target = await resolveGitHubTarget()
125-
if (!target) {
126-
emitReviewPostOutcome({ outcome: "target_unresolved", durationMs: postDuration(), sessionID: "" })
127-
UI.println(
128-
"⚠️ --post requested but GITHUB_TOKEN / GITHUB_REPOSITORY / PR number could not be resolved; skipping post.",
129-
)
125+
try {
126+
// Emitted before anything that can throw, so the no-publication case cannot be lost.
127+
if (!args.post) emitPostOnce("not_requested", 0)
128+
129+
if (args.output) await fs.writeFile(args.output as string, JSON.stringify(env, null, 2))
130+
131+
// Primary output → stdout (pipeable). Diagnostics below → stderr via UI.
132+
if (args.json) {
133+
process.stdout.write(JSON.stringify(env, null, 2) + "\n")
130134
} else {
131-
let r
135+
process.stdout.write(renderSummary(env) + "\n")
136+
}
137+
138+
if (args.post) {
139+
// Started here, not above: the `not_requested` path reports 0 and never reads these, and
140+
// capturing them earlier made that hardcoded 0 look like an oversight.
141+
const postStartedAt = Date.now()
142+
const postDuration = () => Date.now() - postStartedAt
143+
let target
132144
try {
133-
r = await postGitHubReview(env, target)
145+
target = await resolveGitHubTarget()
134146
} catch (err) {
135-
// A throw here means the summary comment itself failed; nothing was published.
136-
emitReviewPostOutcome({ outcome: "summary_failed", durationMs: postDuration(), sessionID: "" })
147+
// Defensive today — the resolver returns undefined rather than throwing — but the
148+
// contract should not rest on that staying true. No summary was attempted.
149+
emitPostOnce("target_unresolved", postDuration())
137150
throw err
138151
}
139-
emitReviewPostOutcome({
140-
outcome: classifyPostOutcome(r),
141-
durationMs: postDuration(),
142-
sessionID: "",
143-
})
144-
const where = `${target.owner}/${target.repo}#${target.prNumber}`
145-
if (r.postError) {
146-
UI.println(`⚠️ Posted the summary comment to ${where}, but the review event failed: ${r.postError}`)
147-
} else {
152+
if (!target) {
153+
emitPostOnce("target_unresolved", postDuration())
148154
UI.println(
149-
`Posted review to ${where}` +
150-
(r.inlineFellBack ? " (inline comments fell back to summary-only)" : ""),
155+
"⚠️ --post requested but GITHUB_TOKEN / GITHUB_REPOSITORY / PR number could not be resolved; skipping post.",
151156
)
157+
} else {
158+
let r
159+
try {
160+
r = await postGitHubReview(env, target)
161+
} catch (err) {
162+
// A throw here means the summary comment itself failed; nothing was published.
163+
emitPostOnce("summary_failed", postDuration())
164+
throw err
165+
}
166+
emitPostOnce(classifyPostOutcome(r), postDuration())
167+
const where = `${target.owner}/${target.repo}#${target.prNumber}`
168+
if (r.postError) {
169+
UI.println(`⚠️ Posted the summary comment to ${where}, but the review event failed: ${r.postError}`)
170+
} else {
171+
UI.println(
172+
`Posted review to ${where}` +
173+
(r.inlineFellBack ? " (inline comments fell back to summary-only)" : ""),
174+
)
175+
}
152176
}
153177
}
178+
} finally {
179+
// Anything that threw between the completed review and the post attempt — a bad
180+
// `--output` path, a stdout write error. Latched, so a real outcome always wins.
181+
emitPostOnce("not_attempted", 0)
154182
}
183+
// altimate_change end
155184

156185
// Gate: exit non-zero when blocking, so CI fails the check.
157186
if (env.mode === "gate" && env.verdict === "REQUEST_CHANGES") {

packages/opencode/test/altimate/review/telemetry.test.ts

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,54 @@ describe("review_run", () => {
9595
expect(Object.keys(byCategory)).toHaveLength(ReviewCategory.options.length)
9696
})
9797

98+
test("a category naming an Object.prototype member cannot slip past the guard", () => {
99+
// The ordinary-string case above cannot catch this: `{}` plus `in` returns true for every
100+
// prototype member, so `toString` both minted a dimension AND made `counts[k] += 1` evaluate
101+
// `<native function> + 1` — a string inside a Record<string, number>. Fails before the
102+
// Object.create(null) / Object.hasOwn fix.
103+
const events = captureEvents()
104+
emitReviewRun({
105+
invocation: "cli",
106+
durationMs: 1,
107+
sessionID: "",
108+
envelope: envelope({
109+
findings: [
110+
{ category: "toString", severity: "warning" },
111+
{ category: "constructor", severity: "warning" },
112+
{ category: "valueOf", severity: "warning" },
113+
{ category: "__proto__", severity: "warning" },
114+
],
115+
}),
116+
})
117+
118+
const byCategory = (events[0] as any).by_category
119+
expect(Object.keys(byCategory)).toHaveLength(ReviewCategory.options.length)
120+
for (const v of Object.values(byCategory)) expect(typeof v).toBe("number")
121+
})
122+
123+
test("stale_manifest and degraded are carried from the envelope", () => {
124+
// Same `=== true` normalisation as tier_forced, which has its own test; these two had none,
125+
// and the shared envelope() helper omits staleManifest so every other test covers only the
126+
// undefined case.
127+
const events = captureEvents()
128+
emitReviewRun({ invocation: "cli", durationMs: 1, sessionID: "", envelope: envelope() })
129+
expect((events[0] as any).stale_manifest).toBe(false)
130+
expect((events[0] as any).degraded).toBe(false)
131+
132+
events.length = 0
133+
emitReviewRun({
134+
invocation: "cli",
135+
durationMs: 1,
136+
sessionID: "",
137+
envelope: envelope({
138+
staleManifest: true,
139+
summary: { critical: 0, warning: 0, suggestion: 0, degraded: true },
140+
}),
141+
})
142+
expect((events[0] as any).stale_manifest).toBe(true)
143+
expect((events[0] as any).degraded).toBe(true)
144+
})
145+
98146
test("the tool path carries its session, the CLI path does not", () => {
99147
const events = captureEvents()
100148
emitReviewRun({ invocation: "tool", durationMs: 1, sessionID: "ses_abc", envelope: envelope() })
@@ -142,6 +190,22 @@ describe("review_run", () => {
142190
})
143191
})
144192

193+
describe("telemetry failure isolation", () => {
194+
// The two empty catch blocks in the emitters are the "observability must never break
195+
// functionality" guarantee. Removing either one fails these and nothing else.
196+
test("a throwing Telemetry.track cannot propagate out of either emitter", () => {
197+
spyOn(Telemetry, "track").mockImplementation(() => {
198+
throw new Error("buffer full")
199+
})
200+
201+
expect(() =>
202+
emitReviewRun({ invocation: "cli", durationMs: 1, sessionID: "", envelope: envelope() }),
203+
).not.toThrow()
204+
expect(() => emitReviewRun({ invocation: "cli", durationMs: 1, sessionID: "", error: new Error("x") })).not.toThrow()
205+
expect(() => emitReviewPostOutcome({ outcome: "not_requested", durationMs: 0, sessionID: "" })).not.toThrow()
206+
})
207+
})
208+
145209
describe("failure classification", () => {
146210
test("the config loader's fixed prefix", () => {
147211
expect(classifyReviewFailure(new Error("Failed to load .altimate/review.yml: bad yaml"))).toBe("config_error")
@@ -152,6 +216,13 @@ describe("failure classification", () => {
152216
expect(classifyReviewFailure(err)).toBe("git_error")
153217
})
154218

219+
test("a git-shaped message without a cmd is not a git error", () => {
220+
// The message fallback that used to classify this was unreachable for the real git path
221+
// (execFile always sets `cmd`, and its message starts "Command failed: ") and contradicted
222+
// the docstring's promise not to substring-match. Removed.
223+
expect(classifyReviewFailure(new Error("git diff exploded"))).toBe("error")
224+
})
225+
155226
test("anything else is `error` rather than an invented bucket", () => {
156227
// The engine degrades rather than throwing for missing manifests, dispatcher failures and the
157228
// AI lane, so there are no buckets for those — they never arrive here.
@@ -209,7 +280,11 @@ describe("caller attribution", () => {
209280
expect(run.data.baseData.properties.source).toBe("plugin:claude-code")
210281
expect(post.data.baseData.properties.source).toBe("plugin:claude-code")
211282
} finally {
212-
process.env.ALTIMATE_TELEMETRY_DISABLED = origDisabled
283+
// Unlike the two restores below, this was unconditional: an originally-absent variable
284+
// came back as the string "undefined", leaking a disabled-telemetry flag into later tests
285+
// and any child process they spawn.
286+
if (origDisabled !== undefined) process.env.ALTIMATE_TELEMETRY_DISABLED = origDisabled
287+
else delete process.env.ALTIMATE_TELEMETRY_DISABLED
213288
if (origCs !== undefined) process.env.APPLICATIONINSIGHTS_CONNECTION_STRING = origCs
214289
else delete process.env.APPLICATIONINSIGHTS_CONNECTION_STRING
215290
if (origClient !== undefined) process.env.ALTIMATE_CLI_CLIENT = origClient

0 commit comments

Comments
 (0)