Module: QueryGuard::Fingerprint

Defined in:
lib/query_guard/fingerprint.rb

Overview

SQL fingerprinting and statistics module. Normalizes SQL queries and tracks per-fingerprint statistics.

Class Method Summary collapse

Class Method Details

.generate(sql) ⇒ Object

Generate a stable fingerprint for a SQL query by:

  • Normalizing whitespace
  • Replacing string literals with ?
  • Replacing numeric literals with ?
  • Replacing IN (...) lists with IN (?) Returns a SHA1 hash of the normalized query


15
16
17
18
# File 'lib/query_guard/fingerprint.rb', line 15

def generate(sql)
  normalized = normalize(sql)
  Digest::SHA1.hexdigest(normalized)
end

.normalize(sql) ⇒ Object

Normalize SQL by removing all literals and standardizing whitespace



21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
# File 'lib/query_guard/fingerprint.rb', line 21

def normalize(sql)
  s = sql.to_s.dup
  
  # Replace string literals (both single and double quotes)
  s.gsub!(/'(?:''|[^'])*'/, "?")
  s.gsub!(/"(?:""|[^"])*"/, "?")
  
  # Replace numeric literals (integers and floats)
  s.gsub!(/\b\d+\.?\d*\b/, "?")
  
  # Replace IN (...) with IN (?)
  s.gsub!(/IN\s*\([^)]+\)/i, "IN (?)")
  
  # Normalize whitespace
  s.gsub!(/\s+/, " ")
  
  s.strip.downcase
end

.record(sql, duration_ms) ⇒ Object

Record a query execution



55
56
57
58
59
60
61
62
63
64
65
66
# File 'lib/query_guard/fingerprint.rb', line 55

def record(sql, duration_ms)
  fp = generate(sql)
  stat = stats[fp]
  
  stat[:count] += 1
  stat[:total_duration_ms] += duration_ms
  stat[:min_duration_ms] = [stat[:min_duration_ms], duration_ms].min
  stat[:max_duration_ms] = [stat[:max_duration_ms], duration_ms].max
  stat[:last_seen_at] = Time.now
  
  fp
end

.reset! ⇒ Object

Reset all stats (useful for testing)



91
92
93
# File 'lib/query_guard/fingerprint.rb', line 91

def reset!
  @stats = nil
end

.stats ⇒ Object

Get or initialize the stats store for the current process



41
42
43
44
45
46
47
48
49
50
51
52
# File 'lib/query_guard/fingerprint.rb', line 41

def stats
  @stats ||= Hash.new do |h, fp|
    h[fp] = {
      count: 0,
      total_duration_ms: 0.0,
      min_duration_ms: Float::INFINITY,
      max_duration_ms: 0.0,
      first_seen_at: Time.now,
      last_seen_at: Time.now
    }
  end
end

.stats_for(fingerprint) ⇒ Object

Get stats for a specific fingerprint



69
70
71
# File 'lib/query_guard/fingerprint.rb', line 69

def stats_for(fingerprint)
  stats[fingerprint]
end

.top_by_avg_duration(limit = 10) ⇒ Object

Get all fingerprints sorted by average duration



84
85
86
87
88
# File 'lib/query_guard/fingerprint.rb', line 84

def top_by_avg_duration(limit = 10)
  stats.sort_by { |_fp, s| 
    s[:count] > 0 ? -(s[:total_duration_ms] / s[:count]) : 0
  }.first(limit).to_h
end

.top_by_count(limit = 10) ⇒ Object

Get all fingerprints sorted by frequency



74
75
76
# File 'lib/query_guard/fingerprint.rb', line 74

def top_by_count(limit = 10)
  stats.sort_by { |_fp, s| -s[:count] }.first(limit).to_h
end

.top_by_duration(limit = 10) ⇒ Object

Get all fingerprints sorted by total duration



79
80
81
# File 'lib/query_guard/fingerprint.rb', line 79

def top_by_duration(limit = 10)
  stats.sort_by { |_fp, s| -s[:total_duration_ms] }.first(limit).to_h
end