Skip to content

Commit

Permalink
A/B testing - UI (#924)
Browse files Browse the repository at this point in the history
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. 
<img width="1155" alt="Screenshot 2024-10-31 at 15 59 24"
src="https://github.com/user-attachments/assets/dbe6f6c9-35e0-48d2-a726-d45d858b09ff">
2. We got some help either.
<img width="1151" alt="Screenshot 2024-10-31 at 15 59 59"
src="https://github.com/user-attachments/assets/b3388887-cf05-483e-860c-aef16a0fbf42">
3. You can change sorting here:
<img width="1142" alt="Screenshot 2024-10-31 at 15 59 51"
src="https://github.com/user-attachments/assets/8352b102-c928-40d6-ab86-a0fe025ceced">
4. You'll see a list of differences on the panel if you click the
'Details' button.
<img width="1139" alt="Screenshot 2024-10-31 at 16 00 13"
src="https://github.com/user-attachments/assets/44d88e5d-fc8f-4461-b4a7-c846d19fb9d8">
5. Click on the 'Requests' button and get a list of requests matching
the difference:
<img width="855" alt="Screenshot 2024-10-31 at 16 00 33"
src="https://github.com/user-attachments/assets/8832429c-ea70-4797-9ab8-e6bce9dcac20">
6. Click on the Request ID and you'll get the request details: 
<img width="1235" alt="Screenshot 2024-10-31 at 16 00 41"
src="https://github.com/user-attachments/assets/1366bdad-0fba-4c38-957d-0fb02ac0ea39">
7. Both results:
<img width="1249" alt="Screenshot 2024-10-31 at 16 00 53"
src="https://github.com/user-attachments/assets/5dc115ea-31ef-409d-9cd4-7c59e25979f7">
8. And the list of differences:
<img width="1223" alt="Screenshot 2024-10-31 at 16 01 01"
src="https://github.com/user-attachments/assets/5c82be2f-4a9c-486d-9a45-116c0d2fe753">
  • Loading branch information
nablaone authored Nov 6, 2024
1 parent c5d5ebf commit d6efff5
Show file tree
Hide file tree
Showing 11 changed files with 1,155 additions and 28 deletions.
4 changes: 3 additions & 1 deletion quesma/ab_testing/collector/collector.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down
22 changes: 16 additions & 6 deletions quesma/ab_testing/collector/diff.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
package collector

import (
"crypto/sha1"
"encoding/json"
"fmt"
"quesma/jsondiff"
Expand Down Expand Up @@ -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)

Expand All @@ -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 {
Expand Down
13 changes: 10 additions & 3 deletions quesma/ab_testing/collector/processors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
4 changes: 4 additions & 0 deletions quesma/clickhouse/clickhouse.go
Original file line number Diff line number Diff line change
Expand Up @@ -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. */

Expand Down
44 changes: 29 additions & 15 deletions quesma/jsondiff/jsondiff.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package jsondiff
import (
"fmt"
"math"
"time"

"quesma/quesma/types"
"reflect"
Expand Down Expand Up @@ -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) {

Expand All @@ -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
Expand Down Expand Up @@ -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))
}

Expand Down
22 changes: 22 additions & 0 deletions quesma/jsondiff/jsondiff_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package jsondiff

import (
"fmt"
"strings"

"github.com/k0kubun/pp"

Expand Down Expand Up @@ -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)
Expand Down
5 changes: 3 additions & 2 deletions quesma/quesma/search_ab_testing.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
}

Expand All @@ -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)
}
Expand Down
Loading

0 comments on commit d6efff5

Please sign in to comment.