Skip to content
This repository was archived by the owner on Nov 7, 2025. It is now read-only.

Commit d6efff5

Browse files
authored
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. <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">
1 parent c5d5ebf commit d6efff5

File tree

11 files changed

+1155
-28
lines changed

11 files changed

+1155
-28
lines changed

quesma/ab_testing/collector/collector.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ type ResponseMismatch struct {
1717

1818
Mismatches string `json:"mismatches"` // JSON array of differences
1919
Message string `json:"message"` // human readable variant of the array above
20+
SHA1 string `json:"sha1"` // SHA1 of the differences
2021

2122
Count int `json:"count"` // number of differences
2223
TopMismatchType string `json:"top_mismatch_type"` // most common difference type
@@ -36,7 +37,8 @@ type EnrichedResults struct {
3637
QuesmaBuildHash string `json:"quesma_hash"`
3738
Errors []string `json:"errors,omitempty"`
3839

39-
KibanaDashboardId string `json:"kibana_dashboard_id,omitempty"`
40+
KibanaDashboardId string `json:"kibana_dashboard_id,omitempty"`
41+
KibanaDashboardPanelId string `json:"kibana_dashboard_panel_id,omitempty"`
4042
}
4143

4244
type pipelineProcessor interface {

quesma/ab_testing/collector/diff.go

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
package collector
44

55
import (
6+
"crypto/sha1"
67
"encoding/json"
78
"fmt"
89
"quesma/jsondiff"
@@ -67,6 +68,15 @@ func (t *diffTransformer) process(in EnrichedResults) (out EnrichedResults, drop
6768

6869
if len(mismatches) > 0 {
6970

71+
b, err := json.Marshal(mismatches)
72+
73+
if err != nil {
74+
return in, false, fmt.Errorf("failed to marshal mismatches: %w", err)
75+
}
76+
77+
in.Mismatch.Mismatches = string(b)
78+
hash := sha1.Sum(b)
79+
in.Mismatch.SHA1 = fmt.Sprintf("%x", hash)
7080
in.Mismatch.IsOK = false
7181
in.Mismatch.Count = len(mismatches)
7282

@@ -75,20 +85,20 @@ func (t *diffTransformer) process(in EnrichedResults) (out EnrichedResults, drop
7585
in.Mismatch.TopMismatchType = topMismatchType
7686
}
7787

88+
size := len(mismatches)
89+
7890
// if there are too many mismatches, we only show the first 20
7991
// this is to avoid overwhelming the user with too much information
8092
const mismatchesSize = 20
8193

8294
if len(mismatches) > mismatchesSize {
8395
mismatches = mismatches[:mismatchesSize]
96+
mismatches = append(mismatches, jsondiff.JSONMismatch{
97+
Type: "info",
98+
Message: fmt.Sprintf("only first %d mismatches, total %d", mismatchesSize, size),
99+
})
84100
}
85101

86-
b, err := json.MarshalIndent(mismatches, "", " ")
87-
88-
if err != nil {
89-
return in, false, fmt.Errorf("failed to marshal mismatches: %w", err)
90-
}
91-
in.Mismatch.Mismatches = string(b)
92102
in.Mismatch.Message = mismatches.String()
93103

94104
} else {

quesma/ab_testing/collector/processors.go

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,25 +68,32 @@ func (t *extractKibanaIds) name() string {
6868
}
6969

7070
var opaqueIdKibanaDashboardIdRegexp = regexp.MustCompile(`dashboards:([0-9a-f-]+)`)
71+
var opaqueIdKibanaPanelIdRegexp = regexp.MustCompile(`dashboard:dashboards:.*;.*:.*:([0-9a-f-]+)`)
7172

7273
func (t *extractKibanaIds) process(in EnrichedResults) (out EnrichedResults, drop bool, err error) {
7374

7475
opaqueId := in.OpaqueID
7576

76-
// TODO maybe we should extract panel id as well
77+
in.KibanaDashboardId = "n/a"
78+
in.KibanaDashboardPanelId = "n/a"
7779

7880
if opaqueId == "" {
79-
in.KibanaDashboardId = "n/a"
8081
return in, false, nil
8182
}
8283

8384
matches := opaqueIdKibanaDashboardIdRegexp.FindStringSubmatch(opaqueId)
8485

8586
if len(matches) < 2 {
86-
in.KibanaDashboardId = "n/a"
8787
return in, false, nil
8888
}
8989

9090
in.KibanaDashboardId = matches[1]
91+
92+
panelsMatches := opaqueIdKibanaPanelIdRegexp.FindStringSubmatch(opaqueId)
93+
if len(panelsMatches) < 2 {
94+
return in, false, nil
95+
}
96+
in.KibanaDashboardPanelId = panelsMatches[1]
97+
9198
return in, false, nil
9299
}

quesma/clickhouse/clickhouse.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,10 @@ func (lm *LogManager) executeRawQuery(query string) (*sql.Rows, error) {
215215
}
216216
}
217217

218+
func (lm *LogManager) GetDB() *sql.DB {
219+
return lm.chDb
220+
}
221+
218222
/* The logic below contains a simple checks that are executed by connectors to ensure that they are
219223
not connected to the data sources which are not allowed by current license. */
220224

quesma/jsondiff/jsondiff.go

Lines changed: 29 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ package jsondiff
55
import (
66
"fmt"
77
"math"
8+
"time"
89

910
"quesma/quesma/types"
1011
"reflect"
@@ -357,7 +358,28 @@ func (d *JSONDiff) asType(a any) string {
357358
return fmt.Sprintf("%T", a)
358359
}
359360

360-
var dateRx = regexp.MustCompile(`\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}`)
361+
var dateRx = regexp.MustCompile(`\d{4}-\d{2}-\d{2}.\d{2}:\d{2}:`)
362+
363+
func (d *JSONDiff) uniformTimeFormat(date string) string {
364+
returnFormat := "2006-01-02T15:04:05.000Z"
365+
366+
inputFormats := []string{
367+
"2006-01-02T15:04:05.000+02:00",
368+
"2006-01-02T15:04:05.000Z",
369+
"2006-01-02T15:04:05.000",
370+
"2006-01-02 15:04:05",
371+
}
372+
373+
var parsedDate time.Time
374+
var err error
375+
for _, format := range inputFormats {
376+
parsedDate, err = time.Parse(format, date)
377+
if err == nil {
378+
return parsedDate.Format(returnFormat)
379+
}
380+
}
381+
return date
382+
}
361383

362384
func (d *JSONDiff) compare(expected any, actual any) {
363385

@@ -379,12 +401,12 @@ func (d *JSONDiff) compare(expected any, actual any) {
379401
return
380402
}
381403

382-
switch aVal := expected.(type) {
404+
switch expectedVal := expected.(type) {
383405
case map[string]any:
384406

385407
switch bVal := actual.(type) {
386408
case map[string]any:
387-
d.compareObject(aVal, bVal)
409+
d.compareObject(expectedVal, bVal)
388410
default:
389411
d.addMismatch(invalidType, d.asType(expected), d.asType(actual))
390412
return
@@ -428,20 +450,12 @@ func (d *JSONDiff) compare(expected any, actual any) {
428450
switch actualString := actual.(type) {
429451
case string:
430452

431-
if dateRx.MatchString(aVal) && dateRx.MatchString(actualString) {
432-
433-
// TODO add better date comparison here
434-
// parse both date and compare them with desired precision
435-
436-
// elastics returns date in formats
437-
// "2024-10-24T00:00:00.000+02:00"
438-
// "2024-10-24T00:00:00.000Z"
453+
if dateRx.MatchString(expectedVal) {
439454

440-
// quesma returns
441-
// 2024-10-23T22:00:00.000
442-
compareOnly := "2000-01-"
455+
aDate := d.uniformTimeFormat(expectedVal)
456+
bDate := d.uniformTimeFormat(actualString)
443457

444-
if aVal[:len(compareOnly)] != actualString[:len(compareOnly)] {
458+
if aDate != bDate {
445459
d.addMismatch(invalidDateValue, d.asValue(expected), d.asValue(actual))
446460
}
447461

quesma/jsondiff/jsondiff_test.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ package jsondiff
44

55
import (
66
"fmt"
7+
"strings"
78

89
"github.com/k0kubun/pp"
910

@@ -118,11 +119,32 @@ func TestJSONDiff(t *testing.T) {
118119
actual: `{"bar": [5, 2, 4, 3, 1, -1], "b": 2, "c": 3}`,
119120
problems: []JSONMismatch{mismatch("bar", arrayKeysDifferenceSlightly)},
120121
},
122+
{
123+
name: "dates",
124+
expected: `{"a": "2021-01-01T00:00:00.000Z"}`,
125+
actual: `{"a": "2021-01-01T00:00:00.001Z"}`,
126+
problems: []JSONMismatch{mismatch("a", invalidDateValue)},
127+
},
128+
{
129+
name: "dates 2",
130+
expected: `{"a": "2021-01-01T00:00:00.000Z"}`,
131+
actual: `{"a": "2021-01-01T00:00:00.000"}`,
132+
problems: []JSONMismatch{},
133+
},
134+
{
135+
name: "SKIP dates 3", // TODO fix this, not sure how we handle TZ
136+
expected: `{"a": "2024-10-24T10:00:00.000"}`,
137+
actual: `{"a": "2024-10-24T12:00:00.000+02:00"}`,
138+
},
121139
}
122140

123141
for _, tt := range tests {
124142
t.Run(tt.name, func(t *testing.T) {
125143

144+
if strings.HasPrefix(tt.name, "SKIP") {
145+
return
146+
}
147+
126148
diff, err := NewJSONDiff("_ignore")
127149
if err != nil {
128150
t.Fatalf("Expected no error, got %v", err)

quesma/quesma/search_ab_testing.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"quesma/model"
1616
"quesma/queryparser"
1717
"quesma/quesma/async_search_storage"
18+
"quesma/quesma/config"
1819
"quesma/quesma/recovery"
1920
"quesma/quesma/types"
2021
"quesma/quesma/ui"
@@ -128,7 +129,7 @@ func (q *QueryRunner) executeABTesting(ctx context.Context, plan *model.Executio
128129

129130
case *table_resolver.ConnectorDecisionClickhouse:
130131
planExecutor = func(ctx context.Context) ([]byte, error) {
131-
plan.Name = "clickhouse"
132+
plan.Name = config.ClickhouseTarget
132133
return q.executePlan(ctx, plan, queryTranslator, table, body, optAsync, optComparePlansCh, isMainPlan)
133134
}
134135

@@ -139,7 +140,7 @@ func (q *QueryRunner) executeABTesting(ctx context.Context, plan *model.Executio
139140
QueryRowsTransformers: []model.QueryRowsTransformer{},
140141
Queries: []*model.Query{},
141142
StartTime: plan.StartTime,
142-
Name: "elastic",
143+
Name: config.ElasticsearchTarget,
143144
}
144145
return q.executePlanElastic(ctx, elasticPlan, body, optAsync, optComparePlansCh, isMainPlan)
145146
}

0 commit comments

Comments
 (0)