From d6efff5777f958cb84742922a0b5e5056c5c76e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rafa=C5=82=20Strzali=C5=84ski?= Date: Wed, 6 Nov 2024 12:49:28 +0100 Subject: [PATCH] A/B testing - UI (#924) This PR adds the new UI panel to our console. It has two major functionalities: 1. Show the report about Quesma's readiness to deploy on the PROD. This is for our PO and clients 2. Allow to examine which panel doesn't work and why. Both functionalities rely on A/B testing results stored in the `ab_testing_logs` table. 1. The first screen is the report. Screenshot 2024-10-31 at 15 59 24 2. We got some help either. Screenshot 2024-10-31 at 15 59 59 3. You can change sorting here: Screenshot 2024-10-31 at 15 59 51 4. You'll see a list of differences on the panel if you click the 'Details' button. Screenshot 2024-10-31 at 16 00 13 5. Click on the 'Requests' button and get a list of requests matching the difference: Screenshot 2024-10-31 at 16 00 33 6. Click on the Request ID and you'll get the request details: Screenshot 2024-10-31 at 16 00 41 7. Both results: Screenshot 2024-10-31 at 16 00 53 8. And the list of differences: Screenshot 2024-10-31 at 16 01 01 --- quesma/ab_testing/collector/collector.go | 4 +- quesma/ab_testing/collector/diff.go | 22 +- quesma/ab_testing/collector/processors.go | 13 +- quesma/clickhouse/clickhouse.go | 4 + quesma/jsondiff/jsondiff.go | 44 +- quesma/jsondiff/jsondiff_test.go | 22 + quesma/quesma/search_ab_testing.go | 5 +- quesma/quesma/ui/ab_testing.go | 948 ++++++++++++++++++++++ quesma/quesma/ui/asset/head.html | 57 ++ quesma/quesma/ui/console_routes.go | 52 ++ quesma/quesma/ui/html_utils.go | 12 +- 11 files changed, 1155 insertions(+), 28 deletions(-) create mode 100644 quesma/quesma/ui/ab_testing.go diff --git a/quesma/ab_testing/collector/collector.go b/quesma/ab_testing/collector/collector.go index e75cbdf08..989e1d734 100644 --- a/quesma/ab_testing/collector/collector.go +++ b/quesma/ab_testing/collector/collector.go @@ -17,6 +17,7 @@ type ResponseMismatch struct { Mismatches string `json:"mismatches"` // JSON array of differences Message string `json:"message"` // human readable variant of the array above + SHA1 string `json:"sha1"` // SHA1 of the differences Count int `json:"count"` // number of differences TopMismatchType string `json:"top_mismatch_type"` // most common difference type @@ -36,7 +37,8 @@ type EnrichedResults struct { QuesmaBuildHash string `json:"quesma_hash"` Errors []string `json:"errors,omitempty"` - KibanaDashboardId string `json:"kibana_dashboard_id,omitempty"` + KibanaDashboardId string `json:"kibana_dashboard_id,omitempty"` + KibanaDashboardPanelId string `json:"kibana_dashboard_panel_id,omitempty"` } type pipelineProcessor interface { diff --git a/quesma/ab_testing/collector/diff.go b/quesma/ab_testing/collector/diff.go index 612d83b61..a2d909630 100644 --- a/quesma/ab_testing/collector/diff.go +++ b/quesma/ab_testing/collector/diff.go @@ -3,6 +3,7 @@ package collector import ( + "crypto/sha1" "encoding/json" "fmt" "quesma/jsondiff" @@ -67,6 +68,15 @@ func (t *diffTransformer) process(in EnrichedResults) (out EnrichedResults, drop if len(mismatches) > 0 { + b, err := json.Marshal(mismatches) + + if err != nil { + return in, false, fmt.Errorf("failed to marshal mismatches: %w", err) + } + + in.Mismatch.Mismatches = string(b) + hash := sha1.Sum(b) + in.Mismatch.SHA1 = fmt.Sprintf("%x", hash) in.Mismatch.IsOK = false in.Mismatch.Count = len(mismatches) @@ -75,20 +85,20 @@ func (t *diffTransformer) process(in EnrichedResults) (out EnrichedResults, drop in.Mismatch.TopMismatchType = topMismatchType } + size := len(mismatches) + // if there are too many mismatches, we only show the first 20 // this is to avoid overwhelming the user with too much information const mismatchesSize = 20 if len(mismatches) > mismatchesSize { mismatches = mismatches[:mismatchesSize] + mismatches = append(mismatches, jsondiff.JSONMismatch{ + Type: "info", + Message: fmt.Sprintf("only first %d mismatches, total %d", mismatchesSize, size), + }) } - b, err := json.MarshalIndent(mismatches, "", " ") - - if err != nil { - return in, false, fmt.Errorf("failed to marshal mismatches: %w", err) - } - in.Mismatch.Mismatches = string(b) in.Mismatch.Message = mismatches.String() } else { diff --git a/quesma/ab_testing/collector/processors.go b/quesma/ab_testing/collector/processors.go index 3a84603a8..275f98a94 100644 --- a/quesma/ab_testing/collector/processors.go +++ b/quesma/ab_testing/collector/processors.go @@ -68,25 +68,32 @@ func (t *extractKibanaIds) name() string { } var opaqueIdKibanaDashboardIdRegexp = regexp.MustCompile(`dashboards:([0-9a-f-]+)`) +var opaqueIdKibanaPanelIdRegexp = regexp.MustCompile(`dashboard:dashboards:.*;.*:.*:([0-9a-f-]+)`) func (t *extractKibanaIds) process(in EnrichedResults) (out EnrichedResults, drop bool, err error) { opaqueId := in.OpaqueID - // TODO maybe we should extract panel id as well + in.KibanaDashboardId = "n/a" + in.KibanaDashboardPanelId = "n/a" if opaqueId == "" { - in.KibanaDashboardId = "n/a" return in, false, nil } matches := opaqueIdKibanaDashboardIdRegexp.FindStringSubmatch(opaqueId) if len(matches) < 2 { - in.KibanaDashboardId = "n/a" return in, false, nil } in.KibanaDashboardId = matches[1] + + panelsMatches := opaqueIdKibanaPanelIdRegexp.FindStringSubmatch(opaqueId) + if len(panelsMatches) < 2 { + return in, false, nil + } + in.KibanaDashboardPanelId = panelsMatches[1] + return in, false, nil } diff --git a/quesma/clickhouse/clickhouse.go b/quesma/clickhouse/clickhouse.go index b1e916407..5d32fe765 100644 --- a/quesma/clickhouse/clickhouse.go +++ b/quesma/clickhouse/clickhouse.go @@ -215,6 +215,10 @@ func (lm *LogManager) executeRawQuery(query string) (*sql.Rows, error) { } } +func (lm *LogManager) GetDB() *sql.DB { + return lm.chDb +} + /* The logic below contains a simple checks that are executed by connectors to ensure that they are not connected to the data sources which are not allowed by current license. */ diff --git a/quesma/jsondiff/jsondiff.go b/quesma/jsondiff/jsondiff.go index 1ce9160ca..e85a50791 100644 --- a/quesma/jsondiff/jsondiff.go +++ b/quesma/jsondiff/jsondiff.go @@ -5,6 +5,7 @@ package jsondiff import ( "fmt" "math" + "time" "quesma/quesma/types" "reflect" @@ -357,7 +358,28 @@ func (d *JSONDiff) asType(a any) string { return fmt.Sprintf("%T", a) } -var dateRx = regexp.MustCompile(`\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}`) +var dateRx = regexp.MustCompile(`\d{4}-\d{2}-\d{2}.\d{2}:\d{2}:`) + +func (d *JSONDiff) uniformTimeFormat(date string) string { + returnFormat := "2006-01-02T15:04:05.000Z" + + inputFormats := []string{ + "2006-01-02T15:04:05.000+02:00", + "2006-01-02T15:04:05.000Z", + "2006-01-02T15:04:05.000", + "2006-01-02 15:04:05", + } + + var parsedDate time.Time + var err error + for _, format := range inputFormats { + parsedDate, err = time.Parse(format, date) + if err == nil { + return parsedDate.Format(returnFormat) + } + } + return date +} func (d *JSONDiff) compare(expected any, actual any) { @@ -379,12 +401,12 @@ func (d *JSONDiff) compare(expected any, actual any) { return } - switch aVal := expected.(type) { + switch expectedVal := expected.(type) { case map[string]any: switch bVal := actual.(type) { case map[string]any: - d.compareObject(aVal, bVal) + d.compareObject(expectedVal, bVal) default: d.addMismatch(invalidType, d.asType(expected), d.asType(actual)) return @@ -428,20 +450,12 @@ func (d *JSONDiff) compare(expected any, actual any) { switch actualString := actual.(type) { case string: - if dateRx.MatchString(aVal) && dateRx.MatchString(actualString) { - - // TODO add better date comparison here - // parse both date and compare them with desired precision - - // elastics returns date in formats - // "2024-10-24T00:00:00.000+02:00" - // "2024-10-24T00:00:00.000Z" + if dateRx.MatchString(expectedVal) { - // quesma returns - // 2024-10-23T22:00:00.000 - compareOnly := "2000-01-" + aDate := d.uniformTimeFormat(expectedVal) + bDate := d.uniformTimeFormat(actualString) - if aVal[:len(compareOnly)] != actualString[:len(compareOnly)] { + if aDate != bDate { d.addMismatch(invalidDateValue, d.asValue(expected), d.asValue(actual)) } diff --git a/quesma/jsondiff/jsondiff_test.go b/quesma/jsondiff/jsondiff_test.go index ce8ddcb56..527ff9071 100644 --- a/quesma/jsondiff/jsondiff_test.go +++ b/quesma/jsondiff/jsondiff_test.go @@ -4,6 +4,7 @@ package jsondiff import ( "fmt" + "strings" "github.com/k0kubun/pp" @@ -118,11 +119,32 @@ func TestJSONDiff(t *testing.T) { actual: `{"bar": [5, 2, 4, 3, 1, -1], "b": 2, "c": 3}`, problems: []JSONMismatch{mismatch("bar", arrayKeysDifferenceSlightly)}, }, + { + name: "dates", + expected: `{"a": "2021-01-01T00:00:00.000Z"}`, + actual: `{"a": "2021-01-01T00:00:00.001Z"}`, + problems: []JSONMismatch{mismatch("a", invalidDateValue)}, + }, + { + name: "dates 2", + expected: `{"a": "2021-01-01T00:00:00.000Z"}`, + actual: `{"a": "2021-01-01T00:00:00.000"}`, + problems: []JSONMismatch{}, + }, + { + name: "SKIP dates 3", // TODO fix this, not sure how we handle TZ + expected: `{"a": "2024-10-24T10:00:00.000"}`, + actual: `{"a": "2024-10-24T12:00:00.000+02:00"}`, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + if strings.HasPrefix(tt.name, "SKIP") { + return + } + diff, err := NewJSONDiff("_ignore") if err != nil { t.Fatalf("Expected no error, got %v", err) diff --git a/quesma/quesma/search_ab_testing.go b/quesma/quesma/search_ab_testing.go index 5bec6d5df..cd272d0cf 100644 --- a/quesma/quesma/search_ab_testing.go +++ b/quesma/quesma/search_ab_testing.go @@ -15,6 +15,7 @@ import ( "quesma/model" "quesma/queryparser" "quesma/quesma/async_search_storage" + "quesma/quesma/config" "quesma/quesma/recovery" "quesma/quesma/types" "quesma/quesma/ui" @@ -128,7 +129,7 @@ func (q *QueryRunner) executeABTesting(ctx context.Context, plan *model.Executio case *table_resolver.ConnectorDecisionClickhouse: planExecutor = func(ctx context.Context) ([]byte, error) { - plan.Name = "clickhouse" + plan.Name = config.ClickhouseTarget return q.executePlan(ctx, plan, queryTranslator, table, body, optAsync, optComparePlansCh, isMainPlan) } @@ -139,7 +140,7 @@ func (q *QueryRunner) executeABTesting(ctx context.Context, plan *model.Executio QueryRowsTransformers: []model.QueryRowsTransformer{}, Queries: []*model.Query{}, StartTime: plan.StartTime, - Name: "elastic", + Name: config.ElasticsearchTarget, } return q.executePlanElastic(ctx, elasticPlan, body, optAsync, optComparePlansCh, isMainPlan) } diff --git a/quesma/quesma/ui/ab_testing.go b/quesma/quesma/ui/ab_testing.go new file mode 100644 index 000000000..14dea3839 --- /dev/null +++ b/quesma/quesma/ui/ab_testing.go @@ -0,0 +1,948 @@ +// Copyright Quesma, licensed under the Elastic License 2.0. +// SPDX-License-Identifier: Elastic-2.0 +package ui + +import ( + "context" + "encoding/json" + "fmt" + "io" + "quesma/elasticsearch" + "quesma/jsondiff" + "quesma/logger" + "quesma/quesma/ui/internal/builder" + "strings" + "time" +) + +const abTestingPath = "/ab-testing-dashboard" + +func (qmc *QuesmaManagementConsole) hasABTestingTable() bool { + + db := qmc.logManager.GetDB() + + sql := `SELECT count(*) FROM ab_testing_logs` + + row := db.QueryRow(sql) + var count int + err := row.Scan(&count) + if err != nil { + logger.Error().Err(err).Msg("Error checking for ab_testing_logs table") + return false + } + + return true +} + +func (qmc *QuesmaManagementConsole) renderError(buff *builder.HtmlBuffer, err error) { + + buff.Html(`
`) + buff.Html(`

Error

`) + buff.Html(`

`) + buff.Text(err.Error()) + buff.Html(`

`) + buff.Html(`
`) + +} + +func (qmc *QuesmaManagementConsole) generateABTestingDashboard() []byte { + + buffer := newBufferWithHead() + buffer.Write(qmc.generateTopNavigation("ab-testing-dashboard")) + + buffer.Html(`
`) + + explanation := ` +This table compares results and performance of Kibana dashboards and its panels as seen by Quesma. +Every panel query returning similar results is a success, +load times are calculated into performance gain as a percentage by comparing the average times of first and second backend connectors for successful responses. +If the performance gain is positive, it means that the second backend connector is faster than the first one. + ` + + buffer.Html(`

Kibana dashboards compatibility report

`) + + if qmc.hasABTestingTable() { + + buffer.Html(`
`) + buffer.Html(``) + buffer.Html(``) + buffer.Html(`
`) + buffer.Html(``) + buffer.Html(``) + buffer.Html(`
`) + buffer.Html(``) + buffer.Html(`
`) + buffer.Html(`
`) + + buffer.Html(`") + } else { + buffer.Html(`

A/B Testing results are not available.

`) + } + + buffer.Html("\n
\n\n") + return buffer.Bytes() +} + +type kibanaDashboard struct { + name string + panels map[string]string +} + +type resolvedDashboards struct { + dashboards map[string]kibanaDashboard +} + +func (d resolvedDashboards) dashboardName(dashboardId string) string { + if dashboard, ok := d.dashboards[dashboardId]; ok { + return dashboard.name + } + return dashboardId +} + +func (d resolvedDashboards) panelName(dashboardId, panelId string) string { + if dashboard, ok := d.dashboards[dashboardId]; ok { + if name, ok := dashboard.panels[panelId]; ok { + return name + } + } + return panelId +} + +func (qmc *QuesmaManagementConsole) readKibanaDashboards() (resolvedDashboards, error) { + + result := resolvedDashboards{ + dashboards: make(map[string]kibanaDashboard), + } + + elasticQuery := ` +{ + "_source": false, + "fields": [ + "_id", + "dashboard.title", + "panelsJSON", + "dashboard.panelsJSON" + ], + "query": { + "bool": { + "filter": [ + { + "term": { + "type": "dashboard" + } + } + ] + } + } +} +` + client := elasticsearch.NewSimpleClient(&qmc.cfg.Elasticsearch) + + resp, err := client.Request(context.Background(), "POST", ".kibana_analytics/_search", []byte(elasticQuery)) + if err != nil { + return result, err + } + + if resp.StatusCode != 200 { + return result, fmt.Errorf("unexpected HTTP status: %s", resp.Status) + } + + defer resp.Body.Close() + + data, err := io.ReadAll(resp.Body) + if err != nil { + return result, err + } + + type responseSchema struct { + Hits struct { + Hits []struct { + Fields struct { + Id []string `json:"_id"` + Title []string `json:"dashboard.title"` + Panels []string `json:"dashboard.panelsJSON"` + } `json:"fields"` + } `json:"hits"` + } `json:"hits"` + } + + type panelSchema struct { + Type string `json:"type"` + PanelID string `json:"panelIndex"` + Name string `json:"title"` + } + + var response responseSchema + err = json.Unmarshal(data, &response) + if err != nil { + return result, err + } + + for _, hit := range response.Hits.Hits { + if len(hit.Fields.Id) == 0 { + continue // no ID, skip + } + _id := hit.Fields.Id[0] + + var title string + if len(hit.Fields.Title) > 0 { + title = hit.Fields.Title[0] + } else { + title = _id + } + _id = strings.TrimPrefix(_id, "dashboard:") + + var panels string + if len(hit.Fields.Panels) > 0 { + panels = hit.Fields.Panels[0] + } else { + panels = "[]" // empty array, so we can unmarshal it + } + + var panelsJson []panelSchema + err := json.Unmarshal([]byte(panels), &panelsJson) + if err != nil { + return result, err + } + + dashboard := kibanaDashboard{ + name: title, + panels: make(map[string]string), + } + + for _, panel := range panelsJson { + if panel.Name == "" { + panel.Name = panel.PanelID + } + dashboard.panels[panel.PanelID] = panel.Name + } + result.dashboards[_id] = dashboard + } + + return result, nil +} + +func parseMismatches(mismatch string) ([]jsondiff.JSONMismatch, error) { + var mismatches []jsondiff.JSONMismatch + err := json.Unmarshal([]byte(mismatch), &mismatches) + return mismatches, err +} + +func formatJSON(in *string) string { + if in == nil { + return "n/a" + } + + m := make(map[string]interface{}) + + err := json.Unmarshal([]byte(*in), &m) + if err != nil { + return err.Error() + } + + b, err := json.MarshalIndent(m, "", " ") + if err != nil { + return err.Error() + } + return string(b) +} + +type abTestingReportRow struct { + dashboardId string + panelId string + dashboardUrl string + detailsUrl string + dashboardName string + panelName string + aName string + bName string + successRate *float64 + performanceGain *float64 + count int +} + +func (qmc *QuesmaManagementConsole) abTestingReadReport(kibanaUrl, orderBy string) ([]abTestingReportRow, error) { + + kibanaDashboards, err := qmc.readKibanaDashboards() + if err != nil { + logger.Warn().Msgf("Error reading dashboards %v", err) + } + + orderByToSQL := map[string]string{ + "default": "dashboard_id, panel_id, a_name, b_name", + "response_similarity": "response_similarity DESC, dashboard_id, panel_id, a_name, b_name", + "performance_gain": "performance_gain DESC,dashboard_id, panel_id, a_name, b_name", + "count": "count DESC,dashboard_id, panel_id, a_name, b_name", + } + + orderBySQL, ok := orderByToSQL[orderBy] + if !ok { + orderBySQL = orderByToSQL["default"] + } + + sql := ` +WITH subresults AS ( +SELECT + kibana_dashboard_id , + kibana_dashboard_panel_id, + response_a_name AS a_name, + response_b_name AS b_name, + response_mismatch_is_ok AS ok , + count(*) AS c, + avg(response_a_time) AS a_time, + avg(response_b_time) AS b_time +FROM + ab_testing_logs GROUP BY 1,2,3,4,5 +) + +SELECT + kibana_dashboard_id AS dashboard_id, + kibana_dashboard_panel_id AS panel_id, + a_name, + b_name, + (sumIf(c,ok)/ sum(c)) * 100 as response_similarity, + ((avgIf(a_time,ok)- avgIf(b_time,ok))/avgIf(a_time,ok))*100.0 as performance_gain, + sum(c) as count +FROM + subresults +GROUP BY + kibana_dashboard_id,kibana_dashboard_panel_id,a_name,b_name +` + + sql = sql + " ORDER BY " + orderBySQL + + var result []abTestingReportRow + + db := qmc.logManager.GetDB() + rows, err := db.Query(sql, orderBySQL) + if err != nil { + return nil, err + } + + for rows.Next() { + row := abTestingReportRow{} + err := rows.Scan(&row.dashboardId, &row.panelId, &row.aName, &row.bName, &row.successRate, &row.performanceGain, &row.count) + if err != nil { + return nil, err + } + + row.dashboardUrl = fmt.Sprintf("%s/app/kibana#/dashboard/%s", kibanaUrl, row.dashboardId) + row.detailsUrl = fmt.Sprintf("%s/panel?dashboard_id=%s&panel_id=%s", abTestingPath, row.dashboardId, row.panelId) + row.dashboardName = kibanaDashboards.dashboardName(row.dashboardId) + row.panelName = kibanaDashboards.panelName(row.dashboardId, row.panelId) + + result = append(result, row) + } + + if rows.Err() != nil { + return nil, rows.Err() + } + + return result, nil +} + +func (qmc *QuesmaManagementConsole) generateABTestingReport(kibanaUrl, orderBy string) []byte { + buffer := newBufferWithHead() + + buffer.Html("\n") + buffer.Html("\n") + buffer.Html(`` + "\n") + buffer.Html(`` + "\n") + buffer.Html(`` + "\n") + buffer.Html(`` + "\n") + buffer.Html(`` + "\n") + buffer.Html(`` + "\n") + buffer.Html(`` + "\n") + buffer.Html("\n") + buffer.Html("\n") + buffer.Html("\n") + + rows, err := qmc.abTestingReadReport(kibanaUrl, orderBy) + if err != nil { + qmc.renderError(&buffer, err) + return buffer.Bytes() + } + + var lastDashboardId string + for _, row := range rows { + buffer.Html(`` + "\n") + + if lastDashboardId != row.dashboardId { + buffer.Html(``) + lastDashboardId = row.dashboardId + } else { + buffer.Html(``) + } + + buffer.Html(``) + + buffer.Html(``) + + buffer.Html(``) + + buffer.Html(``) + + buffer.Html("") + buffer.Html("") + } + + buffer.Html("\n") + buffer.Html("
DashboardPanelCount
(since start)
Response similarityPerformance gain
`) + buffer.Html(``).Text(row.dashboardName).Html(``) + buffer.Html("
") + buffer.Text(fmt.Sprintf("(%s vs %s)", row.aName, row.bName)) + buffer.Html(`
`) + buffer.Text(row.panelName) + buffer.Html(``) + buffer.Text(fmt.Sprintf("%d", row.count)) + buffer.Html(``) + if row.successRate != nil { + buffer.Text(fmt.Sprintf("%.01f%%", *row.successRate)) + } else { + buffer.Text("n/a") + } + buffer.Html(``) + if row.performanceGain != nil { + buffer.Text(fmt.Sprintf("%.01f%%", *row.performanceGain)) + } else { + buffer.Text("n/a") + } + buffer.Html(`") + + buffer.Html(``) + buffer.Text("Details") + buffer.Html(``) + + buffer.Html("
\n") + + return buffer.Bytes() +} + +type abTestingPanelDetailsRow struct { + mismatch string + mismatchId string + count int +} + +func (qmc *QuesmaManagementConsole) abTestingReadPanelDetails(dashboardId, panelId string) ([]abTestingPanelDetailsRow, error) { + + sql := ` + select response_mismatch_mismatches, response_mismatch_sha1, count() as c + from ab_testing_logs + where kibana_dashboard_id = ? and + kibana_dashboard_panel_id = ? and + response_mismatch_is_ok = false + group by 1,2 + order by c desc + limit 100 +` + db := qmc.logManager.GetDB() + + rows, err := db.Query(sql, dashboardId, panelId) + if err != nil { + return nil, err + } + + var result []abTestingPanelDetailsRow + for rows.Next() { + + var mismatch string + var count int + var mismatchId string + + err := rows.Scan(&mismatch, &mismatchId, &count) + if err != nil { + return nil, err + } + + r := abTestingPanelDetailsRow{ + mismatch: mismatch, + mismatchId: mismatchId, + count: count, + } + result = append(result, r) + } + + if rows.Err() != nil { + return nil, rows.Err() + } + + return result, nil +} + +func (qmc *QuesmaManagementConsole) renderABTestingMismatch(buffer *builder.HtmlBuffer, mismatch jsondiff.JSONMismatch) { + + buffer.Html(`
  • `) + buffer.Html(`

    `) + buffer.Text(mismatch.Message) + buffer.Text(" ") + + if mismatch.Path != "" { + buffer.Html(``) + buffer.Text(`(`) + buffer.Text(mismatch.Path) + buffer.Text(`)`) + buffer.Html(``) + { // poor man's HTML indent + buffer.Html(`

    `) + } + } + buffer.Html(`

    `) + buffer.Html(`
  • `) + +} + +func (qmc *QuesmaManagementConsole) generateABPanelDetails(dashboardId, panelId string) []byte { + buffer := newBufferWithHead() + + dashboards, err := qmc.readKibanaDashboards() + dashboardName := dashboardId + panelName := panelId + + if err == nil { + dashboardName = dashboards.dashboardName(dashboardId) + panelName = dashboards.panelName(dashboardId, panelId) + } else { + logger.Warn().Err(err).Msgf("Error reading dashboards %v", err) + } + + buffer.Html(`
    `) + + buffer.Html(`

    A/B Testing - Panel Details

    `) + buffer.Html(`

    `) + buffer.Text(fmt.Sprintf("Dashboard: %s", dashboardName)) + buffer.Html(`

    `) + buffer.Html(`

    `) + buffer.Text(fmt.Sprintf("Panel: %s", panelName)) + buffer.Html(`

    `) + + rows, err := qmc.abTestingReadPanelDetails(dashboardId, panelId) + if err != nil { + qmc.renderError(&buffer, err) + return buffer.Bytes() + } + + if len(rows) > 0 { + buffer.Html("") + buffer.Html("") + buffer.Html(``) + buffer.Html(``) + buffer.Html(``) + buffer.Html(``) + buffer.Html("") + + buffer.Html("\n") + buffer.Html("\n") + + for _, row := range rows { + buffer.Html(``) + buffer.Html(``) + + buffer.Html(``) + + buffer.Html("") + + buffer.Html("\n") + } + + buffer.Html("\n") + buffer.Html("
    MismatchCount
    `) + + mismatches, err := parseMismatches(row.mismatch) + if err == nil { + const limit = 10 + size := len(mismatches) + if size > limit { + mismatches = mismatches[:limit] + mismatches = append(mismatches, jsondiff.JSONMismatch{ + Message: fmt.Sprintf("... and %d more", size-limit), + }) + } + + buffer.Html(`
      `) + for _, m := range mismatches { + qmc.renderABTestingMismatch(&buffer, m) + } + buffer.Html(`
    `) + + } else { + buffer.Text(row.mismatch) + } + buffer.Html(`
    `) + buffer.Text(fmt.Sprintf("%d", row.count)) + buffer.Html(`") + buffer.Html(``).Text("Requests").Html(``) + buffer.Html("
    \n") + buffer.Html("\n
    \n\n") + } else { + buffer.Html(`

    No mismatches found

    `) + + } + + return buffer.Bytes() +} + +type abTestingMismatchDetailsRow struct { + timestamp string + requestId string + requestPath string + opaqueId string +} + +func (qmc *QuesmaManagementConsole) abTestingReadMismatchDetails(dashboardId, panelId, mismatchHash string) ([]abTestingMismatchDetailsRow, error) { + + sql := ` + select "@timestamp", request_id, request_path, opaque_id + from ab_testing_logs + where + kibana_dashboard_id = ? and + kibana_dashboard_panel_id = ? and + response_mismatch_sha1 = ? + + order by 1 desc + limit 100 +` + + db := qmc.logManager.GetDB() + + rows, err := db.Query(sql, dashboardId, panelId, mismatchHash) + if err != nil { + return nil, err + } + + var result []abTestingMismatchDetailsRow + for rows.Next() { + + row := abTestingMismatchDetailsRow{} + err := rows.Scan(&row.timestamp, &row.requestId, &row.requestPath, &row.opaqueId) + if err != nil { + return nil, err + } + result = append(result, row) + + } + if rows.Err() != nil { + return nil, rows.Err() + } + return result, nil +} + +func (qmc *QuesmaManagementConsole) generateABMismatchDetails(dashboardId, panelId, mismatchHash string) []byte { + buffer := newBufferWithHead() + + dashboards, err := qmc.readKibanaDashboards() + dashboardName := dashboardId + panelName := panelId + + if err == nil { + dashboardName = dashboards.dashboardName(dashboardId) + panelName = dashboards.panelName(dashboardId, panelId) + } else { + logger.Warn().Err(err).Msgf("Error reading dashboards %v", err) + } + + buffer.Html(`
    `) + + buffer.Html(`

    A/B Testing - Panel requests

    `) + + buffer.Html(`

    `) + buffer.Text(fmt.Sprintf("Dashboard: %s", dashboardName)) + buffer.Html(`

    `) + buffer.Html(`

    `) + buffer.Text(fmt.Sprintf("Panel: %s", panelName)) + buffer.Html(`

    `) + + buffer.Html("") + buffer.Html("") + buffer.Html(``) + buffer.Html(``) + buffer.Html(``) + buffer.Html(``) + buffer.Html(``) + buffer.Html("") + buffer.Html("") + + buffer.Html("") + + rows, err := qmc.abTestingReadMismatchDetails(dashboardId, panelId, mismatchHash) + if err != nil { + qmc.renderError(&buffer, err) + return buffer.Bytes() + } + + for _, row := range rows { + + buffer.Html(``) + buffer.Html(``) + + buffer.Html(``) + + buffer.Html(``) + + buffer.Html(``) + + buffer.Html("\n") + } + + buffer.Html("\n") + buffer.Html("
    TimestampRequest IDRequest PathOpaque ID
    `) + buffer.Text(row.timestamp) + buffer.Html(``) + buffer.Html(``).Text(row.requestId).Html(``) + + buffer.Html(``) + buffer.Text(row.requestPath) + buffer.Html(``) + buffer.Text(row.opaqueId) + buffer.Html(`
    \n") + + buffer.Html(`
    `) + return buffer.Bytes() +} + +type abTestingTableRow struct { + requestID *string + requestPath *string + requestIndexName *string + requestBody *string + responseBTime *float64 + responseBError *string + responseBName *string + responseBBody *string + quesmaHash *string + kibanaDashboardID *string + opaqueID *string + responseABody *string + responseATime *float64 + responseAError *string + responseAName *string + timestamp time.Time + responseMismatchSHA1 *string + responseMismatchCount *int64 + responseMismatchTopType *string + responseMismatchIsOK *bool + responseMismatchMismatches *string + responseMismatchMessage *string + quesmaVersion *string + kibanaDashboardPanelID *string +} + +func (qmc *QuesmaManagementConsole) abTestingReadRow(requestId string) (abTestingTableRow, error) { + sql := `SELECT + request_id, request_path, request_index_name, + request_body, response_b_time, response_b_error, response_b_name, response_b_body, + quesma_hash, kibana_dashboard_id, opaque_id, response_a_body, response_a_time, + response_a_error, response_a_name, "@timestamp", response_mismatch_sha1, + response_mismatch_count, response_mismatch_top_mismatch_type, response_mismatch_is_ok, + response_mismatch_mismatches, response_mismatch_message, quesma_version, + kibana_dashboard_panel_id + FROM ab_testing_logs + WHERE request_id = ?` + + db := qmc.logManager.GetDB() + + row := db.QueryRow(sql, requestId) + + rec := abTestingTableRow{} + err := row.Scan( + &rec.requestID, &rec.requestPath, &rec.requestIndexName, + &rec.requestBody, &rec.responseBTime, &rec.responseBError, &rec.responseBName, &rec.responseBBody, + &rec.quesmaHash, &rec.kibanaDashboardID, &rec.opaqueID, &rec.responseABody, &rec.responseATime, + &rec.responseAError, &rec.responseAName, &rec.timestamp, &rec.responseMismatchSHA1, + &rec.responseMismatchCount, &rec.responseMismatchTopType, &rec.responseMismatchIsOK, + &rec.responseMismatchMismatches, &rec.responseMismatchMessage, &rec.quesmaVersion, + &rec.kibanaDashboardPanelID) + + if err != nil { + return rec, err + } + + if row.Err() != nil { + return rec, row.Err() + } + + return rec, nil +} + +func (qmc *QuesmaManagementConsole) generateABSingleRequest(requestId string) []byte { + buffer := newBufferWithHead() + buffer.Html(`
    `) + + buffer.Html(`

    A/B Testing - Request Results

    `) + + fmtAny := func(value any) string { + if value == nil { + return "n/a" + } + + switch v := value.(type) { + case *string: + return *v + case *float64: + return fmt.Sprintf("%f", *v) + case *int64: + return fmt.Sprintf("%d", *v) + case *bool: + return fmt.Sprintf("%t", *v) + default: + return fmt.Sprintf("%s", value) + } + } + + tableRow := func(label string, value any, pre bool) { + + buffer.Html(``) + buffer.Html(``) + buffer.Text(label) + buffer.Html(``) + buffer.Html(``) + if pre { + buffer.Html(`
    `)
    +		}
    +		buffer.Text(fmtAny(value))
    +		if pre {
    +			buffer.Html(`
    `) + } + buffer.Html(``) + buffer.Html("\n") + + } + + var dashboardName string + var panelName string + + dashboards, err := qmc.readKibanaDashboards() + if err != nil { + logger.Warn().Err(err).Msgf("Error reading dashboards %v", err) + } + + row, err := qmc.abTestingReadRow(requestId) + + if err == nil { + + if row.kibanaDashboardID != nil { + + dashboardName = dashboards.dashboardName(*row.kibanaDashboardID) + if row.kibanaDashboardPanelID != nil { + panelName = dashboards.panelName(*row.kibanaDashboardID, *row.kibanaDashboardPanelID) + } + } + } else { + logger.Warn().Err(err).Msgf("Error reading dashboards %v", err) + } + + buffer.Html(``) + tableRow("Request ID", row.requestID, true) + tableRow("Timestamp", row.timestamp, true) + tableRow("Kibana Dashboard ID", dashboardName, false) + tableRow("Kibana Dashboard Panel ID", panelName, false) + tableRow("Opaque ID", row.opaqueID, true) + tableRow("Quesma Hash", row.quesmaHash, true) + tableRow("Quesma Version", row.quesmaVersion, true) + tableRow("Request Path", row.requestPath, true) + tableRow("Request Index Name", row.requestIndexName, false) + tableRow("Request Body", formatJSON(row.requestBody), true) + buffer.Html(`
    `) + + rowAB := func(label string, valueA any, valueB any, pre bool) { + buffer.Html(``) + buffer.Html(``) + buffer.Text(label) + buffer.Html(``) + buffer.Html(``) + if pre { + buffer.Html(`
    `)
    +		}
    +		buffer.Text(fmtAny(valueA))
    +		if pre {
    +			buffer.Html(`
    `) + } + buffer.Html(``) + buffer.Html(``) + if pre { + buffer.Html(`
    `)
    +		}
    +		buffer.Text(fmtAny(valueB))
    +		if pre {
    +			buffer.Html(`
    `) + } + buffer.Html(``) + buffer.Html("\n") + } + + buffer.Html(`

    Response A vs Response B

    `) + buffer.Html(``) + buffer.Html(``) + buffer.Html(``) + buffer.Html(``) + buffer.Html(``) + buffer.Html("") + + rowAB("Name", row.responseAName, row.responseBName, false) + rowAB("Time", row.responseATime, row.responseBTime, false) + rowAB("Error", row.responseAError, row.responseBError, true) + rowAB("Response Body", formatJSON(row.responseABody), formatJSON(row.responseBBody), true) + buffer.Html(`
    LabelResponse AResponse B
    `) + + buffer.Html(`

    Difference

    `) + if row.responseMismatchSHA1 != nil { + mismaches, err := parseMismatches(*row.responseMismatchMismatches) + if err != nil { + buffer.Text(fmt.Sprintf("Error: %s", err)) + } else { + buffer.Html(``) + buffer.Html(``) + buffer.Html(``) + buffer.Html(``) + buffer.Html(``) + buffer.Html(``) + buffer.Html("") + + for _, m := range mismaches { + buffer.Html(``) + buffer.Html(``) + buffer.Html(``) + buffer.Html(``) + buffer.Html(``) + buffer.Html("") + } + } + } + + buffer.Html(``) + return buffer.Bytes() +} diff --git a/quesma/quesma/ui/asset/head.html b/quesma/quesma/ui/asset/head.html index 070341ad7..d3ad6c3c9 100644 --- a/quesma/quesma/ui/asset/head.html +++ b/quesma/quesma/ui/asset/head.html @@ -563,6 +563,63 @@ font-size: small; } + #ab_testing_dashboard table { + border-collapse: collapse; + table-layout: fixed; + //width: 98%; + word-wrap: break-word; + font-size: small; + } + + #ab_testing_dashboard table th { + border: solid 1px black; + } + + #ab_testing_dashboard table td { + border: solid 1px black; + overflow-x: auto; + vertical-align: top; + } + + /* Tooltip container */ + .tooltip { + position: relative; + cursor: pointer; + color: #0056b3; /* Optional: make it look like a link */ + text-decoration: none; + font-size: small; + /* Optional: underline to indicate it's interactive */ + } + + /* Tooltip text */ + .tooltip::after { + content: attr(data-tooltip); /* Get tooltip text from data attribute */ + position: absolute; + top: 125%; /* Position below the span */ + left: 50%; + transform: translateX(-20%); + background-color: #eee; + color: black; + padding: 1em; + border-radius: 5px; + width: 40em; + max-width: 50em; /* Set maximum width */ + white-space: wrap; /* Allow text to wrap */ + opacity: 0; + visibility: hidden; + transition: opacity 0.2s; + z-index: 10; + pointer-events: none; + text-align: left; /* Center-align text for readability */ + } + + /* Show the tooltip on hover */ + .tooltip:hover::after { + opacity: 1; + visibility: visible; + } + + #quesma_all_logs table { border-collapse: collapse; table-layout: fixed; diff --git a/quesma/quesma/ui/console_routes.go b/quesma/quesma/ui/console_routes.go index a88ef1410..8b730ea8f 100644 --- a/quesma/quesma/ui/console_routes.go +++ b/quesma/quesma/ui/console_routes.go @@ -107,6 +107,58 @@ func (qmc *QuesmaManagementConsole) createRouting() *mux.Router { _, _ = writer.Write(buf) }) + checkIfAbAvailable := func(writer http.ResponseWriter, req *http.Request) bool { + if qmc.hasABTestingTable() { + return true + } + + _, _ = writer.Write([]byte("AB Testing results are not available.")) + return false + } + + authenticatedRoutes.HandleFunc(abTestingPath, func(writer http.ResponseWriter, req *http.Request) { + buf := qmc.generateABTestingDashboard() + _, _ = writer.Write(buf) + }) + + authenticatedRoutes.HandleFunc(abTestingPath+"/report", func(writer http.ResponseWriter, req *http.Request) { + if checkIfAbAvailable(writer, req) { + kibanaUrl := req.PostFormValue("kibana_url") + orderBy := req.PostFormValue("order_by") + buf := qmc.generateABTestingReport(kibanaUrl, orderBy) + _, _ = writer.Write(buf) + } + }) + + authenticatedRoutes.HandleFunc(abTestingPath+"/panel", func(writer http.ResponseWriter, req *http.Request) { + if checkIfAbAvailable(writer, req) { + dashboardId := req.FormValue("dashboard_id") + panelId := req.FormValue("panel_id") + + buf := qmc.generateABPanelDetails(dashboardId, panelId) + _, _ = writer.Write(buf) + } + }) + + authenticatedRoutes.HandleFunc(abTestingPath+"/mismatch", func(writer http.ResponseWriter, req *http.Request) { + if checkIfAbAvailable(writer, req) { + dashboardId := req.FormValue("dashboard_id") + panelId := req.FormValue("panel_id") + mismatchId := req.FormValue("mismatch_id") + + buf := qmc.generateABMismatchDetails(dashboardId, panelId, mismatchId) + _, _ = writer.Write(buf) + } + }) + + authenticatedRoutes.HandleFunc(abTestingPath+"/request", func(writer http.ResponseWriter, req *http.Request) { + if checkIfAbAvailable(writer, req) { + requestId := req.FormValue("request_id") + buf := qmc.generateABSingleRequest(requestId) + _, _ = writer.Write(buf) + } + }) + authenticatedRoutes.HandleFunc("/tables/reload", func(writer http.ResponseWriter, req *http.Request) { qmc.logManager.ReloadTables() diff --git a/quesma/quesma/ui/html_utils.go b/quesma/quesma/ui/html_utils.go index 3419d43ef..56a21f496 100644 --- a/quesma/quesma/ui/html_utils.go +++ b/quesma/quesma/ui/html_utils.go @@ -77,6 +77,16 @@ func (qmc *QuesmaManagementConsole) generateTopNavigation(target string) []byte } buffer.Html(`>Data sources`) + buffer.Html("A/B`) + + buffer.Html(`
  • Logout
  • `) + if qmc.isAuthEnabled { buffer.Html(`
  • Logout
  • `) } @@ -84,7 +94,7 @@ func (qmc *QuesmaManagementConsole) generateTopNavigation(target string) []byte buffer.Html("\n\n") buffer.Html("\n\n") - if target != "tables" && target != "telemetry" && target != "table_resolver" { + if target != "tables" && target != "telemetry" && target != "table_resolver" && target != "ab-testing-dashboard" { buffer.Html(`
    ` + "\n") buffer.Html(`
    `) buffer.Html(fmt.Sprintf(
    MessagePathActualExpected
    `) + buffer.Text(m.Message) + buffer.Html(``) + buffer.Text(m.Path) + buffer.Html(``) + buffer.Text(m.Actual) + buffer.Html(``) + buffer.Text(m.Expected) + buffer.Html(`