diff --git a/acceptance/dashboard_page.go b/acceptance/dashboard_page.go index 791b335..d19ea4d 100644 --- a/acceptance/dashboard_page.go +++ b/acceptance/dashboard_page.go @@ -250,6 +250,43 @@ func (dp *DashboardPage) ClickFirstEvent() { dp.WaitForEventDetails(5000) } +// GrantClipboardPermissions allows the page to read from and write to the clipboard, +// which is required for asserting on copy-to-clipboard interactions. +func (dp *DashboardPage) GrantClipboardPermissions() { + dp.t.Helper() + + err := dp.Page.Context().GrantPermissions([]string{"clipboard-read", "clipboard-write"}) + require.NoError(dp.t, err, "failed to grant clipboard permissions") +} + +// ClickCopyQueryButton clicks the "Copy with values" button in the DB query details. +func (dp *DashboardPage) ClickCopyQueryButton() { + dp.t.Helper() + + copyButton := dp.Page.Locator("button:has-text('Copy with values')") + err := copyButton.WaitFor(playwright.LocatorWaitForOptions{ + State: playwright.WaitForSelectorStateVisible, + Timeout: playwright.Float(5000), + }) + require.NoError(dp.t, err, "failed to wait for copy query button") + + err = copyButton.Click() + require.NoError(dp.t, err, "failed to click copy query button") +} + +// GetClipboardText returns the current clipboard contents. +// GrantClipboardPermissions must have been called first. +func (dp *DashboardPage) GetClipboardText() string { + dp.t.Helper() + + clipboard, err := dp.Page.Evaluate("() => navigator.clipboard.readText()") + require.NoError(dp.t, err, "failed to read clipboard") + + text, ok := clipboard.(string) + require.True(dp.t, ok, "clipboard content should be a string") + return text +} + // ClickFirstChildEvent clicks on the first child event (nested inside a parent event). func (dp *DashboardPage) ClickFirstChildEvent() { dp.t.Helper() diff --git a/acceptance/event_types_test.go b/acceptance/event_types_test.go index ffaf70a..c012eac 100644 --- a/acceptance/event_types_test.go +++ b/acceptance/event_types_test.go @@ -152,6 +152,26 @@ func TestDBQueryEvent(t *testing.T) { assert.Contains(t, text, "/api/db") }) }) + + t.Run("copies query with interpolated values to clipboard", func(t *testing.T) { + t.Parallel() + WithTestFixtures(t, func(t *testing.T, f *TestFixtures) { + f.Dashboard.GrantClipboardPermissions() + + f.Dashboard.StartCapture("global") + f.Dashboard.FetchAPI("/api/db") + f.Dashboard.WaitForEventCount(1, 5000) + + // The DB query is captured as a child of the HTTP server event. + f.Dashboard.ClickFirstChildEvent() + + f.Dashboard.ClickCopyQueryButton() + + clipboardText := f.Dashboard.GetClipboardText() + assert.Contains(t, clipboardText, "'1'", "clipboard should contain the substituted argument value") + assert.NotContains(t, clipboardText, "$1", "clipboard should not contain the unresolved placeholder") + }) + }) } // TestLogEvent tests structured logging (slog) event capture. diff --git a/acceptance/testapp.go b/acceptance/testapp.go index 67147ba..22db742 100644 --- a/acceptance/testapp.go +++ b/acceptance/testapp.go @@ -92,11 +92,13 @@ func NewTestApp(t *testing.T) *TestApp { // Test endpoint: simulates DB query (DB query event) mux.HandleFunc("GET /api/db", func(w http.ResponseWriter, r *http.Request) { // Simulate a DB query event + interpolatedQuery := "SELECT * FROM users WHERE id = '1'" collectDBQuery(r.Context(), collector.DBQuery{ - Query: "SELECT * FROM users WHERE id = $1", - Args: []driver.NamedValue{{Ordinal: 1, Value: 1}}, - Duration: 5 * time.Millisecond, - Language: "postgresql", + Query: "SELECT * FROM users WHERE id = $1", + Args: []driver.NamedValue{{Ordinal: 1, Value: 1}}, + InterpolatedQuery: &interpolatedQuery, + Duration: 5 * time.Millisecond, + Language: "postgresql", }) w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) diff --git a/collector/db_query_collector.go b/collector/db_query_collector.go index 76ae6f9..48d13aa 100644 --- a/collector/db_query_collector.go +++ b/collector/db_query_collector.go @@ -14,6 +14,10 @@ type DBQuery struct { Query string // Args of the query or statement Args []driver.NamedValue + // InterpolatedQuery is an optional standalone SQL statement with the Args + // already substituted into the placeholders, ready to be pasted into a + // database UI. + InterpolatedQuery *string // Duration of executing the query or statement Duration time.Duration // Timestamp when the query or statement was started @@ -28,6 +32,9 @@ type DBQuery struct { func (q DBQuery) Size() uint64 { size := uint64(100) // base struct overhead size += uint64(len(q.Query)) + if q.InterpolatedQuery != nil { + size += uint64(len(*q.InterpolatedQuery)) + } size += uint64(len(q.Language)) // Calculate actual size of arguments using reflection for _, arg := range q.Args { diff --git a/dashboard/views/event-details.templ b/dashboard/views/event-details.templ index d839c5e..c3db58d 100644 --- a/dashboard/views/event-details.templ +++ b/dashboard/views/event-details.templ @@ -439,10 +439,22 @@ templ LogRecordDetails(event *collector.Event, record slog.Record) { templ DBQueryDetails(event *collector.Event, query collector.DBQuery) {
{ query.Query }
{ *query.InterpolatedQuery }
+ }
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "") + if query.InterpolatedQuery != nil { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "Database Query
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var55 string - templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(query.Query) + if query.InterpolatedQuery != nil { + var templ_7745c5c3_Var55 = []any{buttonClasses(ButtonProps{Variant: ButtonVariantOutline, Size: ButtonSizeSm})} + templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var55...) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "") if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard/views/event-details.templ`, Line: 444, Col: 86} + return templ_7745c5c3_Err } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55)) + var templ_7745c5c3_Var57 string + templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinStringErrs(query.Query) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard/views/event-details.templ`, Line: 453, Col: 86} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var57)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + var templ_7745c5c3_Var58 string + templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.JoinStringErrs(*query.InterpolatedQuery) + if templ_7745c5c3_Err != nil { + return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard/views/event-details.templ`, Line: 456, Col: 89} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var58)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "Error
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var61 string - templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.JoinStringErrs(query.Error.Error()) + var templ_7745c5c3_Var64 string + templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.JoinStringErrs(query.Error.Error()) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard/views/event-details.templ`, Line: 481, Col: 84} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard/views/event-details.templ`, Line: 493, Col: 84} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var61)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var64)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "