Skip to content

Commit 3c31fbe

Browse files
committed
Implement pattern clustering with hash-based bucketing and signature-based grouping
1 parent 3206a94 commit 3c31fbe

8 files changed

Lines changed: 2022 additions & 0 deletions

File tree

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
// Unless explicitly stated otherwise all files in this repository are licensed
2+
// under the Apache License Version 2.0.
3+
// This product includes software developed at Datadog (https://www.datadoghq.com/).
4+
// Copyright 2016-present Datadog, Inc.
5+
6+
// Package clustering provides clustering functionality for grouping similar TokenLists,
7+
// extracting patterns wildcard, and managing pattern lifecycle through eviction policies.
8+
package clustering
9+
10+
import (
11+
"strings"
12+
"time"
13+
14+
"github.com/DataDog/datadog-agent/pkg/logs/patterns/clustering/merging"
15+
"github.com/DataDog/datadog-agent/pkg/logs/patterns/token"
16+
)
17+
18+
// Cluster represents a cluster with a group of TokenLists that have identical signatures.
19+
// A cluster may contain multiple patterns if token lists with the same signature cannot be merged since structural Fidelity is Valuable.
20+
// Examples:
21+
// "Status: OK" → HTTP response format
22+
// "Status; OK" → CSV-like format
23+
// "Status OK" → Plain text format
24+
// These are different log formats, even if semantically similar → we need to keep them separate.
25+
type Cluster struct {
26+
Signature token.Signature
27+
Patterns []*Pattern // Multiple patterns per cluster
28+
29+
// Timestamp tracking for the cluster itself
30+
CreatedAt time.Time // When cluster was first created
31+
UpdatedAt time.Time // When cluster was last modified (any pattern changed)
32+
}
33+
34+
// NewCluster creates a new cluster.
35+
func NewCluster(signature token.Signature) *Cluster {
36+
now := time.Now()
37+
return &Cluster{
38+
Signature: signature,
39+
Patterns: nil, // Will be generated when needed
40+
CreatedAt: now,
41+
UpdatedAt: now,
42+
}
43+
}
44+
45+
// =============================================================================
46+
// Core Clustering Logic
47+
// =============================================================================
48+
49+
// AddTokenListToPatterns adds a TokenList to the appropriate pattern in the cluster.
50+
// If no matching pattern exists, creates a new one.
51+
func (c *Cluster) AddTokenListToPatterns(tokenList *token.TokenList, cm *ClusterManager) *Pattern {
52+
// Ensure patterns are generated
53+
if len(c.Patterns) == 0 {
54+
// No patterns yet, create first one
55+
patternID := cm.generatePatternID()
56+
pattern := newPattern(tokenList, patternID)
57+
58+
c.Patterns = []*Pattern{pattern}
59+
// Update the cluster's new pattern at timestamp
60+
c.UpdatedAt = time.Now()
61+
return pattern
62+
}
63+
64+
// Try to find a matching pattern
65+
for _, p := range c.Patterns {
66+
// Check if this TokenList can merge with this pattern's sample
67+
if p.Sample != nil && merging.CanMergeTokenLists(tokenList, p.Sample) {
68+
// CRITICAL: Also verify it can merge with the template
69+
// If template has evolved differently, regeneratePattern will fail
70+
// and we should create a new pattern instead
71+
// Note: CanMergeTokenLists is not symmetric, so check both directions
72+
if p.Template != nil {
73+
templateCompatible1 := merging.CanMergeTokenLists(p.Template, tokenList)
74+
templateCompatible2 := merging.CanMergeTokenLists(tokenList, p.Template)
75+
templateCompatible := templateCompatible1 || templateCompatible2
76+
if !templateCompatible {
77+
// Log matches sample but not template - template has evolved incompatibly
78+
// Skip this pattern and continue searching or create new one
79+
// This will create a new pattern instead
80+
continue
81+
}
82+
}
83+
84+
// Merge into existing pattern (same PatternID is preserved)
85+
p.LogCount++
86+
p.LastAccessAt = time.Now() // Update last access time for eviction
87+
p.UpdatedAt = time.Now()
88+
c.UpdatedAt = time.Now()
89+
90+
// Incrementally merge the new token list into the pattern template
91+
// regeneratePattern will update template if merge succeeds
92+
if c.regeneratePattern(p, tokenList) {
93+
return p // Return existing pattern with updated template
94+
}
95+
// regeneratePattern failed - template couldn't merge with tokenList
96+
// This shouldn't happen if we checked above, but handle it gracefully
97+
// Create a new pattern instead
98+
break
99+
}
100+
}
101+
102+
// No matching pattern found, create a new one
103+
patternID := cm.generatePatternID()
104+
pattern := newPattern(tokenList, patternID)
105+
c.Patterns = append(c.Patterns, pattern)
106+
c.UpdatedAt = time.Now()
107+
return pattern
108+
}
109+
110+
// regeneratePattern incrementally merges a new token list into the pattern.
111+
// Returns true if merge succeeded, false if merge failed.
112+
func (c *Cluster) regeneratePattern(p *Pattern, newTokenList *token.TokenList) bool {
113+
if p.Template == nil {
114+
return false
115+
}
116+
117+
// Incremental merge: merge new log with existing template
118+
merged := merging.MergeTokenLists(p.Template, newTokenList)
119+
if merged == nil {
120+
// Merge failed - template and newTokenList are incompatible
121+
return false
122+
}
123+
124+
p.Template = merged
125+
p.Positions = make([]int, 0, merged.Length())
126+
127+
// Build wildcard positions list when 2 tokenlists are mergable.
128+
for i := 0; i < merged.Length(); i++ {
129+
tok := merged.Tokens[i]
130+
if tok.Wildcard == token.IsWildcard {
131+
p.Positions = append(p.Positions, i)
132+
133+
// Special handling for path wildcards
134+
if tok.Type == token.TokenAbsolutePath && p.Sample != nil && i < p.Sample.Length() {
135+
firstPath := p.Sample.Tokens[i].Value
136+
merged.Tokens[i].Value = getPathPattern(firstPath)
137+
}
138+
}
139+
}
140+
141+
p.UpdatedAt = time.Now()
142+
return true
143+
}
144+
145+
// getPathPattern converts a path to hierarchical wildcard pattern
146+
func getPathPattern(path string) string {
147+
if path == "/" {
148+
return "/"
149+
}
150+
151+
// Remove leading/trailing slashes and split
152+
trimmed := strings.Trim(path, "/")
153+
if trimmed == "" {
154+
return "/"
155+
}
156+
157+
parts := strings.Split(trimmed, "/")
158+
result := ""
159+
for i := 0; i < len(parts); i++ {
160+
result += "/*"
161+
}
162+
163+
return result
164+
}
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
// Unless explicitly stated otherwise all files in this repository are licensed
2+
// under the Apache License Version 2.0.
3+
// This product includes software developed at Datadog (https://www.datadoghq.com/).
4+
// Copyright 2016-present Datadog, Inc.
5+
6+
// Package clustering provides clustering functionality for grouping similar TokenLists,
7+
// extracting patterns wildcard, and managing pattern lifecycle through eviction policies.
8+
package clustering
9+
10+
import (
11+
"sync"
12+
13+
"github.com/DataDog/datadog-agent/pkg/logs/patterns/clustering/merging"
14+
"github.com/DataDog/datadog-agent/pkg/logs/patterns/token"
15+
"github.com/DataDog/datadog-agent/pkg/trace/log"
16+
)
17+
18+
// PatternChangeType indicates what changed when adding a TokenList to the cluster manager
19+
type PatternChangeType int
20+
21+
const (
22+
// PatternNoChange means the TokenList was added to an existing cluster without structural changes
23+
PatternNoChange PatternChangeType = iota
24+
// PatternNew means a brand new pattern was created (first time seeing this signature)
25+
PatternNew
26+
// PatternUpdated means an existing pattern's structure changed (more wildcards added)
27+
PatternUpdated
28+
)
29+
30+
// ClusterManager manages the clustering of TokenLists using hash-based bucketing.
31+
type ClusterManager struct {
32+
mu sync.RWMutex
33+
hashBuckets map[uint64][]*Cluster
34+
nextID uint64
35+
36+
// patternCount tracks the total number of patterns across all clusters.
37+
// This is maintained incrementally to avoid O(N) scans on every Add().
38+
patternCount int
39+
40+
// estimatedBytes tracks an approximate memory footprint of patterns stored in this manager.
41+
// This is an estimate (not exact Go heap usage) and is intended for threshold-based eviction triggers.
42+
estimatedBytes int64
43+
}
44+
45+
// NewClusterManager creates a new ClusterManager.
46+
func NewClusterManager() *ClusterManager {
47+
return &ClusterManager{
48+
hashBuckets: make(map[uint64][]*Cluster),
49+
nextID: 1,
50+
}
51+
}
52+
53+
// PatternCount returns the total number of patterns currently stored.
54+
func (cm *ClusterManager) PatternCount() int {
55+
cm.mu.RLock()
56+
defer cm.mu.RUnlock()
57+
return cm.patternCount
58+
}
59+
60+
// EstimatedBytes returns the approximate memory footprint (in bytes) of patterns currently stored.
61+
func (cm *ClusterManager) EstimatedBytes() int64 {
62+
cm.mu.RLock()
63+
defer cm.mu.RUnlock()
64+
return cm.estimatedBytes
65+
}
66+
67+
// Add processes a TokenList and adds it to the appropriate cluster.
68+
// Returns:
69+
// - pattern: the pattern that was created/updated
70+
// - changeType: what changed (new/updated/no change)
71+
// - patternCount: total patterns after this addition
72+
// - estimatedBytes: total estimated memory after this addition
73+
func (cm *ClusterManager) Add(tokenList *token.TokenList) (*Pattern, PatternChangeType, int, int64) {
74+
if tokenList == nil || tokenList.IsEmpty() {
75+
log.Errorf("Cluster Manager failed to add log: %v for patterning. Token list is empty or nil.", tokenList.String())
76+
return nil, PatternNoChange, 0, 0
77+
}
78+
79+
// Lock the cluster manager to prevent concurrent access to the hash buckets. Current implementation is single-threaded on local pipeline, but we will eventually build a shared cluster manager across multiple pipelines.
80+
// todo: implement a shared cluster manager across multiple pipelines
81+
cm.mu.Lock()
82+
defer cm.mu.Unlock()
83+
84+
// Create new signature and hash it
85+
signature := token.NewSignature(tokenList)
86+
hash := signature.Hash
87+
88+
// Get hash bucket
89+
clusters := cm.hashBuckets[hash]
90+
91+
// Look for existing cluster with matching signature
92+
for _, cluster := range clusters {
93+
if !cluster.Signature.Equals(signature) {
94+
continue
95+
}
96+
97+
// Find which pattern within the cluster the tokenList will match
98+
var matchedPattern *Pattern
99+
var oldWildcardCount int
100+
oldPatternCount := len(cluster.Patterns)
101+
var oldMatchedBytes int64
102+
for _, p := range cluster.Patterns {
103+
if p.Sample != nil && merging.CanMergeTokenLists(tokenList, p.Sample) {
104+
matchedPattern = p
105+
oldWildcardCount = p.GetWildcardCount()
106+
oldMatchedBytes = p.EstimatedBytes()
107+
break
108+
}
109+
}
110+
111+
// Add the tokenList to the cluster (merges or creates new pattern)
112+
pattern := cluster.AddTokenListToPatterns(tokenList, cm)
113+
114+
// Update counters (pattern count + estimated bytes)
115+
// If a new pattern was appended to this cluster, increment counts.
116+
if len(cluster.Patterns) > oldPatternCount {
117+
cm.patternCount++
118+
cm.estimatedBytes += pattern.EstimatedBytes()
119+
} else if matchedPattern != nil && matchedPattern.PatternID == pattern.PatternID {
120+
// Existing pattern updated; adjust bytes based on template evolution.
121+
cm.estimatedBytes += pattern.EstimatedBytes() - oldMatchedBytes
122+
}
123+
124+
// Check if a new pattern was created (no match found or merge failed)
125+
if matchedPattern == nil || matchedPattern.PatternID != pattern.PatternID {
126+
return pattern, PatternNew, cm.patternCount, cm.estimatedBytes
127+
}
128+
129+
// Check if wildcard count changed (pattern evolved)
130+
if pattern.GetWildcardCount() != oldWildcardCount {
131+
return pattern, PatternUpdated, cm.patternCount, cm.estimatedBytes
132+
}
133+
134+
return pattern, PatternNoChange, cm.patternCount, cm.estimatedBytes
135+
}
136+
137+
// If no matching pattern was found, create a new cluster and pattern.
138+
newCluster := NewCluster(signature)
139+
// Add the token list to create the first pattern
140+
pattern := newCluster.AddTokenListToPatterns(tokenList, cm)
141+
cm.hashBuckets[hash] = append(clusters, newCluster)
142+
143+
// New cluster always creates exactly one new pattern
144+
cm.patternCount++
145+
cm.estimatedBytes += pattern.EstimatedBytes()
146+
147+
return pattern, PatternNew, cm.patternCount, cm.estimatedBytes
148+
}
149+
150+
// Clear removes all clusters.
151+
func (cm *ClusterManager) Clear() {
152+
cm.mu.Lock()
153+
defer cm.mu.Unlock()
154+
cm.hashBuckets = make(map[uint64][]*Cluster)
155+
cm.patternCount = 0
156+
cm.estimatedBytes = 0
157+
}
158+
159+
// GetStats returns the current pattern count and estimated memory usage.
160+
// This is a read-only operation that acquires a read lock for thread safety.
161+
func (cm *ClusterManager) GetStats() (patternCount int, estimatedBytes int64) {
162+
cm.mu.RLock()
163+
defer cm.mu.RUnlock()
164+
return cm.patternCount, cm.estimatedBytes
165+
}
166+
167+
// generatePatternID generates a unique pattern ID using a monotonic counter.
168+
// Must be called while holding the ClusterManager lock.
169+
func (cm *ClusterManager) generatePatternID() uint64 {
170+
id := cm.nextID
171+
cm.nextID++
172+
return id
173+
}

0 commit comments

Comments
 (0)