Skip to content

Commit 852f706

Browse files
committed
feat: add db query collection
1 parent fc0cb13 commit 852f706

File tree

19 files changed

+1026
-70
lines changed

19 files changed

+1026
-70
lines changed

README.md

Lines changed: 113 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,111 @@ http.ListenAndServe(":8080", handler)
142142

143143
### Capturing SQL Queries
144144

145-
> Work in progress.
145+
Devlog can collect SQL queries executed through the standard `database/sql` package. This is done using the `go-sqllogger` adapter.
146+
147+
### Setup
148+
149+
1. First, create a devlog instance:
150+
151+
```go
152+
dlog := devlog.New()
153+
defer dlog.Close()
154+
```
155+
156+
2. Create a database connector with logging:
157+
158+
```go
159+
// Create your base connector (e.g., for SQLite)
160+
connector := newSQLiteConnector(":memory:")
161+
162+
// Wrap it with the logging connector
163+
loggingConnector := sqllogger.LoggingConnector(
164+
sqlloggeradapter.New(dlog.CollectDBQuery()),
165+
connector,
166+
)
167+
168+
// Open the database with the logging connector
169+
db := sql.OpenDB(loggingConnector)
170+
defer db.Close()
171+
```
172+
173+
### What Gets Collected
174+
175+
For each SQL query, the following information is collected:
176+
- The SQL query string
177+
- Query arguments
178+
- Execution duration
179+
- Timestamp
180+
181+
### Example
182+
183+
Here's a complete example showing how to use the SQL query collector:
184+
185+
```go
186+
package main
187+
188+
import (
189+
"database/sql"
190+
_ "github.com/mattn/go-sqlite3"
191+
"github.com/networkteam/go-sqllogger"
192+
sqlloggeradapter "github.com/networkteam/devlog/dbadapter/sqllogger"
193+
"github.com/networkteam/devlog"
194+
)
195+
196+
func main() {
197+
// Create devlog instance
198+
dlog := devlog.New()
199+
defer dlog.Close()
200+
201+
// Create database connector with logging
202+
connector := newSQLiteConnector(":memory:")
203+
loggingConnector := sqllogger.LoggingConnector(
204+
sqlloggeradapter.New(dlog.CollectDBQuery()),
205+
connector,
206+
)
207+
208+
// Open database
209+
db := sql.OpenDB(loggingConnector)
210+
defer db.Close()
211+
212+
// Execute queries - they will be automatically collected
213+
db.ExecContext(ctx, "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)")
214+
db.QueryContext(ctx, "SELECT * FROM users WHERE id = ?", 1)
215+
}
216+
```
217+
218+
### Using SQLite as a `driver.Connector`
219+
220+
```go
221+
// sqliteConnector is a simple implementation of driver.Connector for SQLite
222+
type sqliteConnector struct {
223+
driver *sqlite3.SQLiteDriver
224+
dsn string
225+
}
226+
227+
func newSQLiteConnector(dsn string) *sqliteConnector {
228+
sqliteDriver := &sqlite3.SQLiteDriver{}
229+
return &sqliteConnector{
230+
driver: sqliteDriver,
231+
dsn: dsn,
232+
}
233+
}
234+
235+
// Connect implements driver.Connector interface
236+
func (c *sqliteConnector) Connect(ctx context.Context) (driver.Conn, error) {
237+
return c.driver.Open(c.dsn)
238+
}
239+
240+
// Driver implements driver.Connector interface
241+
func (c *sqliteConnector) Driver() driver.Driver {
242+
return c.driver
243+
}
244+
```
245+
246+
The collected queries will be visible in the devlog dashboard, showing:
247+
- The SQL query (truncated in the list view, full query in details)
248+
- Query arguments
249+
- Execution duration in milliseconds
146250

147251
### Configuring the Dashboard
148252

@@ -159,7 +263,15 @@ dashboard := devlog.NewWithOptions(devlog.Options{
159263

160264
## TODOs
161265

266+
- [ ] Add support for generic events/groups that can be used in user-code
267+
- [ ] Support plugins (e.g. for GraphQL) to add attributes to HTTP requests (operation name)
268+
- [ ] Implement on-demand activation of devlog (record / stop)
269+
- [ ] Change display of time or implement timers via JS
270+
- [ ] Implement reset of collected events
271+
- [ ] Add pretty printing of JSON
272+
- [ ] Implement ad-hoc change of log level via slog.Leveler via UI
162273
- [ ] Implement filtering of events
274+
- [x] Implement SQL query logging with adapters
163275

164276
## License
165277

collector/db_query_collector.go

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
package collector
2+
3+
import (
4+
"context"
5+
"database/sql/driver"
6+
"time"
7+
)
8+
9+
// DBQuery represents a database query execution record
10+
type DBQuery struct {
11+
// Query is the SQL query or statement
12+
Query string
13+
// Args of the query or statement
14+
Args []driver.NamedValue
15+
// Duration of executing the query or statement
16+
Duration time.Duration
17+
// Timestamp when the query or statement was started
18+
Timestamp time.Time
19+
// Error if any error occured
20+
Error error
21+
}
22+
23+
type DBQueryCollector struct {
24+
buffer *RingBuffer[DBQuery]
25+
notifier *Notifier[DBQuery]
26+
eventCollector *EventCollector
27+
}
28+
29+
func (c *DBQueryCollector) Collect(ctx context.Context, query DBQuery) {
30+
c.buffer.Add(query)
31+
c.notifier.Notify(query)
32+
if c.eventCollector != nil {
33+
c.eventCollector.CollectEvent(ctx, query)
34+
}
35+
}
36+
37+
func (c *DBQueryCollector) Tail(n int) []DBQuery {
38+
return c.buffer.GetRecords(uint64(n))
39+
}
40+
41+
// Subscribe returns a channel that receives notifications of new query records
42+
func (c *DBQueryCollector) Subscribe(ctx context.Context) <-chan DBQuery {
43+
return c.notifier.Subscribe(ctx)
44+
}
45+
46+
type DBQueryOptions struct {
47+
// NotifierOptions are options for notification about new queries
48+
NotifierOptions *NotifierOptions
49+
50+
// EventCollector is an optional event collector for collecting logs as grouped events
51+
EventCollector *EventCollector
52+
}
53+
54+
func DefaultDBQueryOptions() DBQueryOptions {
55+
return DBQueryOptions{}
56+
}
57+
58+
func NewDBQueryCollector(capacity uint64) *DBQueryCollector {
59+
return NewDBQueryCollectorWithOptions(capacity, DefaultDBQueryOptions())
60+
}
61+
62+
func NewDBQueryCollectorWithOptions(capacity uint64, options DBQueryOptions) *DBQueryCollector {
63+
notifierOptions := DefaultNotifierOptions()
64+
if options.NotifierOptions != nil {
65+
notifierOptions = *options.NotifierOptions
66+
}
67+
68+
return &DBQueryCollector{
69+
buffer: NewRingBuffer[DBQuery](capacity),
70+
notifier: NewNotifierWithOptions[DBQuery](notifierOptions),
71+
eventCollector: options.EventCollector,
72+
}
73+
}
74+
75+
// Close releases resources used by the collector
76+
func (c *DBQueryCollector) Close() {
77+
c.notifier.Close()
78+
}
File renamed without changes.

dashboard/handler.go

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,21 +15,15 @@ import (
1515
)
1616

1717
type Handler struct {
18-
logCollector *collector.LogCollector
19-
httpClientCollector *collector.HTTPClientCollector
20-
httpServerCollector *collector.HTTPServerCollector
21-
eventCollector *collector.EventCollector
18+
eventCollector *collector.EventCollector
2219

2320
pathPrefix string
2421

2522
mux http.Handler
2623
}
2724

2825
type HandlerOptions struct {
29-
LogCollector *collector.LogCollector
30-
HTTPClientCollector *collector.HTTPClientCollector
31-
HTTPServerCollector *collector.HTTPServerCollector
32-
EventCollector *collector.EventCollector
26+
EventCollector *collector.EventCollector
3327

3428
// PathPrefix where the Handler is mounted (e.g. "/_devlog"), can be left empty if the Handler is at the root ("/").
3529
PathPrefix string
@@ -38,10 +32,7 @@ type HandlerOptions struct {
3832
func NewHandler(options HandlerOptions) *Handler {
3933
mux := http.NewServeMux()
4034
handler := &Handler{
41-
logCollector: options.LogCollector,
42-
httpClientCollector: options.HTTPClientCollector,
43-
httpServerCollector: options.HTTPServerCollector,
44-
eventCollector: options.EventCollector,
35+
eventCollector: options.EventCollector,
4536

4637
pathPrefix: options.PathPrefix,
4738

dashboard/static/main.css

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@
77
'Noto Color Emoji';
88
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
99
monospace;
10+
--color-red-50: oklch(97.1% 0.013 17.38);
1011
--color-red-500: oklch(63.7% 0.237 25.331);
1112
--color-red-600: oklch(57.7% 0.245 27.325);
13+
--color-red-700: oklch(50.5% 0.213 27.518);
1214
--color-orange-400: oklch(75% 0.183 55.934);
1315
--color-orange-600: oklch(64.6% 0.222 41.116);
1416
--color-green-600: oklch(62.7% 0.194 149.214);
@@ -193,6 +195,9 @@
193195
}
194196
}
195197
@layer utilities {
198+
.visible {
199+
visibility: visible;
200+
}
196201
.sr-only {
197202
position: absolute;
198203
width: 1px;
@@ -273,6 +278,9 @@
273278
.inline-flex {
274279
display: inline-flex;
275280
}
281+
.table {
282+
display: table;
283+
}
276284
.h-4 {
277285
height: calc(var(--spacing) * 4);
278286
}
@@ -312,6 +320,9 @@
312320
.cursor-pointer {
313321
cursor: pointer;
314322
}
323+
.grid-cols-2 {
324+
grid-template-columns: repeat(2, minmax(0, 1fr));
325+
}
315326
.grid-cols-\[24rem_1fr\] {
316327
grid-template-columns: 24rem 1fr;
317328
}
@@ -429,6 +440,9 @@
429440
.bg-orange-400 {
430441
background-color: var(--color-orange-400);
431442
}
443+
.bg-red-50 {
444+
background-color: var(--color-red-50);
445+
}
432446
.bg-red-500 {
433447
background-color: var(--color-red-500);
434448
}
@@ -542,9 +556,15 @@
542556
.text-orange-600 {
543557
color: var(--color-orange-600);
544558
}
559+
.text-red-500 {
560+
color: var(--color-red-500);
561+
}
545562
.text-red-600 {
546563
color: var(--color-red-600);
547564
}
565+
.text-red-700 {
566+
color: var(--color-red-700);
567+
}
548568
.text-white {
549569
color: var(--color-white);
550570
}

dashboard/views/event-details.templ

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,8 @@ templ EventDetails(event *collector.Event) {
7171
@HTTPServerRequestDetails(event, data)
7272
case slog.Record:
7373
@LogRecordDetails(event, data)
74+
case collector.DBQuery:
75+
@DBQueryDetails(event, data)
7476
default:
7577
<div class="p-4">
7678
<div class="alert alert-warning">
@@ -429,6 +431,46 @@ templ LogRecordDetails(event *collector.Event, record slog.Record) {
429431
</div>
430432
}
431433

434+
// DB Query Details
435+
templ DBQueryDetails(event *collector.Event, query collector.DBQuery) {
436+
<div class="p-4">
437+
<div class="mb-4">
438+
<h3 class="text-lg font-semibold mb-2">Database Query</h3>
439+
<div class="bg-neutral-50 p-4 rounded">
440+
<pre class="whitespace-pre-wrap break-all">{ query.Query }</pre>
441+
</div>
442+
</div>
443+
444+
if len(query.Args) > 0 {
445+
<div class="mb-4">
446+
<h4 class="text-sm font-semibold mb-2">Arguments</h4>
447+
<div class="bg-neutral-50 p-4 rounded">
448+
<pre class="whitespace-pre-wrap break-all">{ fmt.Sprintf("%v", query.Args) }</pre>
449+
</div>
450+
</div>
451+
}
452+
453+
<div class="mb-4">
454+
<h4 class="text-sm font-semibold mb-2">Details</h4>
455+
<dl class="grid grid-cols-2 gap-2 text-sm">
456+
<dt class="text-neutral-500">Duration</dt>
457+
<dd>{ fmt.Sprintf("%.2fms", float64(query.Duration.Microseconds())/1000) }</dd>
458+
<dt class="text-neutral-500">Timestamp</dt>
459+
<dd>{ query.Timestamp.Format("2006-01-02 15:04:05.000") }</dd>
460+
</dl>
461+
</div>
462+
463+
if query.Error != nil {
464+
<div class="mb-4">
465+
<h4 class="text-sm font-semibold mb-2 text-red-500">Error</h4>
466+
<div class="bg-red-50 p-4 rounded text-red-700">
467+
<pre class="whitespace-pre-wrap break-all">{ query.Error.Error() }</pre>
468+
</div>
469+
</div>
470+
}
471+
</div>
472+
}
473+
432474
// Helper function to determine text color based on status code
433475
func statusCodeTextColor(code int) string {
434476
switch {
@@ -459,4 +501,4 @@ func formatDuration(d time.Duration) string {
459501
} else {
460502
return fmt.Sprintf("%.2fs", d.Seconds())
461503
}
462-
}
504+
}

0 commit comments

Comments
 (0)