@hashgraph/solo
Version:
An opinionated CLI tool to deploy and manage private Hedera Networks.
670 lines • 33.1 kB
JavaScript
// SPDX-License-Identifier: Apache-2.0
import AdmZip from 'adm-zip';
import fs from 'node:fs';
import path from 'node:path';
import chalk from 'chalk';
import * as constants from '../../core/constants.js';
import { PathEx } from '../../business/utils/path-ex.js';
const { green, yellow } = chalk;
/**
* DiagnosticsAnalyzer scans a previously-collected diagnostics output directory
* (produced by `deployment diagnostics logs`) and identifies common failure
* signatures without requiring a live cluster connection.
*
* ## Input sources
*
* ### 1. Solo CLI log (`solo.log`)
* The Solo CLI's own Pino log file (`~/.solo/logs/solo.log` by default, or
* `solo.log` found recursively under `customOutputDirectory`). Lines
* matching `] ERROR:` are captured as `app-error` findings. ANSI escape
* codes and `[traceId="..."]` suffixes are stripped before matching.
*
* ### 2. Pod describe files (`*.describe.txt`)
* Written by `downloadHieroComponentLogs()` for every pod across all clusters.
* These are the output of `kubectl describe pod <name> -n <namespace>` and
* contain the pod's status, container states, events, and resource usage.
*
* Detectable errors:
*
* | Category | Detected keywords / conditions |
* |-----------------|----------------------------------------------------------------------------------------|
* | `image-pull` | `ErrImagePull`, `ImagePullBackOff`, `Back-off pulling image`, |
* | | `failed to pull and unpack image`, `unexpected EOF` (truncated layer), |
* | | `toomanyrequests`, `rate limit exceeded`, `429 Too Many Requests` |
* | `oom` | `OOMKilled`, `out of memory`, `reason: OOMKilled` |
* | `pod-readiness` | Pod `Status` field is not `Running`, or `Ready: False` is present in container status; |
* | | supporting `Reason:` / `Message:` lines are captured as evidence |
*
* ### 2. Consensus node log archives (`*-log-config.zip`)
* Written by `getNodeLogsAndConfigs()` under `~/.solo/logs/<namespace>/`.
* Each zip contains the node's log and config snapshot. Only two log files
* inside the archive are inspected:
*
* - `output/swirlds.log` — Hashgraph platform log
* - `output/hgcaa.log` — Hedera application log
*
* Detectable errors:
*
* | Category | Detected keywords / conditions |
* |--------------------|-------------------------------------------------------------------------------------|
* | `consensus-active` | `swirlds.log` never contains the word `ACTIVE` — the node stalled during |
* | | startup (e.g. stuck in `STARTING_UP`, `OBSERVING`, or `REPLAYING_EVENTS`); |
* | | status-transition lines are captured as evidence |
* | `log-exception` | Any line in `swirlds.log` or `hgcaa.log` matching `Exception`, `Error`, |
* | | or `Caused by:` — the first matching stack-trace block (up to 14 lines) is |
* | | captured as evidence |
*
* ## Output
* All findings are written to `diagnostics-analysis.txt` inside the input
* directory. Up to 10 findings are also printed to the terminal in severity
* order. Duplicate findings (same category + title + source) are suppressed.
*/
export class DiagnosticsAnalyzer {
logger;
static CONSENSUS_LOG_DEFINITIONS = [
{ entrySuffix: 'output/swirlds.log', displayName: 'swirlds.log', checkConsensusActive: true },
{ entrySuffix: 'output/hgcaa.log', displayName: 'hgcaa.log', checkConsensusActive: false },
];
constructor(logger) {
this.logger = logger;
}
/**
* Run the full analysis against `customOutputDirectory` (or the default
* `~/.solo/logs/hiero-components-logs` when empty).
*
* Consensus node zip archives are looked up under
* `~/.solo/logs/<namespaceName>/` when `namespaceName` is provided, or
* directly under `~/.solo/logs/` otherwise.
*/
analyze(customOutputDirectory, namespaceName) {
const hieroOutputDirectory = customOutputDirectory
? path.resolve(customOutputDirectory)
: PathEx.join(constants.SOLO_LOGS_DIR, 'hiero-components-logs');
const findings = [];
this.logger.showUser(`Scanning directory: ${hieroOutputDirectory}`);
if (fs.existsSync(hieroOutputDirectory)) {
this.analyzeDescribeFiles(hieroOutputDirectory, findings);
}
else {
this.logger.showUser(yellow(` Pod describe directory not found, skipping: ${hieroOutputDirectory}`));
}
let consensusArchiveDirectory = constants.SOLO_LOGS_DIR;
if (customOutputDirectory) {
consensusArchiveDirectory = path.resolve(customOutputDirectory);
}
else if (namespaceName) {
consensusArchiveDirectory = PathEx.join(constants.SOLO_LOGS_DIR, namespaceName);
}
if (fs.existsSync(consensusArchiveDirectory)) {
this.analyzeConsensusNodeArchives(consensusArchiveDirectory, findings);
}
else {
this.logger.showUser(yellow(` Consensus archive directory not found, skipping: ${consensusArchiveDirectory}`));
}
if (fs.existsSync(hieroOutputDirectory)) {
this.analyzePodLogFiles(hieroOutputDirectory, findings);
}
if (fs.existsSync(hieroOutputDirectory)) {
this.analyzeSoloLogFiles(hieroOutputDirectory, customOutputDirectory, findings);
}
else {
this.logger.showUser(yellow(` Diagnostics output directory not found, skipping: ${hieroOutputDirectory}`));
}
if (!fs.existsSync(hieroOutputDirectory)) {
fs.mkdirSync(hieroOutputDirectory, { recursive: true });
}
const reportPath = PathEx.join(hieroOutputDirectory, 'diagnostics-analysis.txt');
this.logger.showUser(`Writing report to: ${reportPath}`);
const reportText = this.renderDiagnosticsFindings(findings);
fs.writeFileSync(reportPath, reportText, 'utf8');
if (findings.length > 0) {
this.logger.showUser(yellow(`Detected ${findings.length} potential issue(s) from diagnostics logs. Summary written to ${reportPath}`));
for (const [index, finding] of findings.slice(0, 10).entries()) {
this.logger.showUser(`${index + 1}. ${finding.title} [${finding.source}]`);
if (finding.evidence.length > 0) {
const maxEvidenceLines = finding.category === 'log-exception' ? 8 : 4;
for (const evidenceLine of finding.evidence.slice(0, maxEvidenceLines)) {
this.logger.showUser(` - ${evidenceLine}`);
}
if (finding.evidence.length > maxEvidenceLines) {
this.logger.showUser(` ... and ${finding.evidence.length - maxEvidenceLines} more evidence line(s) in diagnostics-analysis.txt`);
}
}
}
if (findings.length > 10) {
this.logger.showUser(`... and ${findings.length - 10} more. See diagnostics-analysis.txt for details.`);
}
}
else {
this.logger.showUser(green(`No common failure signatures detected. Report: ${reportPath}`));
}
}
/**
* Recursively scans `rootDirectory` for `*.describe.txt` files (one per pod)
* and checks each for image-pull failures, OOM kills, and pod-readiness
* problems.
*
* Detected errors:
* - `image-pull` ErrImagePull / ImagePullBackOff / rate-limit / unexpected EOF
* - `oom` OOMKilled / out of memory
* - `pod-readiness` Status != Running OR Ready: False
*/
analyzeDescribeFiles(rootDirectory, findings) {
const describeFiles = this.collectFilesRecursively(rootDirectory, (filePath) => filePath.endsWith('.describe.txt'));
// Matches any image-pull error surfaced in `kubectl describe pod` output.
// Covers:
// - ErrImagePull / ImagePullBackOff (standard Kubernetes pull errors)
// - "Back-off pulling image" (CRI back-off message in Events)
// - "failed to pull and unpack image" (containerd error)
// - "unexpected EOF" (truncated layer download)
// - toomanyrequests / rate limit exceeded / 429 Too Many Requests
// (Docker Hub and other registries throttle anonymous pulls)
const imagePullPattern = /ErrImagePull|ImagePullBackOff|Back-off pulling image|failed to pull and unpack image|unexpected EOF|toomanyrequests|rate limit exceeded|429 Too Many Requests/i;
// Matches out-of-memory kills.
// "OOMKilled" appears in the container's LastTerminationState and in Events.
// "reason: OOMKilled" is the structured field in the container status JSON.
const oomPattern = /OOMKilled|out of memory|reason:\s*OOMKilled/i;
this.logger.showUser(` Found ${describeFiles.length} pod describe file(s)`);
for (const describeFile of describeFiles) {
const relatedPath = path.relative(rootDirectory, describeFile);
this.logger.showUser(` Reading: ${relatedPath}`);
let content;
try {
content = fs.readFileSync(describeFile, 'utf8');
}
catch (error) {
this.logger.showUser(yellow(` Unable to read describe file ${relatedPath}: ${error.message}`));
continue;
}
const podName = path.basename(describeFile, '.describe.txt');
const source = path.relative(rootDirectory, describeFile);
if (imagePullPattern.test(content)) {
this.addDiagnosticsFinding(findings, {
category: 'image-pull',
title: `Image pull failure detected for pod ${podName}`,
source,
evidence: this.extractMatchSnippets(content, imagePullPattern, 8),
});
}
if (oomPattern.test(content)) {
this.addDiagnosticsFinding(findings, {
category: 'oom',
title: `OOM-related failure detected for pod ${podName}`,
source,
evidence: this.extractMatchSnippets(content, oomPattern, 6),
});
}
// A pod is unhealthy if its top-level status is anything other than
// "Running" or if any container is not ready.
//
// Two file formats are possible depending on how the describe file was
// collected:
// - Text format (kubectl describe pod): "Status: Pending"
// "Ready: False"
// - YAML format (kubectl get pod -o yaml): "phase: Pending"
// "ready: false"
//
// Both are matched so the check is format-agnostic.
// Reason: / Message: / reason: / message: lines (case-insensitive) are
// captured for additional context.
const statusMatch = content.match(/^\s*(?:Status|phase):\s+([^\n]+)/m);
const status = statusMatch?.[1]?.trim().replaceAll(/^"|"$/g, '') ?? '';
const readyFalse = /^\s*[Rr]eady:\s+[Ff]alse\b/m.test(content);
if ((status && status !== constants.POD_PHASE_RUNNING) || readyFalse) {
const evidence = [];
if (status) {
evidence.push(`Status: ${status}`);
}
if (readyFalse) {
evidence.push('Ready: False');
}
evidence.push(...this.extractMatchSnippetsJoiningContinuations(content, /^\s*(Reason|Message):\s+/i, 8));
this.addDiagnosticsFinding(findings, {
category: 'pod-readiness',
title: `Pod not ready/running: ${podName}`,
source,
evidence,
});
}
}
}
/**
* Recursively scans `rootDirectory` for `*.log` pod log files and checks each
* for application-level ERROR lines (category: `app-error`).
*
* These are the raw container logs downloaded by `downloadHieroComponentLogs()`
* alongside the `*.describe.txt` files. Each file is scanned for lines
* containing `ERROR` and the first matching block (up to 8 lines) is captured.
*/
analyzePodLogFiles(rootDirectory, findings) {
// Only scan logs for non-consensus components. Consensus node logs are
// handled separately via the *-log-config.zip archives (which include
// swirlds.log and hgcaa.log). Broad *.log would match those files too
// and produce duplicate / noisy findings.
const componentLogPattern = /[\\/](?:mirror|block|relay|explorer|solo-shared)[^/\\]*\.log$/i;
const logFiles = this.collectFilesRecursively(rootDirectory, (filePath) => componentLogPattern.test(filePath));
// Strip Docker/containerd timestamp prefix (e.g. "2026-04-06T03:24:32.470558065Z ") before matching.
const errorPattern = /\b(?:ERROR|FATAL)\b/i;
this.logger.showUser(` Found ${logFiles.length} pod log file(s)`);
for (const logFile of logFiles) {
const relativePath = path.relative(rootDirectory, logFile);
this.logger.showUser(` Reading: ${relativePath}`);
let content;
try {
content = fs.readFileSync(logFile, 'utf8');
}
catch (error) {
this.logger.showUser(yellow(` Unable to read log file ${relativePath}: ${error.message}`));
continue;
}
// Strip leading container-runtime timestamps so the pattern matches the application log line.
const strippedContent = content.replaceAll(/^\d{4}-\d{2}-\d{2}T[\d:.]+Z\s+/gm, '');
if (!errorPattern.test(strippedContent)) {
continue;
}
const podName = path.basename(logFile, '.log');
const evidence = this.extractMatchSnippets(strippedContent, errorPattern, 8);
this.addDiagnosticsFinding(findings, {
category: 'app-error',
title: `Application ERROR detected in pod log: ${podName}`,
source: relativePath,
evidence,
});
}
}
/**
* Searches for `solo.log` in `hieroOutputDirectory` (recursively) and, when
* no custom output directory was specified, also checks the standard
* `~/.solo/logs/solo.log` location. ERROR lines are extracted and reported
* as `app-error` findings.
*
*/
analyzeSoloLogFiles(hieroOutputDirectory, customOutputDirectory, findings) {
const soloLogFiles = this.collectFilesRecursively(hieroOutputDirectory, (filePath) => path.basename(filePath) === 'solo.log');
// When using the default output path, the solo.log lives one level up at
// ~/.solo/logs/solo.log — outside hieroOutputDirectory, so check it separately.
if (!customOutputDirectory) {
const defaultSoloLog = PathEx.join(constants.SOLO_LOGS_DIR, 'solo.log');
if (fs.existsSync(defaultSoloLog) && !soloLogFiles.includes(defaultSoloLog)) {
soloLogFiles.push(defaultSoloLog);
}
}
this.logger.showUser(` Found ${soloLogFiles.length} solo log file(s)`);
const errorPattern = /\]\s+ERROR:/;
// eslint-disable-next-line no-control-regex
const ansiPattern = new RegExp('\u001B\\[[0-9;]*m', 'g');
const traceIdPattern = /\s+\[traceId="[^"]*"\]/g;
for (const soloLogFile of soloLogFiles) {
const relativePath = path.relative(hieroOutputDirectory, soloLogFile);
const sourceLabel = relativePath || path.basename(soloLogFile);
this.logger.showUser(` Reading: ${sourceLabel}`);
let content;
try {
content = fs.readFileSync(soloLogFile, 'utf8');
}
catch (error) {
this.logger.showUser(yellow(` Unable to read solo log ${sourceLabel}: ${error.message}`));
continue;
}
const cleanedContent = content.replaceAll(ansiPattern, '').replaceAll(traceIdPattern, '');
if (!errorPattern.test(cleanedContent)) {
continue;
}
const evidence = this.extractSoloLogErrorBlocks(cleanedContent, 3, 14);
this.addDiagnosticsFinding(findings, {
category: 'app-error',
title: 'ERROR detected in solo.log',
source: sourceLabel,
evidence,
});
}
}
/**
* Recursively scans `archiveRootDirectory` for `*-log-config.zip` archives
* produced by `getNodeLogsAndConfigs()` and inspects two log files inside
* each archive:
*
* - `output/swirlds.log` — checked for absence of the `ACTIVE` platform
* status marker (category: `consensus-active`) and for exception blocks
* (category: `log-exception`).
* - `output/hgcaa.log` — checked for exception blocks only
* (category: `log-exception`).
*
* Only the first exception block per log file is captured (up to 14 lines)
* to keep the report readable.
*/
analyzeConsensusNodeArchives(archiveRootDirectory, findings) {
const archiveFiles = this.collectFilesRecursively(archiveRootDirectory, (filePath) => filePath.endsWith('-log-config.zip'));
this.logger.showUser(` Found ${archiveFiles.length} consensus log archive(s)`);
for (const archiveFile of archiveFiles) {
const archiveName = path.basename(archiveFile);
this.logger.showUser(` Unzipping: ${archiveName}`);
let archive;
try {
archive = new AdmZip(archiveFile, { readEntries: true });
}
catch (error) {
this.logger.showUser(yellow(` Unable to read archive ${archiveName}: ${error.message}`));
continue;
}
for (const entry of archive.getEntries()) {
const logDefinition = this.findConsensusLogDefinition(entry.entryName);
if (!logDefinition) {
continue;
}
this.analyzeConsensusLogEntry(archiveName, entry, logDefinition, findings);
}
}
}
findConsensusLogDefinition(entryName) {
return DiagnosticsAnalyzer.CONSENSUS_LOG_DEFINITIONS.find((logDefinition) => entryName.endsWith(logDefinition.entrySuffix));
}
analyzeConsensusLogEntry(archiveName, entry, logDefinition, findings) {
this.logger.showUser(` Reading entry: ${entry.entryName}`);
const source = `${archiveName}:${entry.entryName}`;
const content = entry.getData().toString('utf8');
if (logDefinition.checkConsensusActive) {
this.analyzeConsensusActiveStatus(content, source, findings);
}
this.analyzeExceptionBlocks(logDefinition.displayName, content, source, findings);
}
/**
* A healthy consensus node transitions through STARTING_UP → OBSERVING →
* REPLAYING_EVENTS → ACTIVE. If `ACTIVE` never appears in swirlds.log,
* the node likely stalled before becoming ready for transactions.
*/
analyzeConsensusActiveStatus(content, source, findings) {
if (/\bACTIVE\b/.test(content)) {
return;
}
const evidence = this.extractMatchSnippets(content, /PlatformStatus|status|STARTING_UP|OBSERVING|REPLAYING_EVENTS|FREEZING|ACTIVE/i, 8);
if (evidence.length === 0) {
evidence.push('No ACTIVE status marker found in swirlds.log');
}
this.addDiagnosticsFinding(findings, {
category: 'consensus-active',
title: 'Consensus node may not have reached ACTIVE status',
source,
evidence,
});
}
/**
* Captures the first exception/stack-trace block from a consensus log file.
*/
analyzeExceptionBlocks(logDisplayName, content, source, findings) {
const exceptionBlocks = this.extractExceptionBlocks(content, 1, 14);
if (exceptionBlocks.length === 0) {
return;
}
this.addDiagnosticsFinding(findings, {
category: 'log-exception',
title: `Exception detected in ${logDisplayName}`,
source,
evidence: exceptionBlocks[0].split('\n').filter((line) => line.trim().length > 0),
});
}
/**
* Adds `finding` to `findings` unless an identical entry (same category,
* title, and source) already exists. Evidence lines are deduplicated and
* capped at 14 entries to keep the report compact.
*/
addDiagnosticsFinding(findings, finding) {
const key = `${finding.category}|${finding.title}|${finding.source}`;
const existingKeys = new Set(findings.map((item) => `${item.category}|${item.title}|${item.source}`));
if (existingKeys.has(key)) {
return;
}
findings.push({
...finding,
evidence: [...new Set(finding.evidence)].filter((line) => line.trim().length > 0).slice(0, 14),
});
}
/**
* Walks `rootDirectory` recursively and returns all file paths for which
* `matcher` returns `true`.
*/
collectFilesRecursively(rootDirectory, matcher) {
const files = [];
const visit = (directory) => {
const entries = fs.readdirSync(directory, { withFileTypes: true });
for (const entry of entries) {
const entryPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
visit(entryPath);
continue;
}
if (entry.isFile() && matcher(entryPath)) {
files.push(entryPath);
}
}
};
visit(rootDirectory);
return files;
}
/**
* Extracts up to `maxBlocks` ERROR blocks from a solo.log file.
*
* Each block starts on a line matching `] ERROR:` and continues while
* subsequent lines are indented (part of the Pino `err:` object dump).
* A new log entry — any line starting with `[HH:MM:SS` — terminates the
* current block. Each block is capped at `maxLinesPerBlock` lines.
*
* Evidence lines are returned flat (one string per line) in
* `"line <N>: <content>"` format so they render consistently with other
* findings.
*/
extractSoloLogErrorBlocks(content, maxBlocks, maxLinesPerBlock) {
const lines = content.split(/\r?\n/);
const errorPattern = /\]\s+ERROR:/;
// New Pino log entries start with a bracketed timestamp, e.g. "[17:25:23.788]"
const newEntryPattern = /^\[\d{2}:\d{2}:\d{2}\.\d{3}]/;
const evidence = [];
let blocksCollected = 0;
for (let index = 0; index < lines.length && blocksCollected < maxBlocks; index++) {
if (!errorPattern.test(lines[index])) {
continue;
}
const blockLines = [`line ${index + 1}: ${lines[index].trim()}`];
let next = index + 1;
while (next < lines.length && blockLines.length < maxLinesPerBlock) {
const nextLine = lines[next];
// Stop at the next log entry or a blank line that precedes one
if (newEntryPattern.test(nextLine)) {
break;
}
if (nextLine.trim().length > 0) {
blockLines.push(`line ${next + 1}: ${nextLine.trim()}`);
}
next++;
}
evidence.push(...blockLines);
blocksCollected++;
index = next - 1;
}
return evidence;
}
/**
* Returns up to `maxMatches` lines from `content` that match `pattern`,
* formatted as `"line <N>: <trimmed line>"`.
*
* The global (`g`) flag is stripped before matching so the RegExp lastIndex
* does not interfere with repeated calls against the same pattern instance.
*/
extractMatchSnippets(content, pattern, maxMatches) {
const snippets = [];
const lines = content.split(/\r?\n/);
const normalizedFlags = pattern.flags.includes('g') ? pattern.flags.replaceAll('g', '') : pattern.flags;
const matcher = new RegExp(pattern.source, normalizedFlags);
for (const [index, line] of lines.entries()) {
if (matcher.test(line)) {
snippets.push(`line ${index + 1}: ${line.trim()}`);
if (snippets.length >= maxMatches) {
break;
}
}
}
return snippets;
}
/**
* Like {@link extractMatchSnippets} but joins indented continuation lines
* (YAML/kubectl-describe multi-line values) into a single evidence entry.
*
* When a matching key line is found, any immediately following lines whose
* leading whitespace is strictly greater than the key line's indentation are
* appended (space-separated) before the snippet is recorded. This collapses
* a multi-line `message:` value into one readable line instead of surfacing
* only the truncated first line.
*/
extractMatchSnippetsJoiningContinuations(content, pattern, maxMatches) {
const snippets = [];
const lines = content.split(/\r?\n/);
const normalizedFlags = pattern.flags.includes('g') ? pattern.flags.replaceAll('g', '') : pattern.flags;
const matcher = new RegExp(pattern.source, normalizedFlags);
for (let index = 0; index < lines.length && snippets.length < maxMatches; index++) {
const line = lines[index];
if (!matcher.test(line)) {
continue;
}
const keyIndent = (line.match(/^(\s*)/)?.[1] ?? '').length;
let joined = line.trim();
// Absorb continuation lines that are indented more than the key line.
let next = index + 1;
while (next < lines.length) {
const nextLine = lines[next];
if (nextLine.trim().length === 0) {
break;
}
const nextIndent = (nextLine.match(/^(\s*)/)?.[1] ?? '').length;
if (nextIndent <= keyIndent) {
break;
}
joined += ' ' + nextLine.trim();
next++;
}
snippets.push(`line ${index + 1}: ${joined}`);
}
return snippets;
}
/**
* Extracts up to `maxBlocks` exception/stack-trace blocks from `content`.
*
* A block starts on any line matching `Exception`, `Error`, or `Caused by:`
* and continues as long as subsequent lines are stack frames (`at …`),
* chained causes (`Caused by:`), or truncation markers (`… N more`).
* Each block is capped at `maxLinesPerBlock` lines.
*/
extractExceptionBlocks(content, maxBlocks, maxLinesPerBlock) {
const lines = content.split(/\r?\n/);
const blocks = [];
const timestampPattern = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}/;
const exceptionTypeLinePattern = /^\s*(?:[a-z_][A-Za-z0-9_$]*\.)*[A-Z][A-Za-z0-9_$]*(?:Exception|Error|Throwable)(?::|\b)/;
const startPattern = new RegExp(String.raw `${exceptionTypeLinePattern.source}|\b(?:Exception|Error)\b|^\s*Caused by:`);
// Matches only the severity levels that indicate a real error.
const errorLevelPattern = /\b(?:ERROR|FATAL|SEVERE)\b/i;
for (let index = 0; index < lines.length && blocks.length < maxBlocks; index++) {
if (!startPattern.test(lines[index])) {
continue;
}
// Look back up to 5 lines to find the nearest timestamped log line and
// determine its severity. Stack traces following a WARN/INFO/DEBUG line
// are expected (e.g. FileAlreadyExistsException on a WARN archive attempt)
// and must not be reported as findings.
let precedingIsError = false;
let precedingLogLine = '';
for (let scan = index - 1; scan >= 0 && scan >= index - 5; scan--) {
if (timestampPattern.test(lines[scan])) {
precedingLogLine = lines[scan];
precedingIsError = errorLevelPattern.test(lines[scan]);
break;
}
}
// If the nearest timestamped line exists and is not an error level, skip.
if (precedingLogLine && !precedingIsError) {
continue;
}
const blockLines = [lines[index]];
// In swirlds/hgcaa logs, the actual throwable class line can follow a
// timestamped ERROR marker line. Include that marker line as context.
if (index > 0 &&
blockLines.length < maxLinesPerBlock &&
(/\bERROR\s+EXCEPTION\b/i.test(lines[index - 1]) ||
(timestampPattern.test(lines[index - 1]) && errorLevelPattern.test(lines[index - 1]))) &&
!blockLines.includes(lines[index - 1])) {
blockLines.unshift(lines[index - 1]);
}
let next = index + 1;
while (next < lines.length && blockLines.length < maxLinesPerBlock) {
const line = lines[next];
if (line.trim().length === 0 || timestampPattern.test(line)) {
break;
}
if (/^\s+at\s+/.test(line) ||
/^\s*Caused by:/.test(line) ||
/^\s*Suppressed:/.test(line) ||
/^\s*\.\.\.\s+\d+\s+more/.test(line) ||
exceptionTypeLinePattern.test(line)) {
blockLines.push(line);
next++;
continue;
}
break;
}
blocks.push(blockLines.join('\n'));
index = next - 1;
}
return blocks;
}
/**
* Renders all findings into a human-readable plain-text report, sorted by
* severity (image-pull → oom → pod-readiness → consensus-active →
* log-exception). Returns the report as a string ready to be written to
* `diagnostics-analysis.txt`.
*/
renderDiagnosticsFindings(findings) {
const severityOrder = {
'image-pull': 1,
oom: 2,
'pod-readiness': 3,
'consensus-active': 4,
'log-exception': 5,
'app-error': 6,
};
const categoryLabel = {
'image-pull': 'Image Pull',
oom: 'Out Of Memory',
'pod-readiness': 'Pod Readiness',
'consensus-active': 'Consensus Active State',
'log-exception': 'Exception Stack',
'app-error': 'Application Error',
};
const lines = ['Solo Diagnostics Analysis Report', `Generated: ${new Date().toISOString()}`, ''];
if (findings.length === 0) {
lines.push('No common failure signatures were detected.');
return lines.join('\n');
}
const orderedFindings = [];
for (const finding of findings) {
let insertionIndex = orderedFindings.length;
for (const [index, existingFinding] of orderedFindings.entries()) {
if (severityOrder[finding.category] < severityOrder[existingFinding.category]) {
insertionIndex = index;
break;
}
}
orderedFindings.splice(insertionIndex, 0, finding);
}
lines.push(`Detected ${orderedFindings.length} potential issue(s):`, '');
for (const [index, finding] of orderedFindings.entries()) {
lines.push(`${index + 1}. [${categoryLabel[finding.category]}] ${finding.title}`, ` Source: ${finding.source}`);
if (finding.evidence.length > 0) {
lines.push(' Evidence:');
for (const evidenceLine of finding.evidence) {
lines.push(` - ${evidenceLine}`);
}
}
lines.push('');
}
return lines.join('\n');
}
}
//# sourceMappingURL=diagnostics-analyzer.js.map