metergraph-cli 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +60 -11
  2. package/assets/skill/SKILL.md +52 -3
  3. package/assets/skill/manifest.json +3 -3
  4. package/assets/skills/manifest.json +13 -13
  5. package/assets/skills/metergraph-analyze/SKILL.md +62 -4
  6. package/assets/skills/metergraph-investigate/SKILL.md +222 -35
  7. package/assets/skills/metergraph-onboarding/SKILL.md +7 -0
  8. package/assets/skills/metergraph-report/SKILL.md +19 -2
  9. package/bin/metergraph.js +1 -1
  10. package/package.json +1 -1
  11. package/src/agent-key.js +160 -0
  12. package/src/approval-handoff.js +5 -0
  13. package/src/args.js +572 -17
  14. package/src/auth-browser.js +5 -19
  15. package/src/auth-callback.js +24 -15
  16. package/src/auth-login.js +55 -12
  17. package/src/auth-oauth.js +77 -7
  18. package/src/callback-page.js +58 -0
  19. package/src/cli.js +190 -45
  20. package/src/constants.js +73 -3
  21. package/src/environment.js +169 -0
  22. package/src/experiments-contract.js +462 -0
  23. package/src/experiments-output.js +189 -0
  24. package/src/experiments.js +397 -0
  25. package/src/origin.js +6 -0
  26. package/src/output.js +437 -131
  27. package/src/pricing-args.js +163 -0
  28. package/src/pricing-output.js +206 -0
  29. package/src/pricing-reference.js +195 -0
  30. package/src/pricing.js +307 -0
  31. package/src/read-contract.js +976 -16
  32. package/src/read-output.js +449 -30
  33. package/src/read.js +312 -60
  34. package/src/setup-deployment.js +11 -3
  35. package/src/setup-interactive.js +341 -0
  36. package/src/setup-refusal.js +172 -0
  37. package/src/setup-state.js +4 -2
  38. package/src/setup.js +227 -53
  39. package/src/skill-bundle.js +2 -2
  40. package/src/style.js +101 -0
  41. package/src/transport.js +13 -7
  42. package/src/usage-aggregates.js +197 -0
  43. package/src/verify-output.js +21 -11
@@ -0,0 +1,197 @@
1
+ import { GROUP_KEY } from "./read-contract.js";
2
+
3
+ // Ranking and prior-period comparison for grouped usage. Both work on reports
4
+ // already validated by usageAggregatesReport and say when the returned groups
5
+ // are not every group, so a ranking or a change is never presented as
6
+ // complete when it is not.
7
+
8
+ const HOUR_MS = 60 * 60 * 1000;
9
+ const DAY_MS = 24 * HOUR_MS;
10
+
11
+ // A group's value for one dimension. A metadata dimension's value is
12
+ // metadata_value, and the group of calls without one is told apart from an
13
+ // empty string.
14
+ const valueOf = (item, dimension) =>
15
+ dimension.startsWith("metadata.") ? (item.has_value ? item.metadata_value ?? "" : null) : item[dimension];
16
+ // Groups are joined by the raw group values the validator kept (GROUP_KEY),
17
+ // never by display values, which can be null for text that is unsafe to show.
18
+ const byKey = (groupBy) => (item) =>
19
+ item[GROUP_KEY] ?? JSON.stringify(groupBy.map((dimension) => valueOf(item, dimension) ?? null));
20
+ const desc = (pick) => (a, b) => {
21
+ const left = pick(a);
22
+ const right = pick(b);
23
+ // Unknown values (null) rank last, whichever direction.
24
+ if (left === null && right === null) return 0;
25
+ if (left === null) return 1;
26
+ if (right === null) return -1;
27
+ return right - left;
28
+ };
29
+ const then = (...orders) => (a, b) => {
30
+ for (const order of orders) {
31
+ const result = order(a, b);
32
+ if (result !== 0) return result;
33
+ }
34
+ return 0;
35
+ };
36
+
37
+ // The service orders groups by cost, then calls, then group key. Every other
38
+ // order is applied here over the returned groups.
39
+ const ORDERS = {
40
+ cost: [desc((item) => item.cost_usd), desc((item) => item.calls)],
41
+ "cost-per-call": [desc((item) => item.cost_per_call_usd), desc((item) => item.cost_usd)],
42
+ calls: [desc((item) => item.calls), desc((item) => item.cost_usd)],
43
+ errors: [desc((item) => item.error_calls), desc((item) => item.error_rate), desc((item) => item.calls)],
44
+ "avg-latency": [desc((item) => item.latency.avg_ms), desc((item) => item.calls)],
45
+ "p95-latency": [desc((item) => item.latency.p95_ms), desc((item) => item.calls)],
46
+ day: [(a, b) => (a.day < b.day ? -1 : a.day > b.day ? 1 : 0), desc((item) => item.cost_usd)],
47
+ };
48
+
49
+ // What each order means, for JSON and text output.
50
+ export const SORT_LABELS = Object.freeze({
51
+ cost: "cost, highest first",
52
+ "cost-per-call": "cost per call (cost_usd / calls), highest first",
53
+ calls: "calls, most first",
54
+ errors: "error calls, most first",
55
+ "avg-latency": "mean latency over calls that reported one, highest first",
56
+ "p95-latency": "estimated p95 latency (fixed histogram), highest first",
57
+ day: "UTC day, oldest first",
58
+ });
59
+
60
+ // Orders the report's items and numbers them. A ranking other than by cost
61
+ // over a cut list (the service returns the top groups by cost) is not a
62
+ // ranking of every group, and says so.
63
+ export function rankReport(report, sort, notices) {
64
+ const key = byKey(report.group_by);
65
+ const order = then(...ORDERS[sort], (a, b) => (key(a) < key(b) ? -1 : key(a) > key(b) ? 1 : 0));
66
+ const items = [...report.items].sort(order).map((item, index) => ({ rank: index + 1, ...item }));
67
+ const rankingComplete = sort === "cost" || !report.truncated;
68
+ if (!rankingComplete) notices.add("ranking_partial");
69
+ return {
70
+ ...report,
71
+ items,
72
+ sort: { by: sort, meaning: SORT_LABELS[sort], complete: rankingComplete },
73
+ };
74
+ }
75
+
76
+ // The window to compare with, from the current window the service read.
77
+ // "previous" is the whole period before: for a preset of N UTC days, the N
78
+ // whole UTC days before the current UTC day's start, even though the current
79
+ // window ends part way through today. "previous-same-time" ends the prior
80
+ // window at the same elapsed time, so both windows are the same length.
81
+ export function priorWindow(window, mode) {
82
+ const since = Date.parse(window.since);
83
+ const until = Date.parse(window.until);
84
+ const length = window.preset === "utc_days" ? window.days * DAY_MS : until - since;
85
+ const priorSince = since - length;
86
+ const priorUntil = mode === "previous" ? since : priorSince + (until - since);
87
+ return { since: new Date(priorSince).toISOString(), until: new Date(priorUntil).toISOString() };
88
+ }
89
+
90
+ // The service reports amounts to 8 decimals; keep that precision and take the
91
+ // percentage from the unrounded difference.
92
+ const change = (now, before) => {
93
+ if (now === null || before === null) return { delta: null, percent: null };
94
+ const raw = now - before;
95
+ return {
96
+ delta: Math.round(raw * 1e8) / 1e8,
97
+ percent: before === 0 ? null : Math.round((raw / before) * 1000) / 10,
98
+ };
99
+ };
100
+ // A cost change is known only when both sides' cost is exact: a lower bound
101
+ // can hide a larger true cost and reverse the direction.
102
+ const exact = (pricing) => pricing?.cost === "exact";
103
+ const costChange = (now, before, nowPricing, beforePricing) =>
104
+ exact(nowPricing) && exact(beforePricing) ? change(now, before) : { delta: null, percent: null };
105
+ // A window's totals are the whole question only when its evidence and
106
+ // coverage are complete (rows cut at --limit do not matter for totals);
107
+ // otherwise unseen calls could reverse a change, so none is reported.
108
+ const covered = (report) =>
109
+ report.evidence?.complete !== false &&
110
+ (report.coverage === null || report.coverage === undefined || report.coverage.completeness === "complete");
111
+
112
+ const sideOf = (item) => ({ calls: item.calls, error_calls: item.error_calls, cost_usd: item.cost_usd, pricing: item.pricing });
113
+
114
+ // Joins the current and prior reports group by group. A group missing from a
115
+ // window whose groups are all listed had no calls there (zero); missing from
116
+ // a cut window it is unknown (null), never zero.
117
+ export function compareReports(current, prior, mode, notices) {
118
+ const key = byKey(current.group_by);
119
+ const priorByKey = new Map(prior.items.map((item) => [key(item), item]));
120
+ const currentKeys = new Set(current.items.map(key));
121
+ // No calls in a fully listed window: zero, and exactly so.
122
+ const zero = {
123
+ calls: 0, error_calls: 0, cost_usd: 0,
124
+ pricing: { priced_calls: 0, zero_cost_calls: 0, partial_calls: 0, unpriced_calls: 0, unknown_calls: 0, cost: "exact" },
125
+ };
126
+ // Absence proves zero only in a window whose evidence is complete (not cut,
127
+ // and nothing pending, clipped or unknown); otherwise it is unknown.
128
+ const absent = (report) => (report.complete ? zero : null);
129
+ const group = (item) => Object.fromEntries(current.group_by.flatMap((dimension) =>
130
+ dimension.startsWith("metadata.")
131
+ ? [["metadata_key", item.metadata_key], ["metadata_value", item.metadata_value], ["has_value", item.has_value]]
132
+ : [[dimension, item[dimension]]]));
133
+ const bothCovered = covered(current) && covered(prior);
134
+ const unknownChange = { delta: null, percent: null };
135
+ const row = (item, now, before) => ({
136
+ ...group(item),
137
+ current: now,
138
+ prior: before,
139
+ cost_change: bothCovered
140
+ ? costChange(now?.cost_usd ?? null, before?.cost_usd ?? null, now?.pricing, before?.pricing)
141
+ : unknownChange,
142
+ calls_change: bothCovered ? change(now?.calls ?? null, before?.calls ?? null) : unknownChange,
143
+ });
144
+ const items = current.items.map((item) => {
145
+ const match = priorByKey.get(key(item));
146
+ return row(item, sideOf(item), match === undefined ? absent(prior) : sideOf(match));
147
+ });
148
+ const onlyPrior = prior.items
149
+ .filter((item) => !currentKeys.has(key(item)))
150
+ .map((item) => row(item, absent(current), sideOf(item)));
151
+ const currentHours = current.window.hours;
152
+ const priorHours = prior.window.hours;
153
+ // A window that ends part way through the current UTC day compared with a
154
+ // longer prior period: totals are not like for like.
155
+ const unequal = Math.abs(currentHours - priorHours) > 0.01;
156
+ const partialAgainstFull = unequal && current.window.partial_days.length > 0;
157
+ if (partialAgainstFull) notices.add("partial_day_comparison");
158
+ const complete = current.complete && prior.complete;
159
+ if (!complete) notices.add("comparison_incomplete");
160
+ const totals = (report) => ({
161
+ calls: report.totals.calls,
162
+ error_calls: report.totals.error_calls,
163
+ cost_usd: report.totals.cost_usd,
164
+ });
165
+ const likeForLike = partialAgainstFull ? priorWindow(current.window, "previous-same-time") : null;
166
+ return {
167
+ mode,
168
+ prior: {
169
+ window: prior.window,
170
+ totals: prior.totals,
171
+ coverage: prior.coverage,
172
+ pricing: prior.pricing,
173
+ attribution: prior.attribution,
174
+ evidence: prior.evidence,
175
+ warnings: prior.warnings,
176
+ truncated: prior.truncated,
177
+ complete: prior.complete,
178
+ rows: prior.rows,
179
+ matched_groups: prior.matched_groups,
180
+ },
181
+ hours: { current: currentHours, prior: priorHours },
182
+ partial_day_compared_with_full: partialAgainstFull,
183
+ like_for_like_window: likeForLike,
184
+ complete,
185
+ totals: {
186
+ current: totals(current),
187
+ prior: totals(prior),
188
+ cost_change: bothCovered
189
+ ? costChange(current.totals.cost_usd, prior.totals.cost_usd, current.pricing, prior.pricing)
190
+ : unknownChange,
191
+ cost_change_known: bothCovered && exact(current.pricing) && exact(prior.pricing),
192
+ calls_change: bothCovered ? change(current.totals.calls, prior.totals.calls) : unknownChange,
193
+ calls_change_known: bothCovered,
194
+ },
195
+ items: [...items, ...onlyPrior],
196
+ };
197
+ }
@@ -1,3 +1,5 @@
1
+ import { PLAIN, fields, headline, paragraph, render, steps, toneFor } from "./style.js";
2
+
1
3
  const MESSAGES = Object.freeze({
2
4
  invalid_trace_identity: "Provide exactly one exact trace or request ID from your application run.",
3
5
  invalid_invocation_window: "Provide the start and end timestamps of that application invocation.",
@@ -19,18 +21,26 @@ export function verifyMessage(outcome, reason) {
19
21
  return MESSAGES[reason] ?? `Trace verification stopped (${outcome}). No application traffic claim was made.`;
20
22
  }
21
23
 
22
- export function verifyText(result) {
24
+ export function verifyText(result, style = PLAIN) {
23
25
  const data = result.data;
24
- const lines = ["Metergraph exact trace verification", `Outcome: ${result.outcome} (exit ${result.exit_code})`];
26
+ const title = result.ok ? "Trace verified" : "Trace not verified";
27
+ const lines = [headline(style, toneFor(result.outcome), title, result.outcome), ""];
25
28
  if (data) {
26
- lines.push(`Workspace: ${data.workspace.id}`, `Deployment: ${data.deployment_profile}`,
27
- `Invocation source: ${data.invocation.source}`, `Attempts: ${data.attempts}`,
28
- `Processed: ${data.readiness.processed}`, `Metadata available: ${data.readiness.metadata_available}`,
29
- `Application traffic verified: ${data.application_traffic_verified}`);
30
- if (data.trace) lines.push(`Trace ID: ${data.trace.trace_id}`);
31
- if (data.app_url) lines.push(`Dashboard link${data.link_workspace_bound ? " (workspace bound)" : " (select the correct workspace)"}: ${data.app_url}`);
32
- if (data.browser) lines.push(`Browser: ${data.browser}`);
29
+ lines.push(...fields(style, [
30
+ ["Workspace", data.workspace.id],
31
+ ["Deployment", data.deployment_profile],
32
+ ["Trace ID", data.trace?.trace_id ?? null],
33
+ ["Invocation source", data.invocation.source],
34
+ ["Attempts", data.attempts],
35
+ ["Processed", data.readiness.processed],
36
+ ["Metadata available", data.readiness.metadata_available],
37
+ ["Application traffic verified", data.application_traffic_verified],
38
+ ["Browser", data.browser ?? null],
39
+ ]), "");
40
+ }
41
+ if (result.error) lines.push(...paragraph(style, result.error.message), "");
42
+ if (data?.app_url) {
43
+ lines.push(...steps(style, [[data.link_workspace_bound ? "Dashboard" : "Dashboard (select the correct workspace)", data.app_url]]));
33
44
  }
34
- if (result.error) lines.push(result.error.message);
35
- return `${lines.join("\n")}\n`;
45
+ return render(lines);
36
46
  }