Skip to content

Commit ecfc5d8

Browse files
authored
feat(api): add request logging and slow query detector. Make total counts optional by default on query APIs (#143)
1 parent 59e95e5 commit ecfc5d8

10 files changed

Lines changed: 312 additions & 71 deletions

File tree

docs/api.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,17 @@ Available only when [indexing](#indexing) is enabled.
353353
| `GET` | `/index/query/runs` | Query indexed runs with PostgREST-style filtering, sorting, and pagination |
354354
| `GET` | `/index/query/test_stats` | Query test stat data with PostgREST-style filtering, sorting, and pagination |
355355
| `GET` | `/index/query/test_stats_block_logs` | Query per-block log data with PostgREST-style filtering, sorting, and pagination |
356+
| `GET` | `/index/query/suites` | Query suite data with PostgREST-style filtering, sorting, and pagination |
357+
358+
#### Row count
359+
360+
By default, query endpoints skip the `SELECT count(*)` for performance — the `total` field is omitted from the response. To request an exact row count, send the `Prefer: count=exact` header:
361+
362+
```
363+
Prefer: count=exact
364+
```
365+
366+
When present, the response includes `"total": <n>`. This follows the [PostgREST](https://docs.postgrest.org/en/stable/references/api/preferences.html#exact-count) convention. On large tables (e.g. `test_stats`) the count query can take several seconds, so only request it when needed (e.g. for pagination totals).
356367

357368
### Files (requires authentication unless `anonymous_read` is enabled)
358369

pkg/api/gormlogger/gormlogger.go

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
package gormlogger
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"strings"
8+
"time"
9+
10+
"github.com/sirupsen/logrus"
11+
"gorm.io/gorm"
12+
gormlogger "gorm.io/gorm/logger"
13+
)
14+
15+
const maxSQLLength = 500
16+
17+
// Logger adapts logrus to GORM's logger.Interface, surfacing slow queries
18+
// and errors while keeping normal queries silent.
19+
type Logger struct {
20+
log logrus.FieldLogger
21+
slowThreshold time.Duration
22+
}
23+
24+
// Compile-time interface check.
25+
var _ gormlogger.Interface = (*Logger)(nil)
26+
27+
// New creates a Logger that warns on queries slower than slowThreshold.
28+
func New(log logrus.FieldLogger, slowThreshold time.Duration) *Logger {
29+
return &Logger{
30+
log: log,
31+
slowThreshold: slowThreshold,
32+
}
33+
}
34+
35+
// LogMode is a no-op; log level is controlled by logrus configuration.
36+
func (l *Logger) LogMode(_ gormlogger.LogLevel) gormlogger.Interface {
37+
return l
38+
}
39+
40+
// Info is a no-op; informational GORM messages are not surfaced.
41+
func (l *Logger) Info(_ context.Context, _ string, _ ...any) {}
42+
43+
// Warn forwards GORM warnings to logrus.
44+
func (l *Logger) Warn(_ context.Context, msg string, args ...any) {
45+
l.log.Warnf(msg, args...)
46+
}
47+
48+
// Error forwards GORM errors to logrus.
49+
func (l *Logger) Error(_ context.Context, msg string, args ...any) {
50+
l.log.Errorf(msg, args...)
51+
}
52+
53+
// Trace is called by GORM after every query. It logs errors and slow
54+
// queries while keeping normal operations silent.
55+
func (l *Logger) Trace(
56+
_ context.Context,
57+
begin time.Time,
58+
fc func() (sql string, rows int64),
59+
err error,
60+
) {
61+
duration := time.Since(begin)
62+
sql, rows := fc()
63+
sql = truncateSQL(sql)
64+
65+
if err != nil {
66+
if errors.Is(err, gorm.ErrRecordNotFound) {
67+
return
68+
}
69+
70+
if isContextCanceled(err) {
71+
l.log.WithField("duration", duration).
72+
WithField("canceled", true).
73+
WithField("sql", sql).
74+
Warn("Query canceled")
75+
76+
return
77+
}
78+
79+
l.log.WithError(err).
80+
WithField("duration", duration).
81+
WithField("rows", rows).
82+
WithField("sql", sql).
83+
Error("Query error")
84+
85+
return
86+
}
87+
88+
if l.slowThreshold > 0 && duration >= l.slowThreshold {
89+
l.log.WithField("duration", duration).
90+
WithField("rows", rows).
91+
WithField("sql", sql).
92+
Warn(fmt.Sprintf("Slow query (>= %s)", l.slowThreshold))
93+
}
94+
}
95+
96+
// isContextCanceled checks whether err is caused by context cancellation
97+
// or deadline exceeded. It uses errors.Is first, then falls back to
98+
// string matching for SQLite driver compatibility.
99+
func isContextCanceled(err error) bool {
100+
if errors.Is(err, context.Canceled) ||
101+
errors.Is(err, context.DeadlineExceeded) {
102+
return true
103+
}
104+
105+
msg := err.Error()
106+
107+
return strings.Contains(msg, "context canceled") ||
108+
strings.Contains(msg, "context deadline exceeded")
109+
}
110+
111+
// truncateSQL shortens a SQL string to maxSQLLength to prevent log bloat.
112+
func truncateSQL(sql string) string {
113+
if len(sql) > maxSQLLength {
114+
return sql[:maxSQLLength] + "..."
115+
}
116+
117+
return sql
118+
}

pkg/api/handlers_index.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"encoding/json"
55
"net/http"
66
"sort"
7+
"strings"
78
"time"
89

910
"github.com/ethpandaops/benchmarkoor/pkg/api/indexstore"
@@ -185,6 +186,10 @@ func (s *server) handleQueryRuns(w http.ResponseWriter, r *http.Request) {
185186
return
186187
}
187188

189+
params.CountExact = strings.Contains(
190+
r.Header.Get("Prefer"), "count=exact",
191+
)
192+
188193
result, err := s.indexStore.QueryRuns(r.Context(), params)
189194
if err != nil {
190195
writeJSON(w, http.StatusInternalServerError,
@@ -211,6 +216,10 @@ func (s *server) handleQueryTestStats(
211216
return
212217
}
213218

219+
params.CountExact = strings.Contains(
220+
r.Header.Get("Prefer"), "count=exact",
221+
)
222+
214223
result, err := s.indexStore.QueryTestStats(r.Context(), params)
215224
if err != nil {
216225
writeJSON(w, http.StatusInternalServerError,
@@ -237,6 +246,10 @@ func (s *server) handleQuerySuites(
237246
return
238247
}
239248

249+
params.CountExact = strings.Contains(
250+
r.Header.Get("Prefer"), "count=exact",
251+
)
252+
240253
result, err := s.indexStore.QuerySuites(r.Context(), params)
241254
if err != nil {
242255
writeJSON(w, http.StatusInternalServerError,
@@ -263,6 +276,10 @@ func (s *server) handleQueryTestStatsBlockLogs(
263276
return
264277
}
265278

279+
params.CountExact = strings.Contains(
280+
r.Header.Get("Prefer"), "count=exact",
281+
)
282+
266283
result, err := s.indexStore.QueryTestStatsBlockLogs(
267284
r.Context(), params,
268285
)

pkg/api/indexstore/indexstore.go

Lines changed: 54 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,12 @@ import (
66
"strings"
77
"time"
88

9+
"github.com/ethpandaops/benchmarkoor/pkg/api/gormlogger"
910
"github.com/ethpandaops/benchmarkoor/pkg/config"
1011
"github.com/glebarez/sqlite"
1112
"github.com/sirupsen/logrus"
1213
"gorm.io/driver/postgres"
1314
"gorm.io/gorm"
14-
"gorm.io/gorm/logger"
1515
)
1616

1717
// Store provides persistence for the indexed benchmark data.
@@ -91,7 +91,7 @@ func NewStore(
9191
// Start opens the database connection and runs migrations.
9292
func (s *store) Start(ctx context.Context) error {
9393
gormCfg := &gorm.Config{
94-
Logger: logger.Discard,
94+
Logger: gormlogger.New(s.log, 500*time.Millisecond),
9595
}
9696

9797
switch s.cfg.Driver {
@@ -628,9 +628,15 @@ func (s *store) QueryRuns(
628628
return scanMaps(q, params)
629629
}
630630

631-
var total int64
632-
if err := q.Count(&total).Error; err != nil {
633-
return nil, fmt.Errorf("counting runs: %w", err)
631+
var totalPtr *int64
632+
633+
if params.CountExact {
634+
var total int64
635+
if err := q.Count(&total).Error; err != nil {
636+
return nil, fmt.Errorf("counting runs: %w", err)
637+
}
638+
639+
totalPtr = &total
634640
}
635641

636642
var runs []Run
@@ -647,7 +653,7 @@ func (s *store) QueryRuns(
647653

648654
return &QueryResult{
649655
Data: data,
650-
Total: total,
656+
Total: totalPtr,
651657
Limit: params.Limit,
652658
Offset: params.Offset,
653659
}, nil
@@ -669,9 +675,15 @@ func (s *store) QueryTestStats(
669675
return scanMaps(q, params)
670676
}
671677

672-
var total int64
673-
if err := q.Count(&total).Error; err != nil {
674-
return nil, fmt.Errorf("counting test stats: %w", err)
678+
var totalPtr *int64
679+
680+
if params.CountExact {
681+
var total int64
682+
if err := q.Count(&total).Error; err != nil {
683+
return nil, fmt.Errorf("counting test stats: %w", err)
684+
}
685+
686+
totalPtr = &total
675687
}
676688

677689
var stats []TestStat
@@ -688,7 +700,7 @@ func (s *store) QueryTestStats(
688700

689701
return &QueryResult{
690702
Data: data,
691-
Total: total,
703+
Total: totalPtr,
692704
Limit: params.Limit,
693705
Offset: params.Offset,
694706
}, nil
@@ -710,11 +722,17 @@ func (s *store) QueryTestStatsBlockLogs(
710722
return scanMaps(q, params)
711723
}
712724

713-
var total int64
714-
if err := q.Count(&total).Error; err != nil {
715-
return nil, fmt.Errorf(
716-
"counting test stats block logs: %w", err,
717-
)
725+
var totalPtr *int64
726+
727+
if params.CountExact {
728+
var total int64
729+
if err := q.Count(&total).Error; err != nil {
730+
return nil, fmt.Errorf(
731+
"counting test stats block logs: %w", err,
732+
)
733+
}
734+
735+
totalPtr = &total
718736
}
719737

720738
var logs []TestStatsBlockLog
@@ -733,7 +751,7 @@ func (s *store) QueryTestStatsBlockLogs(
733751

734752
return &QueryResult{
735753
Data: data,
736-
Total: total,
754+
Total: totalPtr,
737755
Limit: params.Limit,
738756
Offset: params.Offset,
739757
}, nil
@@ -753,9 +771,15 @@ func (s *store) QuerySuites(
753771
return scanMaps(q, params)
754772
}
755773

756-
var total int64
757-
if err := q.Count(&total).Error; err != nil {
758-
return nil, fmt.Errorf("counting suites: %w", err)
774+
var totalPtr *int64
775+
776+
if params.CountExact {
777+
var total int64
778+
if err := q.Count(&total).Error; err != nil {
779+
return nil, fmt.Errorf("counting suites: %w", err)
780+
}
781+
782+
totalPtr = &total
759783
}
760784

761785
var suites []Suite
@@ -772,7 +796,7 @@ func (s *store) QuerySuites(
772796

773797
return &QueryResult{
774798
Data: data,
775-
Total: total,
799+
Total: totalPtr,
776800
Limit: params.Limit,
777801
Offset: params.Offset,
778802
}, nil
@@ -845,9 +869,15 @@ func isSQLiteTransient(err error) bool {
845869
func scanMaps(
846870
q *gorm.DB, params *QueryParams,
847871
) (*QueryResult, error) {
848-
var total int64
849-
if err := q.Count(&total).Error; err != nil {
850-
return nil, fmt.Errorf("counting rows: %w", err)
872+
var totalPtr *int64
873+
874+
if params.CountExact {
875+
var total int64
876+
if err := q.Count(&total).Error; err != nil {
877+
return nil, fmt.Errorf("counting rows: %w", err)
878+
}
879+
880+
totalPtr = &total
851881
}
852882

853883
var rows []map[string]any
@@ -859,7 +889,7 @@ func scanMaps(
859889

860890
return &QueryResult{
861891
Data: rows,
862-
Total: total,
892+
Total: totalPtr,
863893
Limit: params.Limit,
864894
Offset: params.Offset,
865895
}, nil

pkg/api/indexstore/query.go

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -160,19 +160,20 @@ type Order struct {
160160

161161
// QueryParams holds the validated, parsed query parameters.
162162
type QueryParams struct {
163-
Filters []Filter
164-
Orders []Order
165-
Limit int
166-
Offset int
167-
Select []string
163+
Filters []Filter
164+
Orders []Order
165+
Limit int
166+
Offset int
167+
Select []string
168+
CountExact bool
168169
}
169170

170171
// QueryResult wraps the paginated response.
171172
type QueryResult struct {
172-
Data any `json:"data"`
173-
Total int64 `json:"total"`
174-
Limit int `json:"limit"`
175-
Offset int `json:"offset"`
173+
Data any `json:"data"`
174+
Total *int64 `json:"total,omitempty"`
175+
Limit int `json:"limit"`
176+
Offset int `json:"offset"`
176177
}
177178

178179
// RunResponse is the JSON DTO for a runs row.

0 commit comments

Comments
 (0)