From 6e6fabd70d524e548656983f4577279e061674ed Mon Sep 17 00:00:00 2001 From: Tetsuro Mikami Date: Sat, 21 Feb 2026 11:57:25 +0900 Subject: [PATCH] feat: colorize slow query --- cmd/sql-tapd/main.go | 12 ++++++++++-- example/postgres/main.go | 9 +++++++++ gen/tap/v1/tap.pb.go | 14 ++++++++++++-- proto/tap/v1/tap.proto | 1 + proxy/proxy.go | 1 + server/server.go | 1 + tui/list.go | 4 ++++ tui/model.go | 8 ++++++-- web/static/app.js | 7 +++++-- web/static/style.css | 6 ++++-- web/web.go | 2 ++ 11 files changed, 55 insertions(+), 10 deletions(-) diff --git a/cmd/sql-tapd/main.go b/cmd/sql-tapd/main.go index a4d33c5..39be883 100644 --- a/cmd/sql-tapd/main.go +++ b/cmd/sql-tapd/main.go @@ -46,6 +46,7 @@ func main() { nplus1Threshold := fs.Int("nplus1-threshold", 5, "N+1 detection threshold (0 to disable)") nplus1Window := fs.Duration("nplus1-window", time.Second, "N+1 detection time window") nplus1Cooldown := fs.Duration("nplus1-cooldown", 10*time.Second, "N+1 alert cooldown per query template") + slowThreshold := fs.Duration("slow-threshold", 100*time.Millisecond, "slow query threshold (0 to disable)") showVersion := fs.Bool("version", false, "show version and exit") _ = fs.Parse(os.Args[1:]) @@ -62,7 +63,7 @@ func main() { err := run( *driver, *listen, *upstream, *grpcAddr, *dsnEnv, *httpAddr, - *nplus1Threshold, *nplus1Window, *nplus1Cooldown, + *nplus1Threshold, *nplus1Window, *nplus1Cooldown, *slowThreshold, ) if err != nil { log.Fatal(err) @@ -71,7 +72,7 @@ func main() { func run( driver, listen, upstream, grpcAddr, dsnEnv, httpAddr string, - nplus1Threshold int, nplus1Window, nplus1Cooldown time.Duration, + nplus1Threshold int, nplus1Window, nplus1Cooldown, slowThreshold time.Duration, ) error { ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() @@ -155,6 +156,10 @@ func run( nplus1Threshold, nplus1Window, nplus1Cooldown) } + if slowThreshold > 0 { + log.Printf("slow query detection enabled (threshold=%s)", slowThreshold) + } + go func() { for ev := range p.Events() { if ev.Query != "" { @@ -168,6 +173,9 @@ func run( r.Alert.Query, r.Alert.Count, nplus1Window) } } + if slowThreshold > 0 && ev.Duration >= slowThreshold { + ev.SlowQuery = true + } b.Publish(ev) } }() diff --git a/example/postgres/main.go b/example/postgres/main.go index 9d0471b..d80753a 100644 --- a/example/postgres/main.go +++ b/example/postgres/main.go @@ -55,6 +55,10 @@ func run() error { if i%3 == 0 { doNPlus1(ctx, db, i) } + // Occasionally simulate slow query. + if i%5 == 0 { + doSlowQuery(ctx, db) + } select { case <-ctx.Done(): @@ -207,6 +211,11 @@ func doNPlus1(ctx context.Context, db *sql.DB, i int) { fmt.Printf("[%d] N+1 simulation done (10 individual SELECTs)\n", i) } +func doSlowQuery(ctx context.Context, db *sql.DB) { + _, _ = db.ExecContext(ctx, "SELECT pg_sleep(0.15)") + fmt.Println("slow query simulation done (150ms sleep)") +} + func doLongQuery(ctx context.Context, db *sql.DB, i int) { var dummy int _ = db.QueryRowContext(ctx, ` diff --git a/gen/tap/v1/tap.pb.go b/gen/tap/v1/tap.pb.go index 3f191f7..d8c7172 100644 --- a/gen/tap/v1/tap.pb.go +++ b/gen/tap/v1/tap.pb.go @@ -36,6 +36,7 @@ type QueryEvent struct { TxId string `protobuf:"bytes,9,opt,name=tx_id,json=txId,proto3" json:"tx_id,omitempty"` NPlus_1 bool `protobuf:"varint,10,opt,name=n_plus_1,json=nPlus1,proto3" json:"n_plus_1,omitempty"` NormalizedQuery string `protobuf:"bytes,11,opt,name=normalized_query,json=normalizedQuery,proto3" json:"normalized_query,omitempty"` + SlowQuery bool `protobuf:"varint,12,opt,name=slow_query,json=slowQuery,proto3" json:"slow_query,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -147,6 +148,13 @@ func (x *QueryEvent) GetNormalizedQuery() string { return "" } +func (x *QueryEvent) GetSlowQuery() bool { + if x != nil { + return x.SlowQuery + } + return false +} + type WatchRequest struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields @@ -335,7 +343,7 @@ var File_tap_v1_tap_proto protoreflect.FileDescriptor const file_tap_v1_tap_proto_rawDesc = "" + "\n" + - "\x10tap/v1/tap.proto\x12\x06tap.v1\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1egoogle/protobuf/duration.proto\"\xdd\x02\n" + + "\x10tap/v1/tap.proto\x12\x06tap.v1\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1egoogle/protobuf/duration.proto\"\xfc\x02\n" + "\n" + "QueryEvent\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x0e\n" + @@ -350,7 +358,9 @@ const file_tap_v1_tap_proto_rawDesc = "" + "\x05tx_id\x18\t \x01(\tR\x04txId\x12\x18\n" + "\bn_plus_1\x18\n" + " \x01(\bR\x06nPlus1\x12)\n" + - "\x10normalized_query\x18\v \x01(\tR\x0fnormalizedQuery\"\x0e\n" + + "\x10normalized_query\x18\v \x01(\tR\x0fnormalizedQuery\x12\x1d\n" + + "\n" + + "slow_query\x18\f \x01(\bR\tslowQuery\"\x0e\n" + "\fWatchRequest\"9\n" + "\rWatchResponse\x12(\n" + "\x05event\x18\x01 \x01(\v2\x12.tap.v1.QueryEventR\x05event\"T\n" + diff --git a/proto/tap/v1/tap.proto b/proto/tap/v1/tap.proto index 77f4aa2..bf1750d 100644 --- a/proto/tap/v1/tap.proto +++ b/proto/tap/v1/tap.proto @@ -17,6 +17,7 @@ message QueryEvent { string tx_id = 9; bool n_plus_1 = 10; string normalized_query = 11; + bool slow_query = 12; } message WatchRequest {} diff --git a/proxy/proxy.go b/proxy/proxy.go index 41f2d3c..59f3bd3 100644 --- a/proxy/proxy.go +++ b/proxy/proxy.go @@ -54,6 +54,7 @@ type Event struct { Error string TxID string NPlus1 bool + SlowQuery bool NormalizedQuery string } diff --git a/server/server.go b/server/server.go index 2299691..9497616 100644 --- a/server/server.go +++ b/server/server.go @@ -119,6 +119,7 @@ func eventToProto(ev proxy.Event) *tapv1.QueryEvent { Error: sanitizeUTF8(ev.Error), TxId: ev.TxID, NPlus_1: ev.NPlus1, + SlowQuery: ev.SlowQuery, NormalizedQuery: sanitizeUTF8(ev.NormalizedQuery), } } diff --git a/tui/list.go b/tui/list.go index 6e70d5e..ae8f2c8 100644 --- a/tui/list.go +++ b/tui/list.go @@ -20,6 +20,10 @@ func eventStatus(ev *tapv1.QueryEvent) string { return lipgloss.NewStyle(). Foreground(lipgloss.Color("3")).Render("N+1") } + if ev.GetSlowQuery() { + return lipgloss.NewStyle(). + Foreground(lipgloss.Color("5")).Render("SLOW") + } return "" } diff --git a/tui/model.go b/tui/model.go index caa0994..8e14648 100644 --- a/tui/model.go +++ b/tui/model.go @@ -173,12 +173,16 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case eventMsg: m.events = append(m.events, msg.Event) - if msg.Event.GetNPlus_1() { + if msg.Event.GetNPlus_1() || msg.Event.GetSlowQuery() { q := msg.Event.GetQuery() if len(q) > 60 { q = q[:57] + "..." } - m, alertCmd := m.showAlert("N+1 detected: " + q) + label := "N+1 detected: " + if msg.Event.GetSlowQuery() && !msg.Event.GetNPlus_1() { + label = "Slow query: " + } + m, alertCmd := m.showAlert(label + q) if m.view != viewList { return m, tea.Batch(alertCmd, recvEvent(m.stream)) } diff --git a/web/static/app.js b/web/static/app.js index 383dd0a..ce70f2f 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -200,15 +200,16 @@ function renderTable() { const fragment = document.createDocumentFragment(); for (const {ev, idx} of filtered) { const tr = document.createElement('tr'); - tr.className = 'row' + (idx === selectedIdx ? ' selected' : '') + (ev.error ? ' has-error' : '') + (ev.n_plus_1 ? ' n-plus-1' : ''); + tr.className = 'row' + (idx === selectedIdx ? ' selected' : '') + (ev.error ? ' has-error' : '') + (ev.n_plus_1 ? ' n-plus-1' : '') + (ev.slow_query ? ' slow-query' : ''); tr.dataset.idx = idx; tr.onclick = () => selectRow(idx); + const status = ev.error ? 'E' : ev.n_plus_1 ? 'N+1' : ev.slow_query ? 'SLOW' : ''; tr.innerHTML = `${escapeHTML(fmtTime(ev.start_time))}` + `${escapeHTML(ev.op)}` + `${highlightSQL(ev.query)}` + `${escapeHTML(fmtDur(ev.duration_ms))}` + - `${ev.error ? 'E' : ev.n_plus_1 ? 'N+1' : ''}`; + `${status}`; fragment.appendChild(tr); } tbody.replaceChildren(fragment); @@ -453,6 +454,8 @@ function connectSSE() { events.push(ev); if (ev.n_plus_1) { showToast('N+1 detected: ' + (ev.query || '').substring(0, 80)); + } else if (ev.slow_query) { + showToast('Slow query: ' + (ev.query || '').substring(0, 80)); } render(); }; diff --git a/web/static/style.css b/web/static/style.css index d5f6450..1dd9ccf 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -90,6 +90,7 @@ tr.row:hover { background: #2a2d2e; } tr.row.selected { background: #094771; } tr.row.has-error td { color: #f44747; } tr.row.n-plus-1 td { color: #e5c07b; } +tr.row.slow-query td { color: #c678dd; } .col-time { width: 110px; } .col-op { width: 80px; } @@ -179,9 +180,10 @@ tr.row.n-plus-1 td { color: #e5c07b; } .sql-num { color: #b5cea8; } .sql-param { color: #9cdcfe; } -/* Disable highlight colors in error / N+1 rows to keep monochrome look */ +/* Disable highlight colors in error / N+1 / slow rows to keep monochrome look */ tr.row.has-error .col-query span, -tr.row.n-plus-1 .col-query span { color: inherit; } +tr.row.n-plus-1 .col-query span, +tr.row.slow-query .col-query span { color: inherit; } #toast { position: fixed; diff --git a/web/web.go b/web/web.go index a46acdb..0606a5b 100644 --- a/web/web.go +++ b/web/web.go @@ -79,6 +79,7 @@ type eventJSON struct { Error string `json:"error,omitempty"` TxID string `json:"tx_id,omitempty"` NPlus1 bool `json:"n_plus_1,omitempty"` + SlowQuery bool `json:"slow_query,omitempty"` NormalizedQuery string `json:"normalized_query,omitempty"` } @@ -96,6 +97,7 @@ func eventToJSON(ev proxy.Event) eventJSON { Error: ev.Error, TxID: ev.TxID, NPlus1: ev.NPlus1, + SlowQuery: ev.SlowQuery, NormalizedQuery: ev.NormalizedQuery, } }