sparkforensics-mcp 0.2.4 → 0.4.0
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.
- package/LICENSE +21 -0
- package/README.md +7 -1
- package/bin/sparkforensics-mcp.mjs +41 -10
- package/package.json +1 -1
- package/vendor-core/analyzer.js +156 -48
- package/vendor-core/check-coverage.js +88 -0
- package/vendor-core/cli/budgets.js +31 -18
- package/vendor-core/cli/collect-run.js +76 -31
- package/vendor-core/cli/native-zstd.js +2 -2
- package/vendor-core/cli/threshold-config.js +28 -0
- package/vendor-core/comparison-verdict.js +177 -0
- package/vendor-core/core-source-hash.txt +1 -0
- package/vendor-core/core-usage-locality.js +56 -2
- package/vendor-core/detector-docs.js +58 -0
- package/vendor-core/detectors.js +933 -459
- package/vendor-core/docs-config.js +0 -36
- package/vendor-core/docs-content/chapters/nav-index.json +31 -0
- package/vendor-core/docs-content/detection/cstor.md +9 -0
- package/vendor-core/docs-content/detection/fail.md +6 -2
- package/vendor-core/docs-site-config.js +3 -0
- package/vendor-core/event-handlers.js +191 -6
- package/vendor-core/event-schemas.js +29 -0
- package/vendor-core/evidence-report.js +440 -112
- package/vendor-core/export-data.js +79 -6
- package/vendor-core/finding-action-label.js +9 -88
- package/vendor-core/finding-filter-predicate.js +9 -0
- package/vendor-core/finding-generic-recommendation.js +6 -104
- package/vendor-core/finding-names.js +21 -45
- package/vendor-core/finding-presentation.js +333 -0
- package/vendor-core/finding-tag-help.js +110 -0
- package/vendor-core/finding-types.js +361 -0
- package/vendor-core/findings-of-type.js +11 -0
- package/vendor-core/format-utils.js +92 -27
- package/vendor-core/html-export.js +51 -0
- package/vendor-core/impact-band.js +21 -8
- package/vendor-core/impact-estimator.js +8 -521
- package/vendor-core/impact-format.js +114 -0
- package/vendor-core/impact-model.js +175 -0
- package/vendor-core/ingest.js +2 -0
- package/vendor-core/intervals.js +13 -0
- package/vendor-core/list-runs.js +2 -3
- package/vendor-core/load-vendored.js +70 -5
- package/vendor-core/mcp-server-factory.js +14 -10
- package/vendor-core/mcp-tools.js +105 -45
- package/vendor-core/model-assembler.js +12 -0
- package/vendor-core/occupancy.js +1 -1
- package/vendor-core/parser-worker.js +22 -5
- package/vendor-core/plan-graph-model.js +3 -2
- package/vendor-core/plan-node-detail.js +1 -1
- package/vendor-core/recommendation-rollup.js +63 -3
- package/vendor-core/redact.js +68 -28
- package/vendor-core/run-comparison.js +40 -7
- package/vendor-core/run-interpretation.js +290 -0
- package/vendor-core/run-outcome.js +74 -0
- package/vendor-core/run-payload.js +17 -0
- package/vendor-core/run-shape.js +40 -0
- package/vendor-core/run-verdict.js +353 -0
- package/vendor-core/scaling-sim.js +4 -5
- package/vendor-core/scorecard-estimates.js +62 -0
- package/vendor-core/shs-fetch.js +175 -65
- package/vendor-core/shs-load.js +1 -1
- package/vendor-core/sql-stages.js +11 -0
- package/vendor-core/stage-quantiles.js +14 -0
- package/vendor-core/task-failure.js +151 -0
- package/vendor-core/threshold-overrides.js +160 -0
- package/vendor-core/threshold-summary.js +11 -33
- package/vendor-core/types.js +6 -42
- package/vendor-core/vendor/fflate.js +1 -1
- package/vendor-core/wall-clock.js +1 -12
- package/vendor-core/wasted-core-hours.js +2 -2
- package/vendor-core/zip-archive.js +167 -0
package/vendor-core/redact.js
CHANGED
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
// Deterministic (sorted assignment), idempotent (pseudonyms map to themselves),
|
|
8
8
|
// and non-mutating (returns a fresh, deep-copied tree).
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
import { decodeCollections, encodeCollections, } from './export-data.js';
|
|
11
|
+
|
|
12
|
+
import { redactTaskFailureGroup, } from './task-failure.js';
|
|
11
13
|
|
|
12
14
|
// Host / IP identifier patterns. Used to enumerate host names that surface only
|
|
13
15
|
// inside free text: recommendation strings, `stageFailed`'s failure-reason
|
|
@@ -34,7 +36,7 @@ const APP_ID_PATTERNS = [/\bapplication_\d{10,}_\d+\b/g];
|
|
|
34
36
|
// Walk every string in the tree once, collecting matches for each `{ patterns,
|
|
35
37
|
// out }` sink. One shared traversal for every token kind (instead of one
|
|
36
38
|
// traversal per kind) keeps redactComparison's dual host+app-id scan the same
|
|
37
|
-
// cost as the single-kind scan redactReport
|
|
39
|
+
// cost as the single-kind scan redactReport already does.
|
|
38
40
|
function scanTokens(node , sinks ) {
|
|
39
41
|
if (typeof node === 'string') {
|
|
40
42
|
for (const { patterns, out } of sinks) {
|
|
@@ -54,11 +56,6 @@ function scanTokens(node , sinks
|
|
|
54
56
|
}
|
|
55
57
|
}
|
|
56
58
|
|
|
57
|
-
// Walk every string in the tree, collecting host/IP tokens into `hosts`.
|
|
58
|
-
function scanHostTokens(node , hosts ) {
|
|
59
|
-
scanTokens(node, [{ patterns: HOST_PATTERNS, out: hosts }]);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
59
|
// Recursively collects every string value found under a key literally named
|
|
63
60
|
// `host`, anywhere in the tree. Host names surface at several depths, a
|
|
64
61
|
// finding's own `host`, `evidence.host`, and now
|
|
@@ -79,12 +76,31 @@ function collectHostFields(node , hosts ) {
|
|
|
79
76
|
}
|
|
80
77
|
}
|
|
81
78
|
|
|
79
|
+
// A failed-task error's message and stack text can carry file paths and data values that no
|
|
80
|
+
// host/app-id pattern recognizes, so they are dropped rather than pseudonymized: every array under
|
|
81
|
+
// a key named `failureGroups` (a `failures` finding's, or a stage's in the HTML export) gets
|
|
82
|
+
// redactTaskFailureGroup applied. Walking by key name, like collectHostFields, needs no path list.
|
|
83
|
+
// Returns a fresh tree.
|
|
84
|
+
function redactFailureGroups (node ) {
|
|
85
|
+
if (Array.isArray(node)) return node.map((n) => redactFailureGroups(n)) ;
|
|
86
|
+
if (node && typeof node === 'object') {
|
|
87
|
+
const out = {};
|
|
88
|
+
for (const [k, v] of Object.entries(node)) {
|
|
89
|
+
out[k] = k === 'failureGroups' && Array.isArray(v)
|
|
90
|
+
? v.map((g) => (g && typeof g === 'object' ? redactTaskFailureGroup(g ) : g))
|
|
91
|
+
: redactFailureGroups(v);
|
|
92
|
+
}
|
|
93
|
+
return out ;
|
|
94
|
+
}
|
|
95
|
+
return node;
|
|
96
|
+
}
|
|
97
|
+
|
|
82
98
|
// Spark config keys ending in `host`/`hostname` (e.g. spark.driver.host,
|
|
83
99
|
// spark.yarn.am.hostname) carry plain FQDN host names that neither
|
|
84
100
|
// HOST_PATTERNS matches (no IP/EC2 shape) nor collectHostFields's by-key-name
|
|
85
101
|
// walk catches (the literal key is the dotted Spark property name, never
|
|
86
102
|
// `host` itself). app.config is a flat Record<string, string> unique to
|
|
87
|
-
//
|
|
103
|
+
// redactRunModel: no other redact* export ships a raw Spark config dict.
|
|
88
104
|
// Known gap: a hostname value under a differently-named key isn't caught by
|
|
89
105
|
// this suffix check. Confirmed against a real cluster config: spark.master,
|
|
90
106
|
// spark.yarn.historyServer.address, and the plural YARN proxy/HA keys
|
|
@@ -172,29 +188,12 @@ function applyReplacements (node , ids
|
|
|
172
188
|
return deepReplace(node, merged) ;
|
|
173
189
|
}
|
|
174
190
|
|
|
175
|
-
export function redactReport (
|
|
191
|
+
export function redactReport (input ) {
|
|
192
|
+
const report = redactFailureGroups(input);
|
|
176
193
|
const { appIds, hosts } = collectIds(report);
|
|
177
194
|
return applyReplacements(report, { appIds, hosts });
|
|
178
195
|
}
|
|
179
196
|
|
|
180
|
-
// Narrow counterpart to redactReport(), for getRunSummary()'s standalone app
|
|
181
|
-
// object (no findings tree to walk). There's exactly one app id here, so no
|
|
182
|
-
// Set/Map/sort is needed for it; name/sparkVersion still go through the
|
|
183
|
-
// shared host/IP scan-and-replace since either can carry a host token as
|
|
184
|
-
// free text.
|
|
185
|
-
export function redactAppIdentity(
|
|
186
|
-
app ,
|
|
187
|
-
) {
|
|
188
|
-
const hosts = new Set ();
|
|
189
|
-
scanHostTokens(app.name, hosts);
|
|
190
|
-
scanHostTokens(app.sparkVersion, hosts);
|
|
191
|
-
return {
|
|
192
|
-
id: typeof app.id === 'string' && app.id.length > 0 ? 'app-1' : app.id,
|
|
193
|
-
name: applyReplacements(app.name, { hosts }),
|
|
194
|
-
sparkVersion: applyReplacements(app.sparkVersion, { hosts }),
|
|
195
|
-
};
|
|
196
|
-
}
|
|
197
|
-
|
|
198
197
|
// Run-comparison counterpart: no single app-id *field* to pseudonymize
|
|
199
198
|
// (baselineLabel/candidateLabel are caller-supplied labels, not Spark app
|
|
200
199
|
// ids), but stage names surface throughout the tree (FindingsDeltaRow.stages,
|
|
@@ -216,7 +215,10 @@ export function redactComparison (comparison ) {
|
|
|
216
215
|
// this also walks executors.added/removed for their literal `host` field
|
|
217
216
|
// (ExecutorAddedEvent.host), since raw executor records: not just findings
|
|
218
217
|
//: reach data.js.
|
|
219
|
-
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
function redactRunTree (input ) {
|
|
221
|
+
const data = redactFailureGroups(input);
|
|
220
222
|
const appIds = new Set ();
|
|
221
223
|
const hosts = new Set ();
|
|
222
224
|
const appId = data.app?.id;
|
|
@@ -229,3 +231,41 @@ export function redactExportData(data ) {
|
|
|
229
231
|
scanTokens(data, [{ patterns: HOST_PATTERNS, out: hosts }, { patterns: APP_ID_PATTERNS, out: appIds }]);
|
|
230
232
|
return applyReplacements(data, { appIds, hosts });
|
|
231
233
|
}
|
|
234
|
+
|
|
235
|
+
/** A run's model and findings with every identifier pseudonymized. Redact this before anything derives
|
|
236
|
+
* text from the run: the verdict truncates Spark's failure reason, and an
|
|
237
|
+
* identifier cut by that truncation is a fragment no later pass can match. */
|
|
238
|
+
export function redactRunModel(
|
|
239
|
+
appModel ,
|
|
240
|
+
catalog ,
|
|
241
|
+
configFindings ,
|
|
242
|
+
) {
|
|
243
|
+
// Maps and Sets would lose their entries in the deep copy, so they cross as
|
|
244
|
+
// tagged plain objects, the way the export payload carries them.
|
|
245
|
+
const tree = encodeCollections({
|
|
246
|
+
app: appModel.app,
|
|
247
|
+
stages: [...appModel.stages.values()],
|
|
248
|
+
jobs: [...appModel.jobs.values()],
|
|
249
|
+
sql: [...appModel.sql.values()],
|
|
250
|
+
executors: appModel.executors,
|
|
251
|
+
runAggregates: appModel.runAggregates,
|
|
252
|
+
evidenceAvailability: appModel.evidenceAvailability,
|
|
253
|
+
catalog,
|
|
254
|
+
configFindings,
|
|
255
|
+
}) ;
|
|
256
|
+
const redacted = decodeCollections(redactRunTree(tree)) ;
|
|
257
|
+
const byId = (items ) => new Map((items ).map((item) => [item.id, item]));
|
|
258
|
+
return {
|
|
259
|
+
appModel: {
|
|
260
|
+
app: redacted.app,
|
|
261
|
+
stages: byId(redacted.stages),
|
|
262
|
+
jobs: byId(redacted.jobs),
|
|
263
|
+
sql: byId(redacted.sql),
|
|
264
|
+
executors: redacted.executors,
|
|
265
|
+
runAggregates: redacted.runAggregates ,
|
|
266
|
+
evidenceAvailability: redacted.evidenceAvailability ,
|
|
267
|
+
} ,
|
|
268
|
+
catalog: redacted.catalog,
|
|
269
|
+
configFindings: redacted.configFindings,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
@@ -2,8 +2,12 @@ import { computeWallClock } from './wall-clock.js';
|
|
|
2
2
|
import { normalizeDetail } from './detectors.js';
|
|
3
3
|
import { cyrb53 } from './string-hash.js';
|
|
4
4
|
import { captureSnapshot } from './session-snapshot.js';
|
|
5
|
-
|
|
5
|
+
import { tunedRunNote } from './threshold-overrides.js';
|
|
6
|
+
|
|
6
7
|
|
|
8
|
+
|
|
9
|
+
import { isIncompleteRun } from './check-coverage.js';
|
|
10
|
+
import { summarizeRunOutcome } from './run-outcome.js';
|
|
7
11
|
|
|
8
12
|
|
|
9
13
|
|
|
@@ -12,7 +16,7 @@ import { captureSnapshot } from './session-snapshot.js';
|
|
|
12
16
|
|
|
13
17
|
|
|
14
18
|
|
|
15
|
-
|
|
19
|
+
|
|
16
20
|
|
|
17
21
|
|
|
18
22
|
|
|
@@ -23,7 +27,16 @@ import { captureSnapshot } from './session-snapshot.js';
|
|
|
23
27
|
|
|
24
28
|
|
|
25
29
|
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
|
|
26
34
|
|
|
35
|
+
|
|
36
|
+
function jobOutcome(snapshot ) {
|
|
37
|
+
const { failedJobs, totalJobs } = summarizeRunOutcome(snapshot.jobs, snapshot.catalog);
|
|
38
|
+
return { failedJobs, totalJobs, incomplete: isIncompleteRun(snapshot.catalog) };
|
|
39
|
+
}
|
|
27
40
|
|
|
28
41
|
// Replace run-varying tokens (digit runs, long hex ids) with a stable marker so
|
|
29
42
|
// the same logical stage across two runs normalizes to one identity.
|
|
@@ -180,6 +193,14 @@ function skewRatios(stages ) {
|
|
|
180
193
|
return out;
|
|
181
194
|
}
|
|
182
195
|
|
|
196
|
+
// Every key metricDeltas() emits, in emission order. The CLI validates
|
|
197
|
+
// --regression-metric against it, so a misspelled key is a usage error rather
|
|
198
|
+
// than an inconclusive budget. A contract test keeps it in sync.
|
|
199
|
+
export const COMPARISON_METRIC_KEYS = [
|
|
200
|
+
'wallClock', 'shuffleSpill', 'taskSkew', 'failedTaskRate', 'diskSpill', 'gcTime',
|
|
201
|
+
'inputBytes', 'outputBytes', 'executorRunTime', 'taskCount', 'executorsAdded',
|
|
202
|
+
];
|
|
203
|
+
|
|
183
204
|
// Volume/count metrics, not cost metrics: more or less input/output data, or
|
|
184
205
|
// tasks/executors, isn't inherently better or worse (it may just reflect a
|
|
185
206
|
// differently-sized job), unlike wall-clock, spill, GC, etc. Exported as the
|
|
@@ -274,14 +295,14 @@ export function metricDeltas(baseSnap , candSnap
|
|
|
274
295
|
export function findingsDelta(baseSnap , candSnap )
|
|
275
296
|
|
|
276
297
|
{
|
|
277
|
-
|
|
298
|
+
|
|
278
299
|
const tally = (snap ) => {
|
|
279
300
|
const m = new Map (); // `${rule}§${impactBand}` -> { rule, impactBand, count, stages:Set }
|
|
280
301
|
for (const f of snap.catalog) {
|
|
281
|
-
const rule = typeof f.rule === 'string' ? f.rule : f.type;
|
|
302
|
+
const rule = 'rule' in f && typeof f.rule === 'string' ? f.rule : f.type;
|
|
282
303
|
const impactBand = f.impactBand ?? 'unknown';
|
|
283
304
|
const key = `${rule}§${impactBand}`;
|
|
284
|
-
const e = m.get(key) ?? { rule, impactBand, count: 0, stages: new Set () };
|
|
305
|
+
const e = m.get(key) ?? { rule, type: f.type, impactBand, count: 0, stages: new Set () };
|
|
285
306
|
e.count++;
|
|
286
307
|
// Resolve stageId → name on this snapshot only: a single-side lookup, so
|
|
287
308
|
// it needs no cross-run identity. App-level findings (stageId null) add none.
|
|
@@ -300,7 +321,7 @@ export function findingsDelta(baseSnap , candSnap
|
|
|
300
321
|
if (candCount === baseCount) continue;
|
|
301
322
|
const meta = ce ?? be ;
|
|
302
323
|
const more = candCount > baseCount ? ce : be ; // the side with more supplies the labels
|
|
303
|
-
const row = { rule: meta.rule, impactBand: meta.impactBand, baseCount, candCount,
|
|
324
|
+
const row = { rule: meta.rule, type: meta.type, impactBand: meta.impactBand, baseCount, candCount,
|
|
304
325
|
delta: candCount - baseCount, stages: [...more.stages].sort() };
|
|
305
326
|
(candCount > baseCount ? introduced : resolved).push(row);
|
|
306
327
|
}
|
|
@@ -420,6 +441,7 @@ export function compareRuns(
|
|
|
420
441
|
stageSkew: stageSkewDeltas(baseSnap, candSnap, match),
|
|
421
442
|
baseStages: stageList(baseSnap),
|
|
422
443
|
candStages: stageList(candSnap),
|
|
444
|
+
jobOutcomes: { baseline: jobOutcome(baseSnap), candidate: jobOutcome(candSnap) },
|
|
423
445
|
};
|
|
424
446
|
}
|
|
425
447
|
|
|
@@ -437,8 +459,19 @@ function renderFindingsSection(title , findings )
|
|
|
437
459
|
// evidence-report.ts's renderMarkdown house style (## section heading, ###
|
|
438
460
|
// subheadings, `- key: value` bullets). Shared by the CLI's --baseline
|
|
439
461
|
// markdown output and the MCP server's compare_runs `format: 'md'`.
|
|
440
|
-
|
|
462
|
+
/** `tuned`: tunedDetectors() for the overrides both runs were analyzed with, named in the output
|
|
463
|
+
* as the evidence report names them. */
|
|
464
|
+
export function renderComparisonMarkdown(
|
|
465
|
+
comparison , verdict , tuned ,
|
|
466
|
+
) {
|
|
441
467
|
const lines = ['', '## Comparison to baseline', ''];
|
|
468
|
+
if (tuned) lines.push(`- Tuned thresholds (both runs): ${tunedRunNote(tuned)}`, '');
|
|
469
|
+
if (verdict) {
|
|
470
|
+
// The dashboard comparison page's headline: run A is the baseline, run B the candidate.
|
|
471
|
+
lines.push(`Run A: ${comparison.baselineLabel} · Run B: ${comparison.candidateLabel}`, '', verdict.title);
|
|
472
|
+
if (verdict.sentences.length > 0) lines.push('', verdict.sentences.join(' '));
|
|
473
|
+
lines.push('');
|
|
474
|
+
}
|
|
442
475
|
if (comparison.confidence === 'low') {
|
|
443
476
|
lines.push(`- confidence: low, ${comparison.reason}`);
|
|
444
477
|
lines.push('');
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
// The interpretation layer of a run as plain data: the verdict, which checks could not run and
|
|
2
|
+
// why, every finding's formatted savings, and the run-shape figures. Everything here can change
|
|
3
|
+
// the conclusion about a run, so it is computed once, by the core that ran the detectors, and
|
|
4
|
+
// handed to the renderer as data. The live dashboard (useIngest) and both HTML-export producers
|
|
5
|
+
// (html-export.ts) call `interpretRun`; the exported bundle only renders what it carries, so an
|
|
6
|
+
// exported file shows the conclusions of the core that wrote it, not of the core that opens it.
|
|
7
|
+
import { checkCoverage, hasFinishedStage, verdictGaps } from './check-coverage.js';
|
|
8
|
+
import { computeCoreLocalityRatio } from './core-locality-ratio.js';
|
|
9
|
+
import { buildLocalityChart, } from './core-usage-locality.js';
|
|
10
|
+
import { detectorInfoByType, } from './detector-docs.js';
|
|
11
|
+
import { computeEfficiencyModel } from './efficiency-model.js';
|
|
12
|
+
import { attributeEtlPhases } from './etl-phases.js';
|
|
13
|
+
import { IMPACT_BAND_ORDER, worstImpactBand } from './format-utils.js';
|
|
14
|
+
import {
|
|
15
|
+
estimateProvenance, impactEstimateCompact, impactEstimateFigure, impactFigure, savingsMeaning,
|
|
16
|
+
} from './impact-format.js';
|
|
17
|
+
import { isEligible, rankedRollup, } from './recommendation-rollup.js';
|
|
18
|
+
import { quotesReasonOf } from './run-outcome.js';
|
|
19
|
+
import { computeRunShape, } from './run-shape.js';
|
|
20
|
+
import {
|
|
21
|
+
buildNextSteps, buildRunVerdict, FINDING_DISPLAY_ORDER, locationKey, quotedReasonText, rankBySavings, stepCopyText,
|
|
22
|
+
} from './run-verdict.js';
|
|
23
|
+
import { recommendationText } from './finding-names.js';
|
|
24
|
+
import { getScorecardEstimates } from './scorecard-estimates.js';
|
|
25
|
+
import { checkConcurrentJobGroups } from './job-groups.js';
|
|
26
|
+
import { computeWallClock } from './wall-clock.js';
|
|
27
|
+
import { computeWastedCoreHours, } from './wasted-core-hours.js';
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
/** A finding's savings figures, formatted with their units, so a widget shows the figure
|
|
31
|
+
* without formatting it. */
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
/** One verdict step, fully worded. `leadIndex` names the finding the step routes to. */
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
/** The Scorecard's three headline figures (the ones the report's runShape also states) and how
|
|
85
|
+
* each is graded. `wallClockMs` is null without a complete application timing interval. */
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
/** A stage's findings as the stage dialog lists them, in the verdict's step order. */
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
/** One row group of the Findings board: a finding type (split by impact kind, and by unit for
|
|
113
|
+
* resource figures), its members representative first, the band it sits under, and its trailing
|
|
114
|
+
* figure. */
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
/** The unfiltered Findings board: which findings it lists and its groups in fix-first order. */
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
/** Findings are referred to by index into the run's findings in catalog-then-config order
|
|
127
|
+
* (`[...catalog, ...configFindings]`), the order both producers pass them in: no copies, so a
|
|
128
|
+
* renderer resolves them to the very objects it already holds. */
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
export function findingSavings(finding ) {
|
|
156
|
+
return {
|
|
157
|
+
figure: impactFigure(finding),
|
|
158
|
+
meaning: savingsMeaning(finding),
|
|
159
|
+
board: impactEstimateFigure(finding.impactEstimate)?.text ?? null,
|
|
160
|
+
compact: impactEstimateCompact(finding.impactEstimate),
|
|
161
|
+
provenance: estimateProvenance(finding),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// The Scorecard's severity rule: a tile is flagged only when the figure is bad enough to act on.
|
|
166
|
+
function efficiencyFlag(pct ) {
|
|
167
|
+
if (pct == null) return null;
|
|
168
|
+
return pct < 75 ? 'critical' : pct < 90 ? 'warning' : null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function unusedCoreTimeFlag(pct ) {
|
|
172
|
+
if (pct == null) return null;
|
|
173
|
+
return pct >= 70 ? 'critical' : pct >= 40 ? 'warning' : null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function interpretRunShape(appModel ) {
|
|
177
|
+
const { wallClockMs, efficiencyPct, unusedCoreTimePct } = computeRunShape(appModel);
|
|
178
|
+
const estimates = getScorecardEstimates(appModel);
|
|
179
|
+
return {
|
|
180
|
+
wallClockMs,
|
|
181
|
+
efficiencyPct,
|
|
182
|
+
unusedCoreTimePct,
|
|
183
|
+
efficiencyFlag: efficiencyFlag(efficiencyPct),
|
|
184
|
+
unusedCoreTimeFlag: unusedCoreTimeFlag(unusedCoreTimePct),
|
|
185
|
+
unusedCoreTimeUnavailableReason: estimates.wastage.unavailableReason,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function interpretEfficiency(appModel ) {
|
|
190
|
+
if (!appModel.runAggregates) return null;
|
|
191
|
+
return computeEfficiencyModel({
|
|
192
|
+
app: appModel.app,
|
|
193
|
+
stages: appModel.stages,
|
|
194
|
+
executorsAdded: appModel.executors.added,
|
|
195
|
+
runAggregates: appModel.runAggregates,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function interpretCoverage(appModel , allFindings ) {
|
|
200
|
+
const noFinishedStages = !hasFinishedStage(appModel.stages);
|
|
201
|
+
const { notRunReason } = checkCoverage(appModel.stages, allFindings);
|
|
202
|
+
const notRunReasons = {};
|
|
203
|
+
for (const type of FINDING_DISPLAY_ORDER) {
|
|
204
|
+
const reason = notRunReason(type);
|
|
205
|
+
if (reason != null) notRunReasons[type] = reason;
|
|
206
|
+
}
|
|
207
|
+
return { noFinishedStages, notRunReasons, gaps: verdictGaps(allFindings, noFinishedStages) };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// The stage dialog's grouping: the verdict's location rule (a sql-scope finding touching only
|
|
211
|
+
// this stage counts), ordered by the stage's own verdict step, then by worst band.
|
|
212
|
+
function interpretStages(
|
|
213
|
+
catalog , indexOf , failedJobStageIds ,
|
|
214
|
+
) {
|
|
215
|
+
const byStage = new Map ();
|
|
216
|
+
for (const finding of catalog) {
|
|
217
|
+
const { stageId } = locationKey(finding);
|
|
218
|
+
if (stageId == null) continue;
|
|
219
|
+
const list = byStage.get(stageId) ?? [];
|
|
220
|
+
list.push(finding);
|
|
221
|
+
byStage.set(stageId, list);
|
|
222
|
+
}
|
|
223
|
+
const stages = {};
|
|
224
|
+
for (const [stageId, findings] of byStage) {
|
|
225
|
+
const [step] = buildNextSteps(findings, failedJobStageIds ? { failedJobStageIds } : {});
|
|
226
|
+
const stepTypes = step ? [step.lead.type, ...step.related.map((f) => f.type)] : [];
|
|
227
|
+
const worstBand = (type ) => IMPACT_BAND_ORDER[worstImpactBand(findings.filter((f) => f.type === type)) ];
|
|
228
|
+
const otherTypes = [...new Set(findings.map((f) => f.type))]
|
|
229
|
+
.filter((type) => !stepTypes.includes(type))
|
|
230
|
+
.sort((a, b) => worstBand(a) - worstBand(b));
|
|
231
|
+
stages[String(stageId)] = { findingIndexes: findings.map((f) => indexOf.get(f) ), typeOrder: [...stepTypes, ...otherTypes] };
|
|
232
|
+
}
|
|
233
|
+
return stages;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const DISPLAY_TYPES = new Set(FINDING_DISPLAY_ORDER);
|
|
237
|
+
|
|
238
|
+
/** Every conclusion the dashboard shows about a run whose detectors already ran. */
|
|
239
|
+
export function interpretRun(appModel , catalog , configFindings ) {
|
|
240
|
+
const allFindings = [...catalog, ...configFindings];
|
|
241
|
+
const indexOf = new Map(allFindings.map((finding, index) => [finding, index]));
|
|
242
|
+
const verdict = buildRunVerdict(appModel, allFindings);
|
|
243
|
+
const { outcome } = verdict;
|
|
244
|
+
const failed = outcome.failedJobs > 0;
|
|
245
|
+
// A board row needs a widget to route to, and every display type has one.
|
|
246
|
+
const eligible = allFindings.filter((finding) => isEligible(finding) && DISPLAY_TYPES.has(finding.type));
|
|
247
|
+
const steps = verdict.shown.map((step) => {
|
|
248
|
+
const quoted = quotesReasonOf(step.lead, outcome) && outcome.reason ? quotedReasonText(outcome.reason) : null;
|
|
249
|
+
return {
|
|
250
|
+
key: step.key,
|
|
251
|
+
leadIndex: indexOf.get(step.lead) ,
|
|
252
|
+
stageId: step.stageId,
|
|
253
|
+
relatedTypes: step.related.map((f) => f.type),
|
|
254
|
+
recommendation: quoted?.shown ?? recommendationText(step.lead),
|
|
255
|
+
copyText: stepCopyText(step.lead, quoted?.copied ?? recommendationText(step.lead)),
|
|
256
|
+
};
|
|
257
|
+
});
|
|
258
|
+
return {
|
|
259
|
+
savings: allFindings.map(findingSavings),
|
|
260
|
+
verdict: {
|
|
261
|
+
title: verdict.title,
|
|
262
|
+
summary: verdict.summary,
|
|
263
|
+
failed,
|
|
264
|
+
clean: verdict.facts.clean,
|
|
265
|
+
failureReason: outcome.reason,
|
|
266
|
+
steps,
|
|
267
|
+
remaining: verdict.remaining,
|
|
268
|
+
copyText: verdict.copyText,
|
|
269
|
+
},
|
|
270
|
+
coverage: interpretCoverage(appModel, allFindings),
|
|
271
|
+
runShape: interpretRunShape(appModel),
|
|
272
|
+
wallClock: computeWallClock(appModel.app, appModel.stages),
|
|
273
|
+
wastedCoreHours: computeWastedCoreHours(appModel.app, appModel.executors.added, appModel.runAggregates),
|
|
274
|
+
efficiency: interpretEfficiency(appModel),
|
|
275
|
+
wallClockReliable: checkConcurrentJobGroups(appModel.jobs).wallClockReliable,
|
|
276
|
+
etlPhases: attributeEtlPhases(appModel.stages),
|
|
277
|
+
coreLocality: {
|
|
278
|
+
chart: buildLocalityChart([...appModel.stages.values()], appModel.app),
|
|
279
|
+
ratio: computeCoreLocalityRatio([...appModel.stages.values()], { topN: Infinity }),
|
|
280
|
+
},
|
|
281
|
+
detectors: detectorInfoByType(),
|
|
282
|
+
savingsRank: rankBySavings(allFindings).map((finding) => indexOf.get(finding) ),
|
|
283
|
+
stages: interpretStages(catalog, indexOf, failed ? outcome.failedJobStageIds : null),
|
|
284
|
+
rollup: {
|
|
285
|
+
eligibleIndexes: eligible.map((finding) => indexOf.get(finding) ),
|
|
286
|
+
groups: rankedRollup(eligible, appModel.stages)
|
|
287
|
+
.map(({ members, ...group }) => ({ ...group, memberIndexes: members.map((finding) => indexOf.get(finding) ) })),
|
|
288
|
+
},
|
|
289
|
+
};
|
|
290
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
/** Finding types that mean work did not finish: a stage attempt that failed
|
|
4
|
+
* outright, and jobs that ended without succeeding. Failed tasks that a retry
|
|
5
|
+
* recovered (`failures`) are not in this set: the run still completed. */
|
|
6
|
+
export const FAILURE_TYPES = new Set(['stageFailed', 'jobFailureRate']);
|
|
7
|
+
|
|
8
|
+
/** Longest failure reason the verdict quotes; Spark reasons can carry a whole
|
|
9
|
+
* stack trace, and the first line already names the cause. */
|
|
10
|
+
export const FAILURE_REASON_MAX_CHARS = 240;
|
|
11
|
+
|
|
12
|
+
/** How the run ended, as far as its jobs say. `failedJobs` and `totalJobs`
|
|
13
|
+
* count only jobs with an end record, so a job still running when an
|
|
14
|
+
* incomplete log stops is neither. */
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
function firstLine(text ) {
|
|
29
|
+
const line = text.split('\n')[0].trim();
|
|
30
|
+
if (line.length === 0) return null;
|
|
31
|
+
return line.length > FAILURE_REASON_MAX_CHARS ? `${line.slice(0, FAILURE_REASON_MAX_CHARS - 3).trimEnd()}...` : line;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function exceptionText(exception ) {
|
|
35
|
+
if (typeof exception === 'string') return exception;
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Summarizes the run's job results. The reason prefers a failed stage that
|
|
40
|
+
* belongs to a failed job (the stage is the nearer cause), then any failed
|
|
41
|
+
* stage, then the first failed job's exception message. */
|
|
42
|
+
export function summarizeRunOutcome(jobs , findings ) {
|
|
43
|
+
const ended = [...jobs.values()].filter((job) => job.result != null);
|
|
44
|
+
const failed = ended.filter((job) => job.succeeded === false).sort((a, b) => a.id - b.id);
|
|
45
|
+
const failedStageIds = new Set(failed.flatMap((job) => job.stageIds));
|
|
46
|
+
const stageReasons = findings
|
|
47
|
+
.filter((finding) => finding.type === 'stageFailed')
|
|
48
|
+
.sort((a, b) => Number(failedStageIds.has(b.stageId )) - Number(failedStageIds.has(a.stageId )));
|
|
49
|
+
const stageSource = stageReasons[0];
|
|
50
|
+
const rawReason =
|
|
51
|
+
stageSource?.valueText
|
|
52
|
+
?? failed.map((job) => exceptionText(job.exception)).find((text) => text != null)
|
|
53
|
+
?? null;
|
|
54
|
+
return {
|
|
55
|
+
failedJobs: failed.length,
|
|
56
|
+
totalJobs: ended.length,
|
|
57
|
+
failedJobStageIds: failedStageIds,
|
|
58
|
+
reason: failed.length > 0 && rawReason != null ? firstLine(rawReason) : null,
|
|
59
|
+
reasonStageId: stageSource?.stageId ?? null,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Whether the quoted reason is this finding's own: the stage failure whose
|
|
64
|
+
* first line it is, or the job-failure finding when exactly one job failed
|
|
65
|
+
* and the reason belongs to that job. Any other failure has a cause the
|
|
66
|
+
* verdict does not show. */
|
|
67
|
+
export function quotesReasonOf(finding , outcome ) {
|
|
68
|
+
if (outcome.reason == null) return false;
|
|
69
|
+
if (finding.type === 'stageFailed') return firstLine(finding.valueText) === outcome.reason;
|
|
70
|
+
if (finding.type === 'jobFailureRate') {
|
|
71
|
+
return outcome.failedJobs === 1 && (outcome.reasonStageId == null || outcome.failedJobStageIds.has(outcome.reasonStageId));
|
|
72
|
+
}
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// How an encoded HTML-export payload reaches the export app: a window global
|
|
2
|
+
// that data.js (or the inlined script) assigns and main-export.tsx reads.
|
|
3
|
+
// Dependency-free, so the export bundle can read the global without pulling in
|
|
4
|
+
// the producer side (html-export.ts and the analysis it runs).
|
|
5
|
+
|
|
6
|
+
/** The window global the payload statement assigns and the export app reads. */
|
|
7
|
+
export const RUN_PAYLOAD_GLOBAL = '__SPARKFORENSICS_RUN_GZ__';
|
|
8
|
+
|
|
9
|
+
/** The one JavaScript statement that hands an encoded payload to the export
|
|
10
|
+
* app. base64's alphabet (A-Za-z0-9+/=) can't contain "<" or a quote, so log
|
|
11
|
+
* free text can't inject a "</script>" break-out or end the string literal:
|
|
12
|
+
* the statement is safe to place inside an inline <script> with no escaping.
|
|
13
|
+
* That only holds while `base64` really is base64; keep any new encoding
|
|
14
|
+
* inside that alphabet or escape it here. */
|
|
15
|
+
export function runPayloadScript(base64 ) {
|
|
16
|
+
return `window.${RUN_PAYLOAD_GLOBAL} = "${base64}";`;
|
|
17
|
+
}
|