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
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,8 @@ search.
| `d>100ms` | Duration greater than | `d>1s`, `d>500us` |
| `d<10ms` | Duration less than | `d<50ms` |
| `error` | Events with errors only | |
| `n+1` | N+1 flagged queries | alias: `nplus1` |
| `slow` | Slow queries only | |
| `op:select` | SQL keyword prefix | `op:insert`, `op:update`, `op:delete` |
| `op:begin` | Protocol operation | `op:commit`, `op:rollback` |
| _(other)_ | Text substring match | `users`, `WHERE id` |
Expand Down Expand Up @@ -351,7 +353,9 @@ Detection is enabled by default and runs server-side, so both TUI and Web UI ben
| `--nplus1-cooldown` | `10s` | Minimum interval between alert notifications for the same query |

Only SELECT queries are monitored. INSERT, UPDATE, DELETE, and transaction lifecycle commands (BEGIN, COMMIT, etc.) are
excluded.
excluded. Metadata queries — SELECT statements without a FROM clause, such as `SELECT database()`, `SELECT @@version`,
or `SELECT 1` — are also excluded, as they are typically driver health checks or system introspection calls rather than
application data queries.

Once the threshold is crossed, all subsequent executions of the same template within the window are flagged. The
cooldown only affects the notification frequency — the Status column marker always appears.
Expand Down
22 changes: 21 additions & 1 deletion cmd/sql-tapd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"net"
"os"
"os/signal"
"regexp"
"strings"
"syscall"
"time"
Expand Down Expand Up @@ -235,9 +236,28 @@ func isSelectQuery(op proxy.Op, q string) bool {
switch op {
case proxy.OpQuery, proxy.OpExec, proxy.OpExecute:
trimmed := strings.TrimSpace(q)
return len(trimmed) >= 6 && strings.EqualFold(trimmed[:6], "SELECT")
if len(trimmed) < 6 || !strings.EqualFold(trimmed[:6], "SELECT") {
return false
}
return !isMetadataQuery(trimmed)
case proxy.OpPrepare, proxy.OpBind, proxy.OpBegin, proxy.OpCommit, proxy.OpRollback:
return false
}
return false
}

// reFromClause matches the SQL FROM keyword as a whole word, used to detect
// whether a SELECT query references any table.
// NOTE: This is a simple keyword check; it cannot distinguish a FROM clause
// from FROM inside expressions (e.g. EXTRACT(EPOCH FROM NOW()) in Postgres).
// Such queries will not be classified as metadata, which is a safe default
// (they stay in N+1 detection rather than being silently dropped).
var reFromClause = regexp.MustCompile(`(?i)\bFROM\b`)

// isMetadataQuery reports whether q is a system/metadata SELECT that should be
// excluded from N+1 detection. These are selects that do not reference any
// table (i.e. have no FROM clause), such as SELECT database(), SELECT @@version,
// or SELECT 1.
func isMetadataQuery(q string) bool {
return !reFromClause.MatchString(q)
Comment on lines +249 to +262
}
134 changes: 134 additions & 0 deletions cmd/sql-tapd/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package main

import (
"testing"

"github.com/mickamy/sql-tap/proxy"
)

func TestIsSelectQuery(t *testing.T) {
t.Parallel()

tests := []struct {
name string
op proxy.Op
q string
want bool
}{
{
name: "regular select",
op: proxy.OpQuery,
q: "SELECT id FROM users WHERE id = 1",
want: true,
},
{
name: "metadata: SELECT database()",
op: proxy.OpQuery,
q: "SELECT database()",
want: false,
},
{
name: "metadata: SELECT @@version",
op: proxy.OpQuery,
q: "SELECT @@version",
want: false,
},
{
name: "metadata: SELECT 1",
op: proxy.OpQuery,
q: "SELECT 1",
want: false,
},
{
name: "metadata: SELECT NOW()",
op: proxy.OpQuery,
q: "SELECT NOW()",
want: false,
},
{
name: "metadata: SELECT current_database()",
op: proxy.OpQuery,
q: "SELECT current_database()",
want: false,
},
{
name: "select with FROM (not metadata)",
op: proxy.OpQuery,
q: "SELECT 1 FROM dual",
want: true,
},
{
name: "insert not select",
op: proxy.OpQuery,
q: "INSERT INTO users VALUES (1)",
want: false,
},
{
name: "begin op",
op: proxy.OpBegin,
q: "",
want: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := isSelectQuery(tt.op, tt.q)
if got != tt.want {
t.Errorf("isSelectQuery(%v, %q) = %v, want %v", tt.op, tt.q, got, tt.want)
}
})
}
}

func TestIsMetadataQuery(t *testing.T) {
t.Parallel()

tests := []struct {
name string
q string
want bool
}{
{
name: "no FROM clause",
q: "SELECT database()",
want: true,
},
{
name: "system variable",
q: "SELECT @@session.transaction_read_only",
want: true,
},
{
name: "constant",
q: "SELECT 1",
want: true,
},
{
name: "has FROM clause",
q: "SELECT id FROM users",
want: false,
},
{
name: "subquery with FROM",
q: "SELECT (SELECT COUNT(*) FROM orders)",
want: false,
},
{
name: "expression-level FROM (Postgres EXTRACT)",
q: "SELECT EXTRACT(EPOCH FROM NOW())",
want: false, // false negative: no table, but FROM in expression
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := isMetadataQuery(tt.q)
if got != tt.want {
t.Errorf("isMetadataQuery(%q) = %v, want %v", tt.q, got, tt.want)
}
})
}
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ require (
github.com/testcontainers/testcontainers-go/modules/mysql v0.40.0
google.golang.org/grpc v1.79.1
google.golang.org/protobuf v1.36.11
gopkg.in/yaml.v3 v3.0.1
)

require (
Expand Down Expand Up @@ -96,5 +97,4 @@ require (
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
28 changes: 23 additions & 5 deletions tui/filter.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ const (
filterDuration // d>100ms, d<10ms
filterError // "error" keyword
filterOp // op:select, op:begin, etc.
filterNPlus1 // "n+1" or "nplus1" keyword
filterSlow // "slow" keyword
)

type durationOp int
Expand Down Expand Up @@ -70,18 +72,27 @@ func parseFilter(input string) []filterCondition {
conds = append(conds, c)
continue
}
if strings.ToLower(tok) == "error" {
lower := strings.ToLower(tok)
if lower == "error" {
conds = append(conds, filterCondition{kind: filterError})
continue
}
if c, ok := parseOp(tok); ok {
if lower == "n+1" || lower == "nplus1" {
conds = append(conds, filterCondition{kind: filterNPlus1})
continue
}
if lower == "slow" {
conds = append(conds, filterCondition{kind: filterSlow})
continue
}
if c, ok := parseOp(lower); ok {
conds = append(conds, c)
continue
}
// Fallback: plain text match.
conds = append(conds, filterCondition{
kind: filterText,
text: strings.ToLower(tok),
text: lower,
})
}
return conds
Expand Down Expand Up @@ -124,8 +135,7 @@ func unitSuffix(unit string) string {
return "ms"
}

func parseOp(tok string) (filterCondition, bool) {
lower := strings.ToLower(tok)
func parseOp(lower string) (filterCondition, bool) {
if !strings.HasPrefix(lower, "op:") {
return filterCondition{}, false
}
Expand Down Expand Up @@ -157,6 +167,10 @@ func (c filterCondition) matchesEvent(ev *tapv1.QueryEvent) bool {
}
case filterError:
return ev.GetError() != ""
case filterNPlus1:
return ev.GetNPlus_1()
case filterSlow:
return ev.GetSlowQuery()
case filterOp:
return matchOp(ev, c.opPattern)
}
Expand Down Expand Up @@ -203,6 +217,10 @@ func describeFilter(input string) string {
parts = append(parts, "d"+op+c.durValue.String())
case filterError:
parts = append(parts, "error")
case filterNPlus1:
parts = append(parts, "n+1")
case filterSlow:
parts = append(parts, "slow")
case filterOp:
parts = append(parts, "op:"+c.opPattern)
}
Expand Down
63 changes: 63 additions & 0 deletions tui/filter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,27 @@ func TestParseFilter(t *testing.T) {
{kind: filterOp, opPattern: "begin"},
},
},
{
name: "n+1 keyword",
input: "n+1",
want: []filterCondition{
{kind: filterNPlus1},
},
},
{
name: "nplus1 keyword",
input: "nplus1",
want: []filterCondition{
{kind: filterNPlus1},
},
},
{
name: "slow keyword",
input: "slow",
want: []filterCondition{
{kind: filterSlow},
},
},
{
name: "combined filter",
input: "op:select d>100ms",
Expand Down Expand Up @@ -227,6 +248,38 @@ func TestMatchesEvent(t *testing.T) {
ev: makeEvent(proxy.OpQuery, "INSERT INTO users (name) VALUES ('alice')", 5*time.Millisecond, ""),
want: true,
},
{
name: "n+1 match",
cond: filterCondition{kind: filterNPlus1},
ev: func() *tapv1.QueryEvent {
ev := makeEvent(proxy.OpQuery, "SELECT id FROM users WHERE id = 1", 5*time.Millisecond, "")
ev.NPlus_1 = true
return ev
}(),
want: true,
},
{
name: "n+1 no match",
cond: filterCondition{kind: filterNPlus1},
ev: makeEvent(proxy.OpQuery, "SELECT id FROM users WHERE id = 1", 5*time.Millisecond, ""),
want: false,
},
{
name: "slow match",
cond: filterCondition{kind: filterSlow},
ev: func() *tapv1.QueryEvent {
ev := makeEvent(proxy.OpQuery, "SELECT id FROM users", 500*time.Millisecond, "")
ev.SlowQuery = true
return ev
}(),
want: true,
},
{
name: "slow no match",
cond: filterCondition{kind: filterSlow},
ev: makeEvent(proxy.OpQuery, "SELECT id FROM users", 5*time.Millisecond, ""),
want: false,
},
}

for _, tt := range tests {
Expand Down Expand Up @@ -356,6 +409,16 @@ func TestDescribeFilter(t *testing.T) {
input: "error",
want: "error",
},
{
name: "n+1 keyword",
input: "n+1",
want: "n+1",
},
{
name: "slow keyword",
input: "slow",
want: "slow",
},
{
name: "text fallback",
input: "users",
Expand Down
6 changes: 6 additions & 0 deletions web/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,8 @@ function parseFilterTokens(input) {
return {kind: 'duration', op, ms};
}
if (tok.toLowerCase() === 'error') return {kind: 'error'};
if (tok.toLowerCase() === 'n+1' || tok.toLowerCase() === 'nplus1') return {kind: 'nplus1'};
if (tok.toLowerCase() === 'slow') return {kind: 'slow'};
const lower = tok.toLowerCase();
if (lower.startsWith('op:') && lower.length > 3) return {kind: 'op', pattern: lower.slice(3)};
return {kind: 'text', text: lower};
Expand All @@ -214,6 +216,10 @@ function matchesFilter(ev, cond) {
return cond.op === '>' ? ev.duration_ms > cond.ms : ev.duration_ms < cond.ms;
case 'error':
return !!ev.error;
case 'nplus1':
return !!ev.n_plus_1;
case 'slow':
return !!ev.slow_query;
case 'op':
if (PROTOCOL_OPS.has(cond.pattern)) return ev.op.toLowerCase() === cond.pattern;
if (OP_KEYWORDS.has(cond.pattern)) return (ev.query || '').trim().toLowerCase().startsWith(cond.pattern);
Expand Down
Loading