Skip to content

Commit a1a4503

Browse files
ethanalee-workgopherbot
authored andcommitted
internal, static: add telemetry click beacon for search A/B testing
Add /search-click handler to log search result click metadata (query, clicked_package, rank, cohort, timestamp) to Cloud Logging. Pass IsVectorSearch to SearchPage template and fire non-blocking sendBeacon telemetry when users select search results. Change-Id: I4d4aec11d28bdb65c4977b04288d63cef57c96c6 Reviewed-on: https://go-review.googlesource.com/c/pkgsite/+/806500 Reviewed-by: Jonathan Amsterdam <jba@google.com> Auto-Submit: Ethan Lee <ethanalee@google.com> kokoro-CI: kokoro <noreply+kokoro@google.com> LUCI-TryBot-Result: golang-scoped@luci-project-accounts.iam.gserviceaccount.com <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
1 parent 7c0ef71 commit a1a4503

6 files changed

Lines changed: 154 additions & 3 deletions

File tree

internal/frontend/search.go

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ package frontend
66

77
import (
88
"context"
9+
"encoding/json"
910
"errors"
1011
"fmt"
1112
"net/http"
@@ -247,11 +248,17 @@ func fetchSearchPage(ctx context.Context, ds internal.DataSource, cq, symbol str
247248
pageParams paginationParams, searchSymbols bool, vulnClient *vuln.Client, embeddingsClient VectorEmbedder) (*SearchPage, error) {
248249
maxResultCount := maxSearchOffset + pageParams.limit
249250

250-
var vec []float32
251+
var (
252+
vec []float32
253+
embeddingLatency time.Duration
254+
searchLatency time.Duration
255+
)
251256
if embeddingsClient != nil && !searchSymbols && strings.TrimSpace(cq) != "" && experiment.IsActive(ctx, internal.ExperimentVectorSearch) {
252257
embedCtx, cancel := context.WithTimeout(ctx, searchEmbeddingTimeout)
253258
defer cancel()
259+
startEmbed := time.Now()
254260
vecs, err := embeddingsClient.GenerateEmbeddings(embedCtx, []string{cq}, "RETRIEVAL_QUERY")
261+
embeddingLatency = time.Since(startEmbed)
255262
if err != nil {
256263
log.Errorf(ctx, "failed to generate query vector for %q: %v", cq, err)
257264
} else if len(vecs) > 0 {
@@ -261,6 +268,7 @@ func fetchSearchPage(ctx context.Context, ds internal.DataSource, cq, symbol str
261268

262269
// Pageless search: always start from the beginning.
263270
offset := 0
271+
startSearch := time.Now()
264272
dbresults, err := ds.Search(ctx, cq, internal.SearchOptions{
265273
MaxResults: pageParams.limit,
266274
Offset: offset,
@@ -270,6 +278,7 @@ func fetchSearchPage(ctx context.Context, ds internal.DataSource, cq, symbol str
270278
GroupResults: true,
271279
Vector: vec,
272280
})
281+
searchLatency = time.Since(startSearch)
273282
if err != nil {
274283
return nil, err
275284
}
@@ -299,6 +308,19 @@ func fetchSearchPage(ctx context.Context, ds internal.DataSource, cq, symbol str
299308
numPageResults += 1 + len(r.SameModule)
300309
}
301310

311+
cohort := "control"
312+
if len(vec) > 0 {
313+
cohort = "treatment"
314+
}
315+
log.Info(ctx, map[string]any{
316+
"log_type": "search_query_execution",
317+
"query": cq,
318+
"cohort": cohort,
319+
"embedding_latency_ms": embeddingLatency.Milliseconds(),
320+
"search_latency_ms": searchLatency.Milliseconds(),
321+
"num_results": numResults,
322+
})
323+
302324
pgs := newPagination(pageParams, numPageResults, numResults)
303325
sp := &SearchPage{
304326
PackageTabQuery: cq,
@@ -604,5 +626,47 @@ func addVulns(ctx context.Context, rs []*SearchResult, vc *vuln.Client) {
604626
})
605627
}
606628
wg.Wait()
629+
}
630+
631+
type searchClickPayload struct {
632+
Query string `json:"query"`
633+
ClickedPackage string `json:"clicked_package"`
634+
Rank int `json:"rank"`
635+
Cohort string `json:"cohort"`
636+
Timestamp string `json:"timestamp"`
637+
}
638+
639+
func (s *Server) handleSearchClick(w http.ResponseWriter, r *http.Request) {
640+
if r.Method != http.MethodPost {
641+
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
642+
return
643+
}
607644

645+
r.Body = http.MaxBytesReader(w, r.Body, 10<<10)
646+
647+
var payload searchClickPayload
648+
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
649+
http.Error(w, "bad request", http.StatusBadRequest)
650+
return
651+
}
652+
653+
// Input validation: require non-empty fields & valid cohort.
654+
if strings.TrimSpace(payload.Query) == "" || strings.TrimSpace(payload.ClickedPackage) == "" || payload.Rank < 0 {
655+
http.Error(w, "missing or invalid required fields", http.StatusBadRequest)
656+
return
657+
}
658+
if payload.Cohort != "control" && payload.Cohort != "treatment" {
659+
http.Error(w, "invalid cohort", http.StatusBadRequest)
660+
return
661+
}
662+
663+
log.Info(r.Context(), map[string]any{
664+
"log_type": "search_click_telemetry",
665+
"query": payload.Query,
666+
"clicked_package": payload.ClickedPackage,
667+
"rank": payload.Rank,
668+
"cohort": payload.Cohort,
669+
"timestamp": payload.Timestamp,
670+
})
671+
w.WriteHeader(http.StatusNoContent)
608672
}

internal/frontend/search_test.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -920,3 +920,60 @@ func TestFetchSearchPageWithVector(t *testing.T) {
920920
})
921921
}
922922
}
923+
924+
func TestHandleSearchClick(t *testing.T) {
925+
s := &Server{}
926+
927+
tests := []struct {
928+
name string
929+
method string
930+
payload string
931+
wantStatus int
932+
}{
933+
{
934+
name: "valid click treatment",
935+
method: http.MethodPost,
936+
payload: `{"query":"http router","clicked_package":"net/http","rank":0,"cohort":"treatment","timestamp":"2026-07-27T17:00:00Z"}`,
937+
wantStatus: http.StatusNoContent,
938+
},
939+
{
940+
name: "valid click control",
941+
method: http.MethodPost,
942+
payload: `{"query":"http router","clicked_package":"net/http","rank":0,"cohort":"control","timestamp":"2026-07-27T17:00:00Z"}`,
943+
wantStatus: http.StatusNoContent,
944+
},
945+
{
946+
name: "invalid method GET",
947+
method: http.MethodGet,
948+
payload: "",
949+
wantStatus: http.StatusMethodNotAllowed,
950+
},
951+
{
952+
name: "invalid payload empty query",
953+
method: http.MethodPost,
954+
payload: `{"query":"","clicked_package":"net/http","rank":0,"cohort":"treatment"}`,
955+
wantStatus: http.StatusBadRequest,
956+
},
957+
{
958+
name: "invalid cohort",
959+
method: http.MethodPost,
960+
payload: `{"query":"http router","clicked_package":"net/http","rank":0,"cohort":"unknown"}`,
961+
wantStatus: http.StatusBadRequest,
962+
},
963+
}
964+
965+
for _, test := range tests {
966+
t.Run(test.name, func(t *testing.T) {
967+
t.Parallel()
968+
req := httptest.NewRequest(test.method, "/search-click", strings.NewReader(test.payload))
969+
req.Header.Set("Content-Type", "application/json")
970+
w := httptest.NewRecorder()
971+
972+
s.handleSearchClick(w, req)
973+
974+
if w.Code != test.wantStatus {
975+
t.Errorf("got status %d, want %d", w.Code, test.wantStatus)
976+
}
977+
})
978+
}
979+
}

internal/frontend/server.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,7 @@ func (s *Server) Install(handle func(string, http.Handler), cacher Cacher, authV
233233
handle("POST /play/fmt", http.HandlerFunc(s.handleFmt))
234234
handle("/play/share", http.HandlerFunc(s.proxyPlayground))
235235
handle("GET /search", searchHandler)
236+
handle("POST /search-click", http.HandlerFunc(s.handleSearchClick))
236237
handle("GET /search-help", s.staticPageHandler("search-help", "Search Help"))
237238
handle("GET /license-policy", s.licensePolicyHandler())
238239
handle("GET /about", s.staticPageHandler("about", "About"))

static/frontend/search/search.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

static/frontend/search/search.tmpl

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,11 @@
107107
<div class="SearchSnippet-headerContainer">
108108
<h2>
109109
<a href="/{{$v.PackagePath}}" data-gtmc="search result" data-gtmv="{{$i}}"
110-
data-test-id="snippet-title">
110+
data-test-id="snippet-title"
111+
data-search-query="{{$query}}"
112+
data-clicked-package="{{$v.PackagePath}}"
113+
data-rank="{{$i}}"
114+
data-experiment-cohort="{{if $.Experiments.IsActive "vector-search"}}treatment{{else}}control{{end}}">
111115
{{$v.Name}}
112116
<span class="SearchSnippet-header-path">({{$v.PackagePath}})</span>
113117
</a>

static/frontend/search/search.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,4 +43,29 @@ searchHeader?.addEventListener('dblclick', e => {
4343
}
4444
});
4545

46+
// Send non-blocking search result click telemetry via navigator.sendBeacon
47+
document.addEventListener('click', e => {
48+
const target = e.target as HTMLElement | null;
49+
const link = target?.closest<HTMLAnchorElement>('a[data-search-query]');
50+
if (!link) return;
51+
52+
const query = link.getAttribute('data-search-query');
53+
const clickedPackage = link.getAttribute('data-clicked-package');
54+
const rankStr = link.getAttribute('data-rank');
55+
const cohort = link.getAttribute('data-experiment-cohort');
56+
57+
if (query && clickedPackage && rankStr && cohort) {
58+
const payload = {
59+
query: query,
60+
clicked_package: clickedPackage,
61+
rank: parseInt(rankStr, 10),
62+
cohort: cohort,
63+
timestamp: new Date().toISOString(),
64+
};
65+
if (navigator.sendBeacon) {
66+
navigator.sendBeacon('/search-click', JSON.stringify(payload));
67+
}
68+
}
69+
});
70+
4671
export {};

0 commit comments

Comments
 (0)