mirror of
https://github.com/hanzoai/traces.git
synced 2026-10-30 18:20:41 +00:00
* [Generator] Add DRAIN span_name sanitizer * Fix linter * Fix formatting * Update default manifest * Fix build error in update-fixtures * Remove bad test * Remove misleading comment * Add per-tenant override for drain mode * Do a machines job * Address PR feedback * Address PR review: rename drain_mode config and improve sanitizer - Rename `drain_mode` to `span_name_sanitization` across all config, legacy config, interfaces, and overrides for better user-facing clarity - Add explicit default value "disabled" in RegisterFlagsAndApplyDefaults - Add ValidateSpanNameSanitization function in validation/fields.go - Rename metrics to remove DRAIN reference: `drain_spans_compacted_total` -> `spans_sanitized_total` `post_drain_demand_estimate` -> `post_sanitization_demand_estimate` - Pass configured staleDuration to NewDrainSanitizer instead of hardcoded 15min, consistent with other registry components - Extract "span_name" magic string to labelSpanName constant - Update all tests with new names and signatures * Update manifest * Use empty string as disabled * Update CHANGELOG.md Co-authored-by: Suraj Nath <[email protected]> * Update manifest --------- Co-authored-by: Suraj Nath <[email protected]>
256 lines
7.0 KiB
Go
256 lines
7.0 KiB
Go
package drain
|
|
|
|
import (
|
|
"slices"
|
|
"strconv"
|
|
)
|
|
|
|
// Drain is an implementation of the DRAIN algorithm, specialized for clustering
|
|
// span names in Tempo.
|
|
type Drain struct {
|
|
config *Config
|
|
maxNodeDepth int
|
|
metrics *tenantMetrics
|
|
tokenizer LineTokenizer
|
|
tokenIsLikelyData IsDataHeuristic
|
|
|
|
// The primary state of the Drain algorithm consists of a prefix tree and a
|
|
// cache of clusters. Clusters are named "log clusters" to match the
|
|
// original DRAIN algorithm, but here are used to cluster span names.
|
|
clustersCounter int
|
|
rootNode *Node
|
|
idToCluster *logClusterCache
|
|
|
|
// tokenBuffer is reused to avoid allocating a new slice for each call to Train.
|
|
tokenBuffer []string
|
|
}
|
|
|
|
// New creates a new Drain instance.
|
|
func New(tenant string, config *Config) *Drain {
|
|
if config.LogClusterDepth < 3 {
|
|
config.LogClusterDepth = 3
|
|
}
|
|
|
|
metrics := metricsForTenant(tenant)
|
|
d := &Drain{
|
|
config: config,
|
|
maxNodeDepth: config.LogClusterDepth - 2,
|
|
metrics: metrics,
|
|
tokenizer: &defaultTokenizer{},
|
|
tokenIsLikelyData: defaultIsDataHeuristic,
|
|
|
|
rootNode: newNode(),
|
|
idToCluster: newLogClusterCache(config.StaleClusterAge, config.MaxClusters, metrics.PatternsEvictedTotal, metrics.PatternsExpiredTotal),
|
|
}
|
|
|
|
return d
|
|
}
|
|
|
|
// Node is a node in the prefix tree.
|
|
type Node struct {
|
|
keyToChildNode map[string]*Node
|
|
clusterIDs []int
|
|
}
|
|
|
|
func newNode() *Node {
|
|
return &Node{
|
|
keyToChildNode: make(map[string]*Node),
|
|
clusterIDs: make([]int, 0),
|
|
}
|
|
}
|
|
|
|
func (d *Drain) Clusters() []*LogCluster {
|
|
return slices.Collect(d.idToCluster.Values())
|
|
}
|
|
|
|
func (d *Drain) Train(content string) *LogCluster {
|
|
d.tokenBuffer = d.tokenizer.Tokenize(content, d.tokenBuffer)
|
|
if len(d.tokenBuffer) == 0 {
|
|
return nil
|
|
}
|
|
|
|
if len(d.tokenBuffer) < d.config.MinTokens {
|
|
d.metrics.LinesSkippedTooFewTokens.Inc()
|
|
return nil
|
|
}
|
|
if len(d.tokenBuffer) > d.config.MaxTokens {
|
|
d.metrics.LinesSkippedTooManyTokens.Inc()
|
|
return nil
|
|
}
|
|
d.metrics.TokensPerLine.Observe(float64(len(d.tokenBuffer)))
|
|
|
|
return d.train(d.tokenBuffer)
|
|
}
|
|
|
|
func (d *Drain) newCluster(tokens []string) *LogCluster {
|
|
d.clustersCounter++
|
|
return &LogCluster{
|
|
Tokens: slices.Clone(tokens),
|
|
id: d.clustersCounter,
|
|
Size: 1,
|
|
Stringer: d.tokenizer.Join,
|
|
ParamString: d.config.ParamString,
|
|
}
|
|
}
|
|
|
|
func (d *Drain) train(tokens []string) *LogCluster {
|
|
cluster := d.findMatchingClusterForTokens(tokens)
|
|
|
|
if cluster == nil {
|
|
cluster = d.newCluster(tokens)
|
|
d.addClusterToRootNode(cluster)
|
|
d.metrics.PatternsDetectedTotal.Inc()
|
|
} else {
|
|
cluster.ingestTokens(tokens)
|
|
}
|
|
|
|
d.idToCluster.Put(cluster)
|
|
return cluster
|
|
}
|
|
|
|
// Prune removes old branches from the tree. We rely on the cache eviction
|
|
// algorithm to remove clusters from the cache, then this method will remove
|
|
// references to them in the tree.
|
|
func (d *Drain) Prune() {
|
|
d.pruneTree(d.rootNode)
|
|
}
|
|
|
|
// pruneTree removes old branches from a node and its children. Nodes are pruned
|
|
// once they reference no valid clusters in their clusterIDs list.
|
|
func (d *Drain) pruneTree(node *Node) int {
|
|
for key, child := range node.keyToChildNode {
|
|
if d.pruneTree(child) == 0 {
|
|
delete(node.keyToChildNode, key)
|
|
}
|
|
}
|
|
|
|
node.clusterIDs = slices.DeleteFunc(node.clusterIDs, d.idToCluster.NotExists)
|
|
return len(node.keyToChildNode) + len(node.clusterIDs)
|
|
}
|
|
|
|
func (d *Drain) Delete(cluster *LogCluster) {
|
|
d.idToCluster.Remove(cluster.id)
|
|
}
|
|
|
|
func (d *Drain) findMatchingClusterForTokens(tokens []string) *LogCluster {
|
|
tokenCount := len(tokens)
|
|
|
|
// at first level, children are grouped by token (word) count
|
|
curNode, ok := d.rootNode.keyToChildNode[strconv.Itoa(tokenCount)]
|
|
if !ok {
|
|
// no template with same token count yet
|
|
return nil
|
|
}
|
|
|
|
// we always end the token list with an <END> token, so <2 tokens is a
|
|
// special case for an empty input string. In this case, we return the
|
|
// single cluster in that group.
|
|
if tokenCount < 2 {
|
|
return d.idToCluster.Get(curNode.clusterIDs[0])
|
|
}
|
|
|
|
// otherwise, we need to find the leaf node for this log.
|
|
curNodeDepth := 1
|
|
for _, token := range tokens {
|
|
// at max depth
|
|
if curNodeDepth >= d.maxNodeDepth {
|
|
break
|
|
}
|
|
|
|
// this is last token
|
|
if curNodeDepth == tokenCount {
|
|
break
|
|
}
|
|
|
|
keyToChildNode := curNode.keyToChildNode
|
|
curNode, ok = keyToChildNode[d.config.ParamString]
|
|
if !ok { // no wildcard node, try exact match
|
|
curNode, ok = keyToChildNode[token]
|
|
}
|
|
if !ok { // no existing path
|
|
return nil
|
|
}
|
|
curNodeDepth++
|
|
}
|
|
|
|
// get best match among all clusters with same prefix, or None if no match is above sim_th
|
|
cluster := d.findBestClusterForTokens(curNode.clusterIDs, tokens)
|
|
return cluster
|
|
}
|
|
|
|
// findBestClusterForTokens finds the best match for a log message (represented
|
|
// as tokens) versus a list of clusters. A Match is considered better if it has
|
|
// a higher similarity or if it has the same similarity but more parameter tokens.
|
|
func (d *Drain) findBestClusterForTokens(clusterIDs []int, tokens []string) *LogCluster {
|
|
var maxCluster *LogCluster
|
|
|
|
maxSimilarity := -1.0
|
|
maxParamCount := -1
|
|
for _, clusterID := range clusterIDs {
|
|
// Try to retrieve cluster from cache. It may not exist due to eviction.
|
|
// In that case, we skip it for now, and this id will eventually be
|
|
// removed from the node by Prune. We do not update the access time
|
|
// here, because this may not be the cluster we're looking for.
|
|
cluster := d.idToCluster.GetQuietly(clusterID)
|
|
if cluster == nil {
|
|
continue
|
|
}
|
|
similarity, paramCount := cluster.tokenDistance(tokens)
|
|
if paramCount < 0 {
|
|
continue
|
|
}
|
|
if similarity > maxSimilarity || (similarity == maxSimilarity && paramCount > maxParamCount) {
|
|
maxSimilarity = similarity
|
|
maxParamCount = paramCount
|
|
maxCluster = cluster
|
|
}
|
|
}
|
|
if maxSimilarity < d.config.SimTh {
|
|
return nil
|
|
}
|
|
return maxCluster
|
|
}
|
|
|
|
func (d *Drain) addClusterToRootNode(cluster *LogCluster) {
|
|
tokenCount := len(cluster.Tokens)
|
|
tokenCountStr := strconv.Itoa(tokenCount)
|
|
|
|
curNode, ok := d.rootNode.keyToChildNode[tokenCountStr]
|
|
if !ok {
|
|
curNode = newNode()
|
|
d.rootNode.keyToChildNode[tokenCountStr] = curNode
|
|
}
|
|
|
|
d.addClusterToNode(curNode, cluster.id, cluster.Tokens, tokenCount, 1)
|
|
}
|
|
|
|
func (d *Drain) addClusterToNode(curNode *Node, clusterID int, tokens []string, totalTokens int, currentDepth int) {
|
|
// If we can't descend any further, add the cluster ID to the node.
|
|
if currentDepth >= min(d.maxNodeDepth, totalTokens) {
|
|
curNode.clusterIDs = append(curNode.clusterIDs, clusterID)
|
|
return
|
|
}
|
|
|
|
token := tokens[0]
|
|
|
|
// If our heuristic says this is likely data, we use the param string as the
|
|
// token instead. This is non-standard DRAIN, but it helps to identify
|
|
// patterns more quickly.
|
|
if d.tokenIsLikelyData(token) {
|
|
token = d.config.ParamString
|
|
}
|
|
|
|
// If we've reached the max number of children, we collapse this node
|
|
if len(curNode.keyToChildNode)+1 >= d.config.MaxChildren {
|
|
token = d.config.ParamString
|
|
}
|
|
|
|
nextNode, ok := curNode.keyToChildNode[token]
|
|
if !ok {
|
|
nextNode = newNode()
|
|
curNode.keyToChildNode[token] = nextNode
|
|
}
|
|
|
|
d.addClusterToNode(nextNode, clusterID, tokens[1:], totalTokens, currentDepth+1)
|
|
}
|