Skip to content
Merged
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
92 changes: 92 additions & 0 deletions .github/workflows/train-drain3-weights.yml
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
75 changes: 75 additions & 0 deletions pkg/agentdrain/anomaly.go
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
}
Comment on lines +29 to +31
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LowSimilarity is effectively unreachable in the current pipeline: Miner.match only returns results when Similarity >= SimThreshold, and AnalyzeEvent constructs the detector with the same threshold (m.cfg.SimThreshold). That means result.Similarity < d.threshold can never be true when isNew == 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.

Copilot uses AI. Check for mistakes.

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, "; ")
}
123 changes: 123 additions & 0 deletions pkg/agentdrain/anomaly_test.go
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")
}
}
88 changes: 88 additions & 0 deletions pkg/agentdrain/cluster.go
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
}
Loading
Loading