UNPKG

@hashgraph/solo

Version:

An opinionated CLI tool to deploy and manage private Hedera Networks.

820 lines (735 loc) 33.7 kB
// 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'; import {type SoloLogger} from '../../core/logging/solo-logger.js'; const {green, yellow} = chalk; /** * Severity-ordered categories for diagnostics findings. * * Ordering (lowest value = highest severity in the report): * 1. image-pull — container image could not be pulled; pod will never start. * 2. oom — container was killed by the kernel due to memory exhaustion. * 3. pod-readiness — pod is not Running or its readiness probe is failing. * 4. consensus-active — consensus node did not reach ACTIVE platform status. * 5. log-exception — an exception/stack-trace was found in an application log. * 6. app-error — an ERROR line was found in a pod's raw container log. */ export type DiagnosticsFindingCategory = | 'image-pull' | 'oom' | 'pod-readiness' | 'consensus-active' | 'log-exception' | 'app-error'; /** A single detected problem with its supporting evidence lines. */ export type DiagnosticsFinding = { category: DiagnosticsFindingCategory; title: string; /** Relative path of the source file (or "archive:entry") that triggered this finding. */ source: string; /** Up to 14 verbatim lines from the source that match the failure pattern. */ evidence: string[]; }; type ConsensusLogDefinition = { entrySuffix: 'output/swirlds.log' | 'output/hgcaa.log'; displayName: 'swirlds.log' | 'hgcaa.log'; checkConsensusActive: boolean; }; /** * 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 { private static readonly CONSENSUS_LOG_DEFINITIONS: readonly ConsensusLogDefinition[] = [ {entrySuffix: 'output/swirlds.log', displayName: 'swirlds.log', checkConsensusActive: true}, {entrySuffix: 'output/hgcaa.log', displayName: 'hgcaa.log', checkConsensusActive: false}, ]; public constructor(private readonly logger: SoloLogger) {} /** * 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. */ public analyze(customOutputDirectory: string, namespaceName: string | undefined): void { const hieroOutputDirectory: string = customOutputDirectory ? path.resolve(customOutputDirectory) : PathEx.join(constants.SOLO_LOGS_DIR, 'hiero-components-logs'); const findings: DiagnosticsFinding[] = []; 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: string = 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: string = PathEx.join(hieroOutputDirectory, 'diagnostics-analysis.txt'); this.logger.showUser(`Writing report to: ${reportPath}`); const reportText: string = 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: number = 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 */ private analyzeDescribeFiles(rootDirectory: string, findings: DiagnosticsFinding[]): void { const describeFiles: string[] = this.collectFilesRecursively(rootDirectory, (filePath: string): boolean => 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: RegExp = /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: RegExp = /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: string = path.relative(rootDirectory, describeFile); this.logger.showUser(` Reading: ${relatedPath}`); let content: string; try { content = fs.readFileSync(describeFile, 'utf8'); } catch (error) { this.logger.showUser(yellow(` Unable to read describe file ${relatedPath}: ${(error as Error).message}`)); continue; } const podName: string = path.basename(describeFile, '.describe.txt'); const source: string = 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: RegExpMatchArray = content.match(/^\s*(?:Status|phase):\s+([^\n]+)/m); const status: string = statusMatch?.[1]?.trim().replaceAll(/^"|"$/g, '') ?? ''; const readyFalse: boolean = /^\s*[Rr]eady:\s+[Ff]alse\b/m.test(content); if ((status && status !== constants.POD_PHASE_RUNNING) || readyFalse) { const evidence: string[] = []; 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. */ private analyzePodLogFiles(rootDirectory: string, findings: DiagnosticsFinding[]): void { // 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: RegExp = /[\\/](?:mirror|block|relay|explorer|solo-shared)[^/\\]*\.log$/i; const logFiles: string[] = this.collectFilesRecursively(rootDirectory, (filePath: string): boolean => componentLogPattern.test(filePath), ); // Strip Docker/containerd timestamp prefix (e.g. "2026-04-06T03:24:32.470558065Z ") before matching. const errorPattern: RegExp = /\b(?:ERROR|FATAL)\b/i; this.logger.showUser(` Found ${logFiles.length} pod log file(s)`); for (const logFile of logFiles) { const relativePath: string = path.relative(rootDirectory, logFile); this.logger.showUser(` Reading: ${relativePath}`); let content: string; try { content = fs.readFileSync(logFile, 'utf8'); } catch (error) { this.logger.showUser(yellow(` Unable to read log file ${relativePath}: ${(error as Error).message}`)); continue; } // Strip leading container-runtime timestamps so the pattern matches the application log line. const strippedContent: string = content.replaceAll(/^\d{4}-\d{2}-\d{2}T[\d:.]+Z\s+/gm, ''); if (!errorPattern.test(strippedContent)) { continue; } const podName: string = path.basename(logFile, '.log'); const evidence: string[] = 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. * */ private analyzeSoloLogFiles( hieroOutputDirectory: string, customOutputDirectory: string, findings: DiagnosticsFinding[], ): void { const soloLogFiles: string[] = this.collectFilesRecursively( hieroOutputDirectory, (filePath: string): boolean => 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: string = 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: RegExp = /\]\s+ERROR:/; // eslint-disable-next-line no-control-regex const ansiPattern: RegExp = new RegExp('\u001B\\[[0-9;]*m', 'g'); const traceIdPattern: RegExp = /\s+\[traceId="[^"]*"\]/g; for (const soloLogFile of soloLogFiles) { const relativePath: string = path.relative(hieroOutputDirectory, soloLogFile); const sourceLabel: string = relativePath || path.basename(soloLogFile); this.logger.showUser(` Reading: ${sourceLabel}`); let content: string; try { content = fs.readFileSync(soloLogFile, 'utf8'); } catch (error) { this.logger.showUser(yellow(` Unable to read solo log ${sourceLabel}: ${(error as Error).message}`)); continue; } const cleanedContent: string = content.replaceAll(ansiPattern, '').replaceAll(traceIdPattern, ''); if (!errorPattern.test(cleanedContent)) { continue; } const evidence: string[] = 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. */ private analyzeConsensusNodeArchives(archiveRootDirectory: string, findings: DiagnosticsFinding[]): void { const archiveFiles: string[] = this.collectFilesRecursively(archiveRootDirectory, (filePath: string): boolean => filePath.endsWith('-log-config.zip'), ); this.logger.showUser(` Found ${archiveFiles.length} consensus log archive(s)`); for (const archiveFile of archiveFiles) { const archiveName: string = path.basename(archiveFile); this.logger.showUser(` Unzipping: ${archiveName}`); let archive: AdmZip; try { archive = new AdmZip(archiveFile, {readEntries: true}); } catch (error) { this.logger.showUser(yellow(` Unable to read archive ${archiveName}: ${(error as Error).message}`)); continue; } for (const entry of archive.getEntries()) { const logDefinition: ConsensusLogDefinition | undefined = this.findConsensusLogDefinition(entry.entryName); if (!logDefinition) { continue; } this.analyzeConsensusLogEntry(archiveName, entry, logDefinition, findings); } } } private findConsensusLogDefinition(entryName: string): ConsensusLogDefinition | undefined { return DiagnosticsAnalyzer.CONSENSUS_LOG_DEFINITIONS.find((logDefinition: ConsensusLogDefinition): boolean => entryName.endsWith(logDefinition.entrySuffix), ); } private analyzeConsensusLogEntry( archiveName: string, entry: AdmZip.IZipEntry, logDefinition: ConsensusLogDefinition, findings: DiagnosticsFinding[], ): void { this.logger.showUser(` Reading entry: ${entry.entryName}`); const source: string = `${archiveName}:${entry.entryName}`; const content: string = 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. */ private analyzeConsensusActiveStatus(content: string, source: string, findings: DiagnosticsFinding[]): void { if (/\bACTIVE\b/.test(content)) { return; } const evidence: string[] = 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. */ private analyzeExceptionBlocks( logDisplayName: ConsensusLogDefinition['displayName'], content: string, source: string, findings: DiagnosticsFinding[], ): void { const exceptionBlocks: string[] = 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: string): boolean => 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. */ private addDiagnosticsFinding(findings: DiagnosticsFinding[], finding: DiagnosticsFinding): void { const key: string = `${finding.category}|${finding.title}|${finding.source}`; const existingKeys: Set<string> = new Set( findings.map((item: DiagnosticsFinding): string => `${item.category}|${item.title}|${item.source}`), ); if (existingKeys.has(key)) { return; } findings.push({ ...finding, evidence: [...new Set(finding.evidence)].filter((line: string): boolean => line.trim().length > 0).slice(0, 14), }); } /** * Walks `rootDirectory` recursively and returns all file paths for which * `matcher` returns `true`. */ private collectFilesRecursively(rootDirectory: string, matcher: (filePath: string) => boolean): string[] { const files: string[] = []; const visit: (directory: string) => void = (directory: string): void => { const entries: fs.Dirent[] = fs.readdirSync(directory, {withFileTypes: true}); for (const entry of entries) { const entryPath: string = 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. */ private extractSoloLogErrorBlocks(content: string, maxBlocks: number, maxLinesPerBlock: number): string[] { const lines: string[] = content.split(/\r?\n/); const errorPattern: RegExp = /\]\s+ERROR:/; // New Pino log entries start with a bracketed timestamp, e.g. "[17:25:23.788]" const newEntryPattern: RegExp = /^\[\d{2}:\d{2}:\d{2}\.\d{3}]/; const evidence: string[] = []; let blocksCollected: number = 0; for (let index: number = 0; index < lines.length && blocksCollected < maxBlocks; index++) { if (!errorPattern.test(lines[index])) { continue; } const blockLines: string[] = [`line ${index + 1}: ${lines[index].trim()}`]; let next: number = index + 1; while (next < lines.length && blockLines.length < maxLinesPerBlock) { const nextLine: string = 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. */ private extractMatchSnippets(content: string, pattern: RegExp, maxMatches: number): string[] { const snippets: string[] = []; const lines: string[] = content.split(/\r?\n/); const normalizedFlags: string = pattern.flags.includes('g') ? pattern.flags.replaceAll('g', '') : pattern.flags; const matcher: RegExp = 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. */ private extractMatchSnippetsJoiningContinuations(content: string, pattern: RegExp, maxMatches: number): string[] { const snippets: string[] = []; const lines: string[] = content.split(/\r?\n/); const normalizedFlags: string = pattern.flags.includes('g') ? pattern.flags.replaceAll('g', '') : pattern.flags; const matcher: RegExp = new RegExp(pattern.source, normalizedFlags); for (let index: number = 0; index < lines.length && snippets.length < maxMatches; index++) { const line: string = lines[index]; if (!matcher.test(line)) { continue; } const keyIndent: number = (line.match(/^(\s*)/)?.[1] ?? '').length; let joined: string = line.trim(); // Absorb continuation lines that are indented more than the key line. let next: number = index + 1; while (next < lines.length) { const nextLine: string = lines[next]; if (nextLine.trim().length === 0) { break; } const nextIndent: number = (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. */ private extractExceptionBlocks(content: string, maxBlocks: number, maxLinesPerBlock: number): string[] { const lines: string[] = content.split(/\r?\n/); const blocks: string[] = []; const timestampPattern: RegExp = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}/; const exceptionTypeLinePattern: RegExp = /^\s*(?:[a-z_][A-Za-z0-9_$]*\.)*[A-Z][A-Za-z0-9_$]*(?:Exception|Error|Throwable)(?::|\b)/; const startPattern: RegExp = 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: RegExp = /\b(?:ERROR|FATAL|SEVERE)\b/i; for (let index: number = 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: boolean = false; let precedingLogLine: string = ''; for (let scan: number = 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: string[] = [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: number = index + 1; while (next < lines.length && blockLines.length < maxLinesPerBlock) { const line: string = 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`. */ private renderDiagnosticsFindings(findings: DiagnosticsFinding[]): string { const severityOrder: Record<DiagnosticsFindingCategory, number> = { 'image-pull': 1, oom: 2, 'pod-readiness': 3, 'consensus-active': 4, 'log-exception': 5, 'app-error': 6, }; const categoryLabel: Record<DiagnosticsFindingCategory, string> = { '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: string[] = ['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: DiagnosticsFinding[] = []; for (const finding of findings) { let insertionIndex: number = 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'); } }