Skip to content

Commit 90ffa44

Browse files
sahrizviclaude
andauthored
feat(review): telemetry for review feature usage (#1064)
* feat(review): telemetry for review feature usage The review feature emitted nothing: neither cli/cmd/review.ts nor the engine had any Telemetry reference, and the CLI path creates no session so it never even picked up session_start. We could not answer how often review runs, from where, what it finds, or whether results get published. Two events, emitted from one helper shared by both engine callers — the `review` command and the `dbt_pr_review` tool, distinguished by `invocation`. Instrumenting only the command would have missed every review the agent runs through the tool. review_run one per engine invocation, status completed | failed review_post_outcome publication, CLI path only Caller attribution needs no code at all. The envelope already seeds `source` from Flag.ALTIMATE_CLI_CLIENT, and an event only overrides it by declaring its own `source` field — so these events deliberately declare none, and a caller that exports ALTIMATE_CLI_CLIENT is attributed automatically. That is what let this drop an earlier design carrying a global envelope property, a CLI-wide entrypoint registry, a reviewPullRequest() signature change and typed errors through the engine. None of it was needed. Details that are easy to get wrong, and why they are the way they are: - Publication is a separate event. It happens after the review is computed and can partially succeed, so it cannot honestly be a field on review_run, and a publish failure must not mark a computed review as failed. - `partial` covers every degraded post state. PostResult cannot distinguish them: postError is not cleared when the retry succeeds, and an inline fallback coexists with a real reviewId. - Only two failure reasons plus a fallback. The engine degrades rather than throwing for missing manifests, dispatcher failures and the AI lane, so buckets for those would never fire. Config is matched on its fixed throw prefix and git on the child-process spawn identity, not loose message matching. - `by_category` is zero-filled from ReviewCategory.options and drops unrecognised keys, so a rule that never fired is distinguishable from one that was not possible, and a malformed category cannot mint a new dimension. - `tier_forced` normalises absent to false; the schema treats explicit false as invalid. - `degraded` is the envelope's fidelity flag — no reviewable files, no usable manifest for the changed models, or a surfaced finding whose analysis was undecidable. It does not mean "no warehouse". - The engine call is timed alone; output writing and posting are excluded. Privacy: enums, booleans and counts only. Review findings are about customer schema, and a test asserts that file paths, model and column names, titles and bodies do not appear in the serialized event. The attribution test asserts the serialized customDimensions.source rather than the tracked object, because the envelope seed is invisible to a track() spy — and that seed is the premise the whole design rests on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018SLUQF3xgZHsGZHSjxe7vb * test(review): end-to-end telemetry against a real review process Opt-in via ALTIMATE_E2E=1; skipped otherwise. ~13s. Runs a real `altimate-code review` against a real git repo with its telemetry endpoint pointed at a local sink, and asserts the envelopes that arrive over HTTP. No PTY needed, unlike the onboarding funnel tests — review is a one-shot command. This is the only test that can prove the design's central claim. Caller attribution works because the events declare no `source` field, so the envelope's process-level value survives serialization. A Telemetry.track spy cannot see that, and no in-process test can show that a variable set in a caller's environment reaches a separate process at all. Here the run is spawned with nothing but ALTIMATE_CLI_CLIENT set, and the arriving event carries source=plugin:claude-code. Also asserts the shape as actually serialized rather than as intended: invocation, status, a numeric duration measurement, by_category as a 14-key JSON string per house convention, and a post-outcome event honestly reporting not_requested. Finally it greps the whole payload for the fixture's file name, changed SQL and repo path, since review findings are about customer schema. Confirmed while building it: a two-line diff takes ~9s of engine time, and a repo with a dbt_project.yml but no compiled manifest reports degraded=true — which is why that field is documented as a fidelity flag rather than "no warehouse". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018SLUQF3xgZHsGZHSjxe7vb * docs(review): document the review telemetry events Required by the contributor checklist on this page, and the page is a user-facing contract: it publishes what the CLI collects and what it never collects, so adding events without listing them makes it untrue. Also records three things that are easy to misread from the field names alone: - `degraded` is a fidelity flag, not a warehouse flag. Confirmed end to end — a repo with a dbt_project.yml but no compiled manifest reports degraded=true. - The category breakdown counts surfaced findings, after de-duplication, rubric exclusion and the severity threshold, grouped by category rather than by rule. `Finding` does not retain a rule key, so rule-level effectiveness is not measurable from this. - The tool path also emits the standard `tool_call` event for the same review, so dashboards should count `review_run`, not both. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018SLUQF3xgZHsGZHSjxe7vb * 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 * fix(review): scope the post-outcome invariant to the CLI, de-order the attribution test Bot review on #1064. Four findings, all valid; two are the same class already fixed on the sibling onboarding PR. - `telemetry.md`: the `review_post_outcome` row claimed "exactly one per **completed** review, so absence means the review failed". That invariant holds only on the CLI path — the `dbt_pr_review` tool completes reviews and never publishes by design, so an analyst correlating events would have read every tool-invoked review as a failure. Scoped to the CLI and the tool path called out explicitly. This was a real error in the wording I added in `4ba07a7`. - `telemetry.md`: the general "each event includes a timestamp, session ID, CLI version, machine ID" sentence had ended up *inside* the review-specific subsection, reading as review-only. Hoisted above it. - `telemetry.test.ts`: the caller-attribution test called the real `Telemetry.init()` without clearing the memoized `initPromise` first, so any earlier init in the same process — including one under `ALTIMATE_TELEMETRY_DISABLED` — silently won and the connection string set by the test was ignored. It also relied on a sibling describe's `afterEach` having undone the `Telemetry.track` spy. Now restores mocks (before installing its own fetch spy, not after), shuts down to clear `initPromise`, asserts `isEnabled()` so a future regression fails with a cause, and reads across all request bodies rather than `bodies[0]`. - Same test: real `init()` writes `~/.altimate/machine-id`, so running the unit suite minted an identity the developer's own CLI would then reuse. `HOME` now points at a temp dir, restored and removed in `finally` — the pattern the e2e in this PR already used. Proven rather than assumed: adding a prior `init()` to this same file makes the attribution test fail without the `shutdown()` and pass with it. Verified: turbo typecheck clean; 921 tests across review/telemetry/upstream/ branding pass; all three `analyze.ts` gates (`--markers --strict`, `--branding`, `--require-markers --strict`) exit 0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018SLUQF3xgZHsGZHSjxe7vb --------- Co-authored-by: Haider <haider@altimate.ai> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 12b58bd commit 90ffa44

7 files changed

Lines changed: 862 additions & 46 deletions

File tree

docs/docs/reference/telemetry.md

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,21 @@ We collect the following categories of events:
6262
| `activation_job_selected` / `first_job_completed` | Which activation job the user started and, where observable, finished. Completion is reported only for the job that was actually selected, so the two form a coherent pair. **Derived** — see the note below. |
6363
| `first_prompt_sent` | The user's first typed message in an onboarding session. Slash commands are excluded, so the hidden `/onboard-connect` submission does not count. |
6464
| `onboarding_abandoned` | The CLI exited during a first run without connecting. `last_stage` is the furthest point reached: `started`, `model_picker`, `provider_setup`, `big_pickle_confirm`, or `gateway_auth`. (`connected` is a funnel position but never a `last_stage` — reaching it means the run completed, which is not an abandonment.) Only emitted for a genuine first run — opening `/connect` as an existing user does not enter the funnel, and abandonment after setup completes is out of scope by definition. Emitted on the exit path under a bounded flush, so the measured rate is a lower bound — see [Delivery & Reliability](#delivery--reliability). |
65+
| `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. |
66+
| `review_post_outcome` | Whether a review was published to GitHub — `not_requested`, `not_attempted`, `target_unresolved`, `full`, `partial`, or `summary_failed`, plus duration. Emitted on the **CLI path only** — the `dbt_pr_review` tool completes reviews but never publishes, so a `review_run` with `invocation: tool` has no post event and that is not a failure. Within the CLI path there is exactly one per **completed** review: a review that failed emits `review_run: failed` and no post event, so absence there 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. |
67+
68+
Each event includes a timestamp, anonymous session ID, a per-launch correlation ID (`launch_id` — a random value regenerated every process start, not persisted and not derived from your machine or identity; it exists only to group events from the same run), CLI version, and an anonymous machine ID (a random UUID stored in `~/.altimate/machine-id`, generated once and never tied to any personal information).
69+
70+
### Notes on the review events
71+
72+
- `degraded` is a fidelity flag, not a warehouse flag. It is set when a review found no reviewable
73+
files, had no usable manifest for the changed models, or surfaced a finding whose analysis was
74+
undecidable. It does not mean "no warehouse was connected".
75+
- The category breakdown counts findings that were actually surfaced — after de-duplication, rubric
76+
exclusion, and the severity threshold. It is not a count of raw rule detections, and it is grouped
77+
by category rather than by individual rule.
78+
- Reviews run through the `dbt_pr_review` tool also emit the standard `tool_call` event. They are
79+
the same review; count `review_run` rather than both.
6580

6681
### A note on the derived activation events
6782

@@ -72,8 +87,6 @@ They are therefore inferred from the closest deterministic signals — the menu
7287
- The "something else" branch has no tool signature at all and is never counted.
7388
- `first_job_completed` only fires for jobs with a real completion signal. Skill-driven jobs (downstream impact, SQL review, cost) load an instruction bundle and then do their work through other tools, so their completion is not observable and they are absent from this event rather than wrongly counted in it.
7489

75-
Each event includes a timestamp, anonymous session ID, a per-launch correlation ID (`launch_id` — a random value regenerated every process start, not persisted and not derived from your machine or identity; it exists only to group events from the same run), CLI version, and an anonymous machine ID (a random UUID stored in `~/.altimate/machine-id`, generated once and never tied to any personal information).
76-
7790
## Delivery & Reliability
7891

7992
Telemetry events are buffered in memory and flushed periodically. If a flush fails (e.g., due to a transient network error), events are re-added to the buffer for one retry. On process exit, the CLI performs a final flush to avoid losing events from the current session.
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
// altimate_change start — review feature telemetry.
2+
//
3+
// The review engine has two callers: the `review` CLI command and the `dbt_pr_review` tool. They
4+
// share this helper so there is one telemetry contract rather than two that drift — the zero-fill,
5+
// the privacy filtering and the failure classification all live here.
6+
//
7+
// Caller attribution needs no code: neither event declares a `source` field, so the envelope's
8+
// process-level `source` (from Flag.ALTIMATE_CLI_CLIENT) passes through untouched. A caller that
9+
// exports that variable is attributed automatically; one that does not reports `cli`.
10+
import { Telemetry } from "../telemetry"
11+
import { ReviewCategory, type Finding } from "./finding"
12+
import type { VerdictEnvelope } from "./verdict"
13+
import type { PostResult } from "./post-github"
14+
15+
export type ReviewInvocation = "cli" | "tool"
16+
17+
/**
18+
* Count surfaced findings by category, zero-filled across the whole enum.
19+
*
20+
* Zero-filled so a category that never fires is distinguishable from one that was never possible
21+
* in this run — an absent key and a zero mean different things to whoever reads the dashboard.
22+
* Keys come from `ReviewCategory.options`, never from the finding values themselves:
23+
* `Telemetry.aggregateFindings` accepts arbitrary strings and returns only observed keys, so a
24+
* malformed category would otherwise become a new dimension.
25+
*/
26+
function countByCategory(findings: Finding[]): 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)
32+
for (const category of ReviewCategory.options) counts[category] = 0
33+
for (const finding of findings) {
34+
if (Object.hasOwn(counts, finding.category)) counts[finding.category] += 1
35+
}
36+
return counts
37+
}
38+
39+
/**
40+
* Classify a thrown review failure without threading typed errors through the engine.
41+
*
42+
* Only two failure modes actually propagate — everything else in the engine degrades rather than
43+
* throwing (missing manifests, dispatcher failures and the AI lane are all caught and turned into
44+
* empty or degraded results). So this deliberately recognises two and calls the rest `error`
45+
* rather than inventing buckets that can never occur.
46+
*
47+
* Matching is on the fixed prefix the config loader throws with, and on the spawn identity of the
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.
55+
*/
56+
export function classifyReviewFailure(err: unknown): "config_error" | "git_error" | "error" {
57+
const message = err instanceof Error ? err.message : String(err)
58+
if (message.startsWith("Failed to load")) return "config_error"
59+
const cmd = (err as { cmd?: unknown } | undefined)?.cmd
60+
if (typeof cmd === "string" && /(^|[\\/\s])git(\s|$)/.test(cmd)) return "git_error"
61+
return "error"
62+
}
63+
64+
/**
65+
* Map a PostResult onto the outcome enum.
66+
*
67+
* `PostResult` cannot express finer states than this: an inline fallback and a recorded post error
68+
* can coexist with a real review id, and `postError` is not cleared when the retry succeeds. So
69+
* everything short of a clean full post collapses to `partial` rather than pretending to a
70+
* precision the shape does not have. A throw before the summary is posted never reaches here — the
71+
* caller reports `summary_failed` for that.
72+
*/
73+
export function classifyPostOutcome(result: PostResult): "full" | "partial" {
74+
if (result.inlineFellBack || result.postError || result.reviewId === undefined) return "partial"
75+
return "full"
76+
}
77+
78+
/** Emitted once per engine invocation, whichever caller reached it. */
79+
export function emitReviewRun(input: {
80+
invocation: ReviewInvocation
81+
durationMs: number
82+
/** Empty on the CLI path, which has no chat session. */
83+
sessionID: string
84+
envelope?: VerdictEnvelope
85+
error?: unknown
86+
}): void {
87+
try {
88+
const base = {
89+
type: "review_run" as const,
90+
timestamp: Date.now(),
91+
session_id: input.sessionID,
92+
invocation: input.invocation,
93+
duration_ms: input.durationMs,
94+
}
95+
96+
if (!input.envelope) {
97+
Telemetry.track({ ...base, status: "failed", reason: classifyReviewFailure(input.error) })
98+
return
99+
}
100+
101+
const env = input.envelope
102+
Telemetry.track({
103+
...base,
104+
status: "completed",
105+
verdict: env.verdict,
106+
ideal_verdict: env.idealVerdict,
107+
// The effective mode, which config can set — not whatever the caller passed as a flag.
108+
mode: env.mode,
109+
tier: env.tier,
110+
// Optional in the schema and explicitly invalid as `false`, so normalise rather than copy.
111+
tier_forced: env.tierForced === true,
112+
degraded: env.summary.degraded,
113+
stale_manifest: env.staleManifest === true,
114+
critical: env.summary.critical,
115+
warning: env.summary.warning,
116+
suggestion: env.summary.suggestion,
117+
by_category: countByCategory(env.findings),
118+
})
119+
} catch {
120+
// Telemetry must never fail a review.
121+
}
122+
}
123+
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+
*/
132+
export function emitReviewPostOutcome(input: {
133+
outcome: "not_requested" | "not_attempted" | "target_unresolved" | "full" | "partial" | "summary_failed"
134+
durationMs: number
135+
sessionID: string
136+
}): void {
137+
try {
138+
Telemetry.track({
139+
type: "review_post_outcome",
140+
timestamp: Date.now(),
141+
session_id: input.sessionID,
142+
outcome: input.outcome,
143+
duration_ms: input.durationMs,
144+
})
145+
} catch {
146+
// Telemetry must never fail a review.
147+
}
148+
}
149+
// altimate_change end

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

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -906,6 +906,59 @@ export namespace Telemetry {
906906
}
907907
// altimate_change end
908908

909+
// altimate_change start — review feature usage.
910+
//
911+
// Deliberately NO `source` field on either event: the envelope seeds `source` from
912+
// Flag.ALTIMATE_CLI_CLIENT and an event-declared `source` would override it. Leaving it off is
913+
// what makes caller attribution work with no other code — a plugin setting
914+
// ALTIMATE_CLI_CLIENT is already attributed.
915+
| {
916+
type: "review_run"
917+
timestamp: number
918+
/** Real session on the tool path; empty for the CLI command, which has no chat session. */
919+
session_id: string
920+
/** Which caller reached the engine. `source` says who launched the process; this says how
921+
* review was invoked within it. */
922+
invocation: "cli" | "tool"
923+
status: "completed" | "failed"
924+
duration_ms: number
925+
/** Present when status is `completed`. */
926+
verdict?: string
927+
ideal_verdict?: string
928+
mode?: string
929+
tier?: string
930+
tier_forced?: boolean
931+
/** The envelope's fidelity flag: no reviewable files, no usable manifest for the changed
932+
* models, OR a surfaced finding whose engine analysis was undecidable. It does NOT mean
933+
* merely "no warehouse". */
934+
degraded?: boolean
935+
stale_manifest?: boolean
936+
critical?: number
937+
warning?: number
938+
suggestion?: number
939+
/** JSON object of the 14-value ReviewCategory enum, zero-filled. Counts surfaced findings
940+
* after dedupe, rubric exclusion and severity threshold — not raw rule detections, and
941+
* not rule-level: `Finding` does not retain a rule key. */
942+
by_category?: Record<string, number>
943+
/** Present when status is `failed`. */
944+
reason?: "config_error" | "git_error" | "error"
945+
}
946+
| {
947+
type: "review_post_outcome"
948+
timestamp: number
949+
session_id: string
950+
/** `partial` covers every "not fully posted as attempted" state PostResult can express —
951+
* inline comments fell back, a post error was recorded, or no review id came back. The
952+
* shape cannot distinguish finer outcomes than that. */
953+
/** `not_attempted`: publication was requested, but the invocation died between the
954+
* completed review and the post attempt (a bad `--output` path, a stdout write error).
955+
* Emitted from the caller's `finally` so a completed review always carries exactly one
956+
* post outcome. */
957+
outcome: "not_requested" | "not_attempted" | "target_unresolved" | "full" | "partial" | "summary_failed"
958+
duration_ms: number
959+
}
960+
// altimate_change end
961+
909962
/** SHA256 hash a masked error message for anonymous grouping. */
910963
// altimate_change start — provider identity for the onboarding funnel.
911964
//

packages/opencode/src/altimate/tools/dbt-pr-review.ts

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import z from "zod"
22
import { Tool } from "../../tool/tool"
33
import { Instance } from "../../project/instance"
44
import { reviewPullRequest } from "../review/run"
5+
// altimate_change — review feature telemetry
6+
import { emitReviewRun } from "../review/telemetry"
57
import { renderSummary, verdictHeadline } from "../review/format"
68
import { ReviewMode } from "../review/verdict"
79

@@ -35,14 +37,36 @@ export const DbtPrReviewTool = Tool.define("dbt_pr_review", {
3537
}),
3638
async execute(args, ctx) {
3739
const cwd = Instance.directory
38-
const env = await reviewPullRequest({
39-
cwd,
40-
base: args.base,
41-
head: args.head,
42-
manifestPath: args.manifest_path,
43-
mode: args.mode,
44-
modelVersion: ctx.agent,
40+
// altimate_change start — same review_run event as the CLI path, distinguished by
41+
// `invocation`. Instrumenting only cli/cmd/review.ts would miss every review the agent runs
42+
// through this tool, which is a real share of usage. Unlike the CLI, this path has a session.
43+
const startedAt = Date.now()
44+
let env
45+
try {
46+
env = await reviewPullRequest({
47+
cwd,
48+
base: args.base,
49+
head: args.head,
50+
manifestPath: args.manifest_path,
51+
mode: args.mode,
52+
modelVersion: ctx.agent,
53+
})
54+
} catch (err) {
55+
emitReviewRun({
56+
invocation: "tool",
57+
durationMs: Date.now() - startedAt,
58+
sessionID: ctx.sessionID,
59+
error: err,
60+
})
61+
throw err
62+
}
63+
emitReviewRun({
64+
invocation: "tool",
65+
durationMs: Date.now() - startedAt,
66+
sessionID: ctx.sessionID,
67+
envelope: env,
4568
})
69+
// altimate_change end
4670
return {
4771
title: verdictHeadline(env),
4872
metadata: {

0 commit comments

Comments
 (0)