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) {
-

Database Query

+
+

Database Query

+ if query.InterpolatedQuery != nil { + + } +
{ query.Query }
+ if query.InterpolatedQuery != nil { + + }
if len(query.Args) > 0 { @@ -485,11 +497,28 @@ templ DBQueryDetails(event *collector.Event, query collector.DBQuery) {
} diff --git a/dashboard/views/event-details_templ.go b/dashboard/views/event-details_templ.go index b0569ba..74707e6 100644 --- a/dashboard/views/event-details_templ.go +++ b/dashboard/views/event-details_templ.go @@ -1054,150 +1054,201 @@ func DBQueryDetails(event *collector.Event, query collector.DBQuery) templ.Compo templ_7745c5c3_Var54 = templ.NopComponent } ctx = templ.ClearChildren(ctx) - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "

Database Query

")
+		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "

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.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 } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "
") + if query.InterpolatedQuery != nil { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "
")
+			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, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if len(query.Args) > 0 { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "

Arguments

") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "

Arguments

") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } for _, arg := range query.Args { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var56 string - templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(arg.Ordinal) + var templ_7745c5c3_Var59 string + templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.JoinStringErrs(arg.Ordinal) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard/views/event-details.templ`, Line: 453, Col: 62} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard/views/event-details.templ`, Line: 465, Col: 62} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var59)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var57 string - templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprint(arg.Value)) + var templ_7745c5c3_Var60 string + templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprint(arg.Value)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard/views/event-details.templ`, Line: 454, Col: 65} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard/views/event-details.templ`, Line: 466, Col: 65} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var57)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var60)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "

Details

Duration
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "

Details

Duration
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var58 string - templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.2fms", float64(query.Duration.Microseconds())/1000)) + var templ_7745c5c3_Var61 string + templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%.2fms", float64(query.Duration.Microseconds())/1000)) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard/views/event-details.templ`, Line: 465, Col: 88} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard/views/event-details.templ`, Line: 477, Col: 88} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var58)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var61)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "
Timestamp
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "
Timestamp
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var59 string - templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.JoinStringErrs(query.Timestamp.Format("2006-01-02 15:04:05.000")) + var templ_7745c5c3_Var62 string + templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.JoinStringErrs(query.Timestamp.Format("2006-01-02 15:04:05.000")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard/views/event-details.templ`, Line: 468, Col: 71} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard/views/event-details.templ`, Line: 480, Col: 71} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var59)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var62)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if query.Language != "" { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "
Language
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "
Language
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - var templ_7745c5c3_Var60 string - templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.JoinStringErrs(query.Language) + var templ_7745c5c3_Var63 string + templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.JoinStringErrs(query.Language) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard/views/event-details.templ`, Line: 472, Col: 40} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `dashboard/views/event-details.templ`, Line: 484, Col: 40} } - _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var60)) + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var63)) if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } if query.Error != nil { - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "

Error

")
+			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "

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, "
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } } - templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, " || \"sql\";\n\n var queryContent = document.getElementById('db-query');\n queryContent.textContent = sqlFormatter.format(queryContent.textContent, {\n language: language,\n });\n\n var copyBtn = document.querySelector('[data-copy-query]');\n var interpolated = document.getElementById('db-query-interpolated');\n if (copyBtn && interpolated) {\n copyBtn.addEventListener('click', function() {\n var sql = interpolated.textContent;\n try {\n sql = sqlFormatter.format(sql, { language: language });\n } catch (e) { /* fall back to the unformatted statement */ }\n navigator.clipboard.writeText(sql).then(function() {\n var original = copyBtn.textContent;\n copyBtn.textContent = 'Copied!';\n setTimeout(function() { copyBtn.textContent = original; }, 1500);\n });\n });\n }\n })();\n ") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err }