Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion internal/ai/analyzer.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ func (a *DefaultAnalyzer) Analyze(ctx context.Context, req doctor.AnalysisReques
}

// Build the prompt.
userPrompt := BuildUserPrompt(req.Signals, req.Findings, req.History, a.privacy)
userPrompt := BuildUserPrompt(req.Signals, req.Findings, req.History, req.Timeline, a.privacy)

a.logger.Debug("sending to AI provider",
"provider", a.provider.Name(),
Expand Down
24 changes: 23 additions & 1 deletion internal/ai/prompt.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ Rules:
- Return ONLY valid JSON, no markdown or extra text`

// BuildUserPrompt serializes signals and findings into a token-efficient prompt.
func BuildUserPrompt(signals *collector.Signals, findings []doctor.Finding, history []*collector.Signals, privacy PrivacyMode) string {
func BuildUserPrompt(signals *collector.Signals, findings []doctor.Finding, history []*collector.Signals, timeline *doctor.Timeline, privacy PrivacyMode) string {
var b strings.Builder

// Host info.
Expand Down Expand Up @@ -89,6 +89,28 @@ func BuildUserPrompt(signals *collector.Signals, findings []doctor.Finding, hist
b.WriteString("\n")
}

// Timeline (if available).
if timeline != nil && len(timeline.Links) > 0 {
fmt.Fprintf(&b, "CAUSAL TIMELINE (ID: %s):\n", timeline.ID)
for _, l := range timeline.Links {
causeTitle := l.CauseRule
effectTitle := l.EffectRule
for _, ev := range timeline.Events {
if ev.FindingRule == l.CauseRule {
causeTitle = ev.Title
}
if ev.FindingRule == l.EffectRule {
effectTitle = ev.Title
}
}
fmt.Fprintf(&b, " - %s → %s (%dms, %.0f%% confidence)\n",
causeTitle, effectTitle, l.GapMs, l.Confidence*100)
}
fmt.Fprintf(&b, "\nINSTRUCTION FOR ANALYSIS:\n")
fmt.Fprintf(&b, "Reference this timeline by ID (%s) when explaining root causes.\n", timeline.ID)
fmt.Fprintf(&b, "Do not repeat raw finding names; reference the timeline chain instead.\n\n")
}

// Raw metrics (token-efficient compact format).
b.WriteString("RAW METRICS:\n")
writeSignalMetrics(&b, signals, privacy)
Expand Down
9 changes: 7 additions & 2 deletions internal/cli/doctor.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ func newDoctorCmd() *cobra.Command {
quiet bool
noBanner bool
onlyCritical bool
timeline bool
)

cmd := &cobra.Command{
Expand Down Expand Up @@ -85,6 +86,7 @@ Add --ai to enrich findings with AI-powered analysis (requires API key).`,
quiet: quiet,
noBanner: noBanner,
onlyCritical: onlyCritical,
timeline: timeline,
})
},
}
Expand All @@ -100,6 +102,7 @@ Add --ai to enrich findings with AI-powered analysis (requires API key).`,
flags.BoolVarP(&quiet, "quiet", "q", false, "only emit critical/warning findings (CI-friendly)")
flags.BoolVar(&noBanner, "no-banner", false, "suppress the ASCII banner block")
flags.BoolVar(&onlyCritical, "only-critical", false, "show only critical severity items")
flags.BoolVar(&timeline, "timeline", false, "render findings ordered by first-detection timestamp and linked by suspected causation")
//nolint:errcheck // RegisterFlagCompletionFunc only returns error on invalid flag name, which is static.
_ = cmd.RegisterFlagCompletionFunc("output", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
return []string{"pretty", "json"}, cobra.ShellCompDirectiveNoFileComp
Expand All @@ -117,6 +120,7 @@ type doctorOpts struct {
quiet bool
noBanner bool
onlyCritical bool
timeline bool
}

func runDoctor(ctx context.Context, opts doctorOpts) error {
Expand Down Expand Up @@ -158,8 +162,9 @@ func runDoctor(ctx context.Context, opts doctorOpts) error {
renderer = &doctor.JSONRenderer{Pretty: true}
default:
renderer = &doctor.PrettyRenderer{
NoColor: viper.GetBool("no_color") || os.Getenv("NO_COLOR") != "" || !isTerminal(),
NoBanner: opts.noBanner,
NoColor: viper.GetBool("no_color") || os.Getenv("NO_COLOR") != "" || !isTerminal(),
NoBanner: opts.noBanner,
ShowTimeline: opts.timeline,
}
}

Expand Down
19 changes: 17 additions & 2 deletions internal/doctor/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ type AnalysisRequest struct {
Signals *collector.Signals
Findings []Finding
History []*collector.Signals
Timeline *Timeline
}

// AnalysisResponse contains AI-generated insights.
Expand Down Expand Up @@ -88,15 +89,24 @@ type Engine struct {

// NewEngine creates a new diagnostic engine.
// Pass nil for analyzer to run without AI enrichment.
// Change the constant in NewEngine:
func NewEngine(thresholds config.DoctorThresholds, analyzer Analyzer, logger *slog.Logger) *Engine {
return &Engine{
thresholds: thresholds,
analyzer: analyzer,
logger: logger,
maxHistory: 10,
maxHistory: 120, // 10 min @ 5s intervals; ~1 MB max
}
}

// History returns a read-only snapshot of the signal history,
// oldest-first. Safe to call concurrently with Diagnose.
func (e *Engine) History() []*collector.Signals {
out := make([]*collector.Signals, len(e.history))
copy(out, e.history)
return out
}

// Diagnose runs the full diagnostic pipeline against collected signals.
func (e *Engine) Diagnose(ctx context.Context, signals *collector.Signals) (*Report, error) {
start := time.Now()
Expand All @@ -108,6 +118,9 @@ func (e *Engine) Diagnose(ctx context.Context, signals *collector.Signals) (*Rep
"duration_ms", time.Since(start).Milliseconds(),
)

// Build timeline.
timeline := BuildTimeline(findings, e.history, e.thresholds)

// Phase 2: Optional AI enrichment.
var analysis *AnalysisResponse
if e.analyzer != nil && hasActionableFindings(findings) {
Expand All @@ -117,6 +130,7 @@ func (e *Engine) Diagnose(ctx context.Context, signals *collector.Signals) (*Rep
Signals: signals,
Findings: findings,
History: e.history,
Timeline: timeline,
})
if err != nil {
// AI failure is non-fatal — log and continue with deterministic results.
Expand All @@ -137,7 +151,8 @@ func (e *Engine) Diagnose(ctx context.Context, signals *collector.Signals) (*Rep
Analysis: analysis,
// Carry the raw signals through so the JSON renderer can
// surface them for debugging — the pretty renderer ignores it.
Signals: signals,
Signals: signals,
Timeline: timeline,
}

// Track events collected.
Expand Down
8 changes: 8 additions & 0 deletions internal/doctor/finding.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,11 @@ type Finding struct {

// Process is the relevant process name, if applicable.
Process string

// FiredAt is the wall-clock time when the underlying metric first crossed
// its threshold in the history ring buffer. Zero value means "current
// snapshot only" (no history available yet).
FiredAt time.Time
}

// ETAString returns a human-readable ETA string, or empty if no ETA.
Expand Down Expand Up @@ -128,6 +133,9 @@ type Report struct {
// Findings are the ranked diagnostic results.
Findings []Finding

// Timeline is the causal sequence for this report.
Timeline *Timeline

// Stats tracks collection metadata.
EventsCollected uint64
ProgramsLoaded int
Expand Down
39 changes: 37 additions & 2 deletions internal/doctor/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@ type Renderer interface {
// PrettyRenderer outputs a human-readable incident report with ANSI colors,
// box-drawn finding cards, and bar-chart signal visualizations.
type PrettyRenderer struct {
NoColor bool
NoBanner bool
NoColor bool
NoBanner bool
ShowTimeline bool
}

const (
Expand Down Expand Up @@ -75,9 +76,15 @@ func (r *PrettyRenderer) Render(w io.Writer, report *Report) error {

r.renderDegradation(w, report, p)
r.renderTriage(w, report, p)

if r.ShowTimeline && report.Timeline != nil {
r.renderTimeline(w, report.Timeline, p)
}

for i := range report.Findings {
r.renderFinding(w, &report.Findings[i], p)
}

if analysis, ok := report.Analysis.(*AnalysisResponse); ok && analysis != nil {
r.renderAIAnalysis(w, analysis, p)
}
Expand Down Expand Up @@ -602,6 +609,7 @@ type jsonReport struct {
Summary reportSummary `json:"summary"`
Analysis *AnalysisResponse `json:"analysis,omitempty"`
Signals any `json:"signals,omitempty"`
Timeline *jsonTimeline `json:"timeline,omitempty"`
}

type jsonFinding struct {
Expand Down Expand Up @@ -653,6 +661,33 @@ func (r *JSONRenderer) Render(w io.Writer, report *Report) error {
jr.Signals = report.Signals
}

if report.Timeline != nil {
events := make([]jsonTimelineEvent, len(report.Timeline.Events))
for i, ev := range report.Timeline.Events {
events[i] = jsonTimelineEvent{
Rule: ev.FindingRule,
Signal: ev.Signal,
FiredAt: ev.FiredAt,
Title: ev.Title,
Evidence: ev.Evidence,
}
}
links := make([]jsonCausalLink, len(report.Timeline.Links))
for i, l := range report.Timeline.Links {
links[i] = jsonCausalLink{
Cause: l.CauseRule,
Effect: l.EffectRule,
Confidence: l.Confidence,
GapMs: l.GapMs,
}
}
jr.Timeline = &jsonTimeline{
ID: report.Timeline.ID,
Events: events,
Links: links,
}
}

for _, f := range report.Findings {
jf := jsonFinding{
Severity: f.Severity.String(),
Expand Down
Loading
Loading