-
Notifications
You must be signed in to change notification settings - Fork 322
feat: integrate log template mining into audit report and logs #24328
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
c671a75
feat: add pkg/agentdrain - Drain3-style log template mining package
Copilot 3dfcd4f
feat: integrate drain3 analysis into audit report and logs
Copilot 1b5ebf2
fix: remove incorrect build tags from non-test source files
Copilot 3b51401
feat: add --train flag to logs command for drain3 weight pretraining
Copilot d90b5f3
fix: address code review - bytes.TrimSpace, log pretty-print errors, …
Copilot b4ab0da
feat: integrate drain3 analysis into audit report subcommand
Copilot 9809766
fix: improve drain3 cross-run test assertions and doc comment
Copilot 2a9876a
fix: remove Drain3 name from user-facing report output
Copilot 93460d9
chore: add daily Drain3 weight training workflow; delete agentdrain d…
Copilot bdd7bf2
Merge branch 'main' into copilot/integrate-drain3-style-analysis
pelikhan File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,92 @@ | ||
| name: Train Log Pattern Weights | ||
|
|
||
| on: | ||
| schedule: | ||
| - cron: "0 4 * * *" # Daily at 04:00 UTC | ||
| workflow_dispatch: | ||
|
|
||
| permissions: {} | ||
|
|
||
| jobs: | ||
| train: | ||
| name: Download logs and train drain3 weights | ||
| runs-on: ubuntu-latest | ||
| timeout-minutes: 30 | ||
| permissions: | ||
| contents: write | ||
| pull-requests: write | ||
| steps: | ||
| - name: Checkout code | ||
| uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 | ||
|
|
||
| - name: Set up Go | ||
| uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c # v6 | ||
| with: | ||
| go-version-file: go.mod | ||
| cache: true | ||
|
|
||
| - name: Build gh-aw | ||
| run: make build | ||
|
|
||
| - name: Download run logs and train weights | ||
| env: | ||
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||
| run: | | ||
| mkdir -p /tmp/drain3-logs | ||
| ./gh-aw logs --train --output /tmp/drain3-logs --count 50 | ||
|
|
||
| - name: Copy trained weights to source tree | ||
| run: | | ||
| if [ -f /tmp/drain3-logs/drain3_weights.json ]; then | ||
| cp /tmp/drain3-logs/drain3_weights.json pkg/agentdrain/data/default_weights.json | ||
| echo "✅ Weights file updated successfully" | ||
| else | ||
| echo "⚠️ No drain3_weights.json produced – skipping PR creation" | ||
| exit 0 | ||
| fi | ||
|
|
||
| - name: Check for changes | ||
| id: check-changes | ||
| run: | | ||
| if git diff --quiet pkg/agentdrain/data/default_weights.json; then | ||
| echo "changes=false" >> "$GITHUB_OUTPUT" | ||
| echo "No changes to default_weights.json – weights are already up to date" | ||
| else | ||
| echo "changes=true" >> "$GITHUB_OUTPUT" | ||
| echo "Changes detected in default_weights.json" | ||
| fi | ||
|
|
||
| - name: Configure Git | ||
| if: steps.check-changes.outputs.changes == 'true' | ||
| run: | | ||
| git config --global user.name "github-actions[bot]" | ||
| git config --global user.email "github-actions[bot]@users.noreply.github.com" | ||
|
|
||
| - name: Create pull request with updated weights | ||
| if: steps.check-changes.outputs.changes == 'true' | ||
| env: | ||
| GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} | ||
| run: | | ||
| BRANCH_NAME="ci/train-drain3-weights-$(date +%Y%m%d)" | ||
|
|
||
| git checkout -b "$BRANCH_NAME" | ||
| git add pkg/agentdrain/data/default_weights.json | ||
| git commit -m "chore: update drain3 default weights from daily training run" | ||
|
|
||
| git push origin "$BRANCH_NAME" | ||
|
|
||
| gh pr create \ | ||
| --title "chore: update drain3 default log pattern weights" \ | ||
| --body "This pull request updates the default Drain3 log pattern weights (\`pkg/agentdrain/data/default_weights.json\`) by training on the most recent workflow run logs. | ||
|
|
||
| ## What changed | ||
| - Re-trained log template clusters from the latest run logs using \`gh aw logs --train\` | ||
| - Copied resulting \`drain3_weights.json\` to the embedded defaults path | ||
|
|
||
| ## How to verify | ||
| 1. Build the binary with \`make build\` | ||
| 2. Run \`gh aw audit\` or \`gh aw logs --train\` and confirm the anomaly analysis reflects the updated patterns | ||
|
|
||
| This PR was created automatically by the [train-drain3-weights](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) workflow." \ | ||
| --head "$BRANCH_NAME" \ | ||
| --base main |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| package agentdrain | ||
|
|
||
| import "strings" | ||
|
|
||
| // AnomalyDetector evaluates match results and produces AnomalyReports. | ||
| type AnomalyDetector struct { | ||
| threshold float64 | ||
| rareThreshold int | ||
| } | ||
|
|
||
| // NewAnomalyDetector creates an AnomalyDetector with the given thresholds. | ||
| func NewAnomalyDetector(simThreshold float64, rareClusterThreshold int) *AnomalyDetector { | ||
| return &AnomalyDetector{ | ||
| threshold: simThreshold, | ||
| rareThreshold: rareClusterThreshold, | ||
| } | ||
| } | ||
|
|
||
| // Analyze produces an AnomalyReport for a match result. | ||
| // | ||
| // - isNew indicates the line created a brand-new cluster. | ||
| // - cluster is the cluster that was matched or created. | ||
| func (d *AnomalyDetector) Analyze(result *MatchResult, isNew bool, cluster *Cluster) *AnomalyReport { | ||
| report := &AnomalyReport{ | ||
| IsNewTemplate: isNew, | ||
| NewClusterCreated: isNew, | ||
| } | ||
|
|
||
| if !isNew { | ||
| report.LowSimilarity = result.Similarity < d.threshold | ||
| } | ||
|
|
||
| if cluster != nil { | ||
| report.RareCluster = cluster.Size <= d.rareThreshold | ||
| } | ||
|
|
||
| // Weighted anomaly score. | ||
| var score float64 | ||
| if report.IsNewTemplate { | ||
| score += 1.0 | ||
| } | ||
| if report.LowSimilarity { | ||
| score += 0.7 | ||
| } | ||
| if report.RareCluster { | ||
| score += 0.3 | ||
| } | ||
| // Normalize to [0, 1]. | ||
| const maxScore = 2.0 | ||
| if score > maxScore { | ||
| score = maxScore | ||
| } | ||
| report.AnomalyScore = score / maxScore | ||
|
|
||
| report.Reason = buildReason(report) | ||
| return report | ||
| } | ||
|
|
||
| // buildReason constructs a human-readable summary of detected anomalies. | ||
| func buildReason(r *AnomalyReport) string { | ||
| var parts []string | ||
| if r.IsNewTemplate { | ||
| parts = append(parts, "new log template discovered") | ||
| } | ||
| if r.LowSimilarity { | ||
| parts = append(parts, "low similarity to known template") | ||
| } | ||
| if r.RareCluster { | ||
| parts = append(parts, "rare cluster (few observations)") | ||
| } | ||
| if len(parts) == 0 { | ||
| return "no anomaly detected" | ||
| } | ||
| return strings.Join(parts, "; ") | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,123 @@ | ||
| //go:build !integration | ||
|
|
||
| package agentdrain | ||
|
|
||
| import ( | ||
| "testing" | ||
| ) | ||
|
|
||
| func TestAnomalyDetection_NewTemplate(t *testing.T) { | ||
| d := NewAnomalyDetector(0.4, 2) | ||
| c := &Cluster{ID: 1, Template: []string{"stage=plan"}, Size: 1} | ||
| result := &MatchResult{ClusterID: 1, Similarity: 1.0} | ||
|
|
||
| report := d.Analyze(result, true, c) | ||
|
|
||
| if !report.IsNewTemplate { | ||
| t.Error("expected IsNewTemplate=true") | ||
| } | ||
| if !report.NewClusterCreated { | ||
| t.Error("expected NewClusterCreated=true") | ||
| } | ||
| if report.AnomalyScore <= 0 { | ||
| t.Errorf("expected positive anomaly score for new template, got %v", report.AnomalyScore) | ||
| } | ||
| } | ||
|
|
||
| func TestAnomalyDetection_LowSimilarity(t *testing.T) { | ||
| d := NewAnomalyDetector(0.4, 2) | ||
| // Size=5 means not rare; not new. | ||
| c := &Cluster{ID: 1, Template: []string{"a", "b", "c"}, Size: 5} | ||
| result := &MatchResult{ClusterID: 1, Similarity: 0.2} | ||
|
|
||
| report := d.Analyze(result, false, c) | ||
|
|
||
| if !report.LowSimilarity { | ||
| t.Error("expected LowSimilarity=true for similarity below threshold") | ||
| } | ||
| if report.IsNewTemplate { | ||
| t.Error("expected IsNewTemplate=false") | ||
| } | ||
| if report.AnomalyScore <= 0 { | ||
| t.Errorf("expected positive anomaly score, got %v", report.AnomalyScore) | ||
| } | ||
| } | ||
|
|
||
| func TestAnomalyDetection_RareCluster(t *testing.T) { | ||
| d := NewAnomalyDetector(0.4, 2) | ||
| c := &Cluster{ID: 1, Template: []string{"a"}, Size: 1} | ||
| result := &MatchResult{ClusterID: 1, Similarity: 0.9} | ||
|
|
||
| report := d.Analyze(result, false, c) | ||
|
|
||
| if !report.RareCluster { | ||
| t.Error("expected RareCluster=true for size=1 with rareThreshold=2") | ||
| } | ||
| if report.AnomalyScore <= 0 { | ||
| t.Errorf("expected positive anomaly score, got %v", report.AnomalyScore) | ||
| } | ||
| } | ||
|
|
||
| func TestAnomalyDetection_Normal(t *testing.T) { | ||
| d := NewAnomalyDetector(0.4, 2) | ||
| // High size, high similarity, not new. | ||
| c := &Cluster{ID: 1, Template: []string{"a", "b"}, Size: 100} | ||
| result := &MatchResult{ClusterID: 1, Similarity: 0.9} | ||
|
|
||
| report := d.Analyze(result, false, c) | ||
|
|
||
| if report.IsNewTemplate { | ||
| t.Error("expected IsNewTemplate=false") | ||
| } | ||
| if report.LowSimilarity { | ||
| t.Error("expected LowSimilarity=false") | ||
| } | ||
| if report.RareCluster { | ||
| t.Error("expected RareCluster=false") | ||
| } | ||
| if report.AnomalyScore > 0 { | ||
| t.Errorf("expected zero anomaly score for normal event, got %v", report.AnomalyScore) | ||
| } | ||
| if report.Reason != "no anomaly detected" { | ||
| t.Errorf("expected 'no anomaly detected', got %q", report.Reason) | ||
| } | ||
| } | ||
|
|
||
| func TestAnalyzeEvent(t *testing.T) { | ||
| cfg := DefaultConfig() | ||
| m, err := NewMiner(cfg) | ||
| if err != nil { | ||
| t.Fatalf("NewMiner: %v", err) | ||
| } | ||
|
|
||
| // First occurrence → new template. | ||
| evt := AgentEvent{ | ||
| Stage: "plan", | ||
| Fields: map[string]string{"action": "start", "model": "gpt-4"}, | ||
| } | ||
| result, report, err := m.AnalyzeEvent(evt) | ||
| if err != nil { | ||
| t.Fatalf("AnalyzeEvent: %v", err) | ||
| } | ||
| if result == nil { | ||
| t.Fatal("AnalyzeEvent: expected non-nil result") | ||
| } | ||
| if report == nil { | ||
| t.Fatal("AnalyzeEvent: expected non-nil report") | ||
| } | ||
| if !report.IsNewTemplate { | ||
| t.Error("first event should be a new template") | ||
| } | ||
|
|
||
| // Second occurrence of the same event → not new. | ||
| result2, report2, err := m.AnalyzeEvent(evt) | ||
| if err != nil { | ||
| t.Fatalf("AnalyzeEvent (second): %v", err) | ||
| } | ||
| if result2 == nil || report2 == nil { | ||
| t.Fatal("AnalyzeEvent (second): expected non-nil results") | ||
| } | ||
| if report2.IsNewTemplate { | ||
| t.Error("second identical event should not be a new template") | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,88 @@ | ||
| package agentdrain | ||
|
|
||
| // clusterStore manages the set of known log template clusters. | ||
| type clusterStore struct { | ||
| clusters map[int]*Cluster | ||
| nextID int | ||
| } | ||
|
|
||
| func newClusterStore() *clusterStore { | ||
| return &clusterStore{ | ||
| clusters: make(map[int]*Cluster), | ||
| nextID: 1, | ||
| } | ||
| } | ||
|
|
||
| // add creates a new Cluster for the given template and returns a pointer to it. | ||
| func (s *clusterStore) add(template []string, stage string) *Cluster { | ||
| id := s.nextID | ||
| s.nextID++ | ||
| tmpl := make([]string, len(template)) | ||
| copy(tmpl, template) | ||
| c := &Cluster{ | ||
| ID: id, | ||
| Template: tmpl, | ||
| Size: 1, | ||
| Stage: stage, | ||
| } | ||
| s.clusters[id] = c | ||
| return c | ||
| } | ||
|
|
||
| // get retrieves a cluster by ID. | ||
| func (s *clusterStore) get(id int) (*Cluster, bool) { | ||
| c, ok := s.clusters[id] | ||
| return c, ok | ||
| } | ||
|
|
||
| // all returns a snapshot of all clusters as a value slice. | ||
| func (s *clusterStore) all() []Cluster { | ||
| out := make([]Cluster, 0, len(s.clusters)) | ||
| for _, c := range s.clusters { | ||
| out = append(out, *c) | ||
| } | ||
| return out | ||
| } | ||
|
|
||
| // computeSimilarity returns the fraction of positions where tokens a and b | ||
| // match exactly, considering only positions that are not paramToken in a. | ||
| // Returns 0 when the slices have different lengths. | ||
| func computeSimilarity(a, b []string, paramToken string) float64 { | ||
| if len(a) != len(b) { | ||
| return 0 | ||
| } | ||
| nonParam := 0 | ||
| matches := 0 | ||
| for i, tok := range a { | ||
| if tok == paramToken { | ||
| continue | ||
| } | ||
| nonParam++ | ||
| if tok == b[i] { | ||
| matches++ | ||
| } | ||
| } | ||
| if nonParam == 0 { | ||
| // All positions are wildcards – treat as a perfect structural match. | ||
| return 1.0 | ||
| } | ||
| return float64(matches) / float64(nonParam) | ||
| } | ||
|
|
||
| // mergeTemplate produces a new template by replacing positions where the two | ||
| // token slices differ with paramToken. Positions where either token already is | ||
| // paramToken also become paramToken. | ||
| func mergeTemplate(existing, incoming []string, paramToken string) []string { | ||
| if len(existing) != len(incoming) { | ||
| return existing | ||
| } | ||
| merged := make([]string, len(existing)) | ||
| for i, tok := range existing { | ||
| if tok == paramToken || incoming[i] == paramToken || tok != incoming[i] { | ||
| merged[i] = paramToken | ||
| } else { | ||
| merged[i] = tok | ||
| } | ||
| } | ||
| return merged | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LowSimilarityis effectively unreachable in the current pipeline:Miner.matchonly returns results whenSimilarity >= SimThreshold, andAnalyzeEventconstructs the detector with the same threshold (m.cfg.SimThreshold). That meansresult.Similarity < d.thresholdcan never be true whenisNew == false, so the low-similarity anomaly path won't trigger. Consider returning the best match even when it is below the threshold (so you can report low similarity), or using a separate (higher) detector threshold than the match threshold.