diff --git a/browser/metric_event_mapping.go b/browser/metric_event_mapping.go new file mode 100644 index 000000000..84967fb50 --- /dev/null +++ b/browser/metric_event_mapping.go @@ -0,0 +1,43 @@ +package browser + +import ( + "fmt" + + "github.com/grafana/xk6-browser/common" +) + +// mapMetricEvent to the JS module. +func mapMetricEvent(vu moduleVU, cm *common.MetricEvent) (mapping, error) { + rt := vu.VU.Runtime() + + // We're setting up the function in the Sobek context that will be reused + // for this VU. + _, err := rt.RunString(` + function _k6BrowserCheckRegEx(pattern, url) { + let r = pattern; + if (typeof pattern === 'string') { + r = new RegExp(pattern); + } + return r.test(url); + }`) + if err != nil { + return nil, fmt.Errorf("evaluating regex function: %w", err) + } + + return mapping{ + "Tag": func(urls common.URLTagPatterns) error { + callback := func(pattern, url string) (bool, error) { + js := fmt.Sprintf(`_k6BrowserCheckRegEx(%s, '%s')`, pattern, url) + + matched, err := rt.RunString(js) + if err != nil { + return false, fmt.Errorf("matching url with regex: %w", err) + } + + return matched.ToBoolean(), nil + } + + return cm.Tag(callback, urls) + }, + }, nil +} diff --git a/browser/page_mapping.go b/browser/page_mapping.go index a94d1cb02..3149ab82f 100644 --- a/browser/page_mapping.go +++ b/browser/page_mapping.go @@ -2,6 +2,7 @@ package browser import ( "context" + "errors" "fmt" "time" @@ -206,18 +207,56 @@ func mapPage(vu moduleVU, p *common.Page) mapping { //nolint:gocognit,cyclop "on": func(event string, handler sobek.Callable) error { tq := vu.taskQueueRegistry.get(vu.Context(), p.TargetID()) - mapMsgAndHandleEvent := func(m *common.ConsoleMessage) error { - mapping := mapConsoleMessage(vu, m) - _, err := handler(sobek.Undefined(), vu.Runtime().ToValue(mapping)) - return err - } - runInTaskQueue := func(m *common.ConsoleMessage) { - tq.Queue(func() error { - if err := mapMsgAndHandleEvent(m); err != nil { - return fmt.Errorf("executing page.on handler: %w", err) - } - return nil - }) + var runInTaskQueue func(any) + switch event { + case common.EventPageConsoleAPICalled: + mapMsgAndHandleEvent := func(m *common.ConsoleMessage) error { + mapping := mapConsoleMessage(vu, m) + _, err := handler(sobek.Undefined(), vu.VU.Runtime().ToValue(mapping)) + return err + } + runInTaskQueue = func(a any) { + tq.Queue(func() error { + m, ok := a.(*common.ConsoleMessage) + if !ok { + return errors.New("incorrect console message") + } + + if err := mapMsgAndHandleEvent(m); err != nil { + return fmt.Errorf("executing page.on handler: %w", err) + } + return nil + }) + } + case common.EventPageMetricCalled: + runInTaskQueue = func(a any) { + // The function on the taskqueue runs in its own goroutine + // so we need to use a channel to wait for it to complete + // since we're waiting for updates from the handler which + // will be written to the ExportedMetric. + c := make(chan bool) + tq.Queue(func() error { + defer close(c) + + m, ok := a.(*common.MetricEvent) + if !ok { + return errors.New("incorrect metric message") + } + + mapping, err := mapMetricEvent(vu, m) + if err != nil { + return fmt.Errorf("mapping the metric: %w", err) + } + + if _, err = handler(sobek.Undefined(), vu.VU.Runtime().ToValue(mapping)); err != nil { + return fmt.Errorf("executing page.on('metric') handler: %w", err) + } + return nil + }) + <-c + } + default: + return fmt.Errorf("unknown page event: %q", event) } return p.On(event, runInTaskQueue) //nolint:wrapcheck diff --git a/browser/sync_page_mapping.go b/browser/sync_page_mapping.go index b4455ccbd..49cbba5f7 100644 --- a/browser/sync_page_mapping.go +++ b/browser/sync_page_mapping.go @@ -1,6 +1,7 @@ package browser import ( + "errors" "fmt" "github.com/grafana/sobek" @@ -121,8 +122,13 @@ func syncMapPage(vu moduleVU, p *common.Page) mapping { //nolint:gocognit,cyclop _, err := handler(sobek.Undefined(), vu.Runtime().ToValue(mapping)) return err } - runInTaskQueue := func(m *common.ConsoleMessage) { + runInTaskQueue := func(a any) { tq.Queue(func() error { + m, ok := a.(*common.ConsoleMessage) + if !ok { + return errors.New("incorrect message") + } + if err := mapMsgAndHandleEvent(m); err != nil { return fmt.Errorf("executing page.on handler: %w", err) } diff --git a/common/frame_session.go b/common/frame_session.go index 370d019f9..84283dc8d 100644 --- a/common/frame_session.go +++ b/common/frame_session.go @@ -121,7 +121,7 @@ func NewFrameSession( if fs.parent != nil { parentNM = fs.parent.networkManager } - fs.networkManager, err = NewNetworkManager(ctx, k6Metrics, s, fs.manager, parentNM) + fs.networkManager, err = NewNetworkManager(ctx, k6Metrics, s, fs.manager, parentNM, fs.manager.page) if err != nil { l.Debugf("NewFrameSession:NewNetworkManager", "sid:%v tid:%v err:%v", s.ID(), tid, err) @@ -355,7 +355,7 @@ func (fs *FrameSession) parseAndEmitWebVitalMetric(object string) error { state := fs.vu.State() tags := state.Tags.GetCurrentValues().Tags if state.Options.SystemTags.Has(k6metrics.TagURL) { - tags = tags.With("url", wv.URL) + tags = handleURLTag(fs.ctx, fs.page, wv.URL, tags) } tags = tags.With("rating", wv.Rating) diff --git a/common/network_manager.go b/common/network_manager.go index dd4512676..24962a3e8 100644 --- a/common/network_manager.go +++ b/common/network_manager.go @@ -57,6 +57,10 @@ func (c *Credentials) Parse(ctx context.Context, credentials sobek.Value) error return nil } +type metricInterceptor interface { + urlTagName(ctx context.Context, urlTag string) (string, bool) +} + // NetworkManager manages all frames in HTML document. type NetworkManager struct { BaseEventEmitter @@ -70,6 +74,7 @@ type NetworkManager struct { resolver k6netext.Resolver vu k6modules.VU customMetrics *k6ext.CustomMetrics + mi metricInterceptor // TODO: manage inflight requests separately (move them between the two maps // as they transition from inflight -> completed) @@ -88,7 +93,12 @@ type NetworkManager struct { // NewNetworkManager creates a new network manager. func NewNetworkManager( - ctx context.Context, customMetrics *k6ext.CustomMetrics, s session, fm *FrameManager, parent *NetworkManager, + ctx context.Context, + customMetrics *k6ext.CustomMetrics, + s session, + fm *FrameManager, + parent *NetworkManager, + mi metricInterceptor, ) (*NetworkManager, error) { vu := k6ext.GetVU(ctx) state := vu.State() @@ -114,6 +124,7 @@ func NewNetworkManager( attemptedAuth: make(map[fetch.RequestID]bool), extraHTTPHeaders: make(map[string]string), networkProfile: NewNetworkProfile(), + mi: mi, } m.initEvents() if err := m.initDomains(); err != nil { @@ -181,7 +192,7 @@ func (m *NetworkManager) emitRequestMetrics(req *Request) { tags = tags.With("method", req.method) } if state.Options.SystemTags.Has(k6metrics.TagURL) { - tags = tags.With("url", req.URL()) + tags = handleURLTag(m.vu.Context(), m.mi, req.URL(), tags) } k6metrics.PushIfNotDone(m.vu.Context(), state.Samples, k6metrics.ConnectedSamples{ @@ -234,7 +245,7 @@ func (m *NetworkManager) emitResponseMetrics(resp *Response, req *Request) { tags = tags.With("method", req.method) } if state.Options.SystemTags.Has(k6metrics.TagURL) { - tags = tags.With("url", url) + tags = handleURLTag(m.vu.Context(), m.mi, url, tags) } if state.Options.SystemTags.Has(k6metrics.TagIP) { tags = tags.With("ip", ipAddress) @@ -278,6 +289,22 @@ func (m *NetworkManager) emitResponseMetrics(resp *Response, req *Request) { } } +// handleURLTag will check if the url tag needs to be grouped by testing +// against user supplied regex. If there's a match a user supplied name will +// be used instead of the url for the url tag, otherwise the url will be used. +func handleURLTag(ctx context.Context, mi metricInterceptor, url string, tags *k6metrics.TagSet) *k6metrics.TagSet { + if newTagName, urlMatched := mi.urlTagName(ctx, url); urlMatched { + tags = tags.With("url", newTagName) + tags = tags.With("name", newTagName) + return tags + } + + tags = tags.With("url", url) + tags = tags.With("name", url) + + return tags +} + func (m *NetworkManager) handleRequestRedirect(req *Request, redirectResponse *network.Response, timestamp *cdp.MonotonicTime) { resp := NewHTTPResponse(m.ctx, req, redirectResponse, timestamp) req.responseMu.Lock() diff --git a/common/network_manager_test.go b/common/network_manager_test.go index 9757f3b53..cdb9f06e9 100644 --- a/common/network_manager_test.go +++ b/common/network_manager_test.go @@ -209,6 +209,12 @@ func TestOnRequestPausedBlockedIPs(t *testing.T) { } } +type MetricInterceptorMock struct{} + +func (m *MetricInterceptorMock) urlTagName(ctx context.Context, urlTag string) (string, bool) { + return "", false +} + func TestNetworkManagerEmitRequestResponseMetricsTimingSkew(t *testing.T) { t.Parallel() @@ -271,7 +277,7 @@ func TestNetworkManagerEmitRequestResponseMetricsTimingSkew(t *testing.T) { var ( vu = k6test.NewVU(t) - nm = &NetworkManager{ctx: vu.Context(), vu: vu, customMetrics: k6m} + nm = &NetworkManager{ctx: vu.Context(), vu: vu, customMetrics: k6m, mi: &MetricInterceptorMock{}} ) vu.ActivateVU() diff --git a/common/page.go b/common/page.go index 53044b399..d74801cf6 100644 --- a/common/page.go +++ b/common/page.go @@ -35,7 +35,8 @@ const BlankPage = "about:blank" const ( webVitalBinding = "k6browserSendWebVitalMetric" - eventPageConsoleAPICalled = "console" + EventPageConsoleAPICalled = "console" + EventPageMetricCalled = "metric" ) // MediaType represents the type of media to emulate. @@ -181,8 +182,6 @@ func NewEmulatedSize(viewport *Viewport, screen *Screen) *EmulatedSize { } } -type consoleEventHandlerFunc func(*ConsoleMessage) - // ConsoleMessage represents a page console message. type ConsoleMessage struct { // Args represent the list of arguments passed to a console function call. @@ -239,7 +238,7 @@ type Page struct { backgroundPage bool eventCh chan Event - eventHandlers map[string][]consoleEventHandlerFunc + eventHandlers map[string][]func(any) eventHandlersMu sync.RWMutex mainFrameSession *FrameSession @@ -278,7 +277,7 @@ func NewPage( Keyboard: NewKeyboard(ctx, s), jsEnabled: true, eventCh: make(chan Event), - eventHandlers: make(map[string][]consoleEventHandlerFunc), + eventHandlers: make(map[string][]func(any)), frameSessions: make(map[cdp.FrameID]*FrameSession), workers: make(map[target.SessionID]*Worker), vu: k6ext.GetVU(ctx), @@ -364,6 +363,109 @@ func (p *Page) initEvents() { }() } +// urlTagName is used to check the incoming metric url tag against user +// defined url regexes. When a match is found a user defined name, which is to +// be used in the urls place in the url metric tag, is returned. +// +// The check is done by calling the handlers that were registered with +// `page.on('metric')`. The user will need to use `Tag` to supply the +// url regexes and the matching is done from within there. If a match is found, +// the supplied name is returned back upstream to the caller of urlTagName. +func (p *Page) urlTagName(ctx context.Context, url string) (string, bool) { + p.eventHandlersMu.RLock() + + // If there are no handlers for EventConsoleAPICalled. + if _, ok := p.eventHandlers[EventPageMetricCalled]; !ok { + p.eventHandlersMu.RUnlock() + + return "", false + } + + var newTagName string + var urlMatched bool + em := &MetricEvent{ + url: url, + } + + for _, h := range p.eventHandlers[EventPageMetricCalled] { + // A handler can register another handler from within itself. This is + // the reason to unlock the mutex before calling the handler. + p.eventHandlersMu.RUnlock() + + // Call and wait for the handler to complete. + h(em) + + p.eventHandlersMu.RLock() + } + p.eventHandlersMu.RUnlock() + + // If a match was found then the name field in em will have been updated. + if em.isUserURLTagNameExist { + newTagName = em.userProvidedTagName + urlMatched = true + } + + p.logger.Debugf("urlTagName", "name: %q nameChanged: %v", newTagName, urlMatched) + + return newTagName, urlMatched +} + +// MetricEvent is the type that is exported to JS. It is currently only used to +// match on the urlTag and return a name when a match is found. +type MetricEvent struct { + // The URL value from the metric's url tag. It will be used to match + // against the URL grouping regexs. + url string + + // When a match is found this userProvidedTagName field should be updated. + userProvidedTagName string + + // When a match is found this is set to true. + isUserURLTagNameExist bool +} + +// URLTagPatterns will contain all the URL groupings. +type URLTagPatterns struct { + URLs []URLTagPattern `js:"urls"` +} + +// URLTagPattern contains the single url regex and the name to give to the metric +// if a match is found. +type URLTagPattern struct { + // This is a regex that will be compared against the existing url tag. + URLRegEx string `js:"url"` + // The name to send back to the caller of the handler. + TagName string `js:"name"` +} + +type k6BrowserCheckRegEx func(pattern, url string) (bool, error) + +// Tag will find the first match given the URLTagPatterns and the URL from +// the metric tag and update the name field. +func (e *MetricEvent) Tag(matchesRegex k6BrowserCheckRegEx, overrides URLTagPatterns) error { + for _, o := range overrides.URLs { + name := strings.TrimSpace(o.TagName) + if name == "" { + return fmt.Errorf("name %q is invalid", o.TagName) + } + + // matchesRegex is a function that will perform the regex test in the Sobek + // runtime. + matched, err := matchesRegex(o.URLRegEx, e.url) + if err != nil { + return err + } + + if matched { + e.isUserURLTagNameExist = true + e.userProvidedTagName = name + return nil + } + } + + return nil +} + func (p *Page) closeWorker(sessionID target.SessionID) { p.logger.Debugf("Page:closeWorker", "sid:%v", sessionID) @@ -990,18 +1092,21 @@ func (p *Page) NavigationTimeout() time.Duration { // On subscribes to a page event for which the given handler will be executed // passing in the ConsoleMessage associated with the event. // The only accepted event value is 'console'. -func (p *Page) On(event string, handler func(*ConsoleMessage)) error { - if event != eventPageConsoleAPICalled { - return fmt.Errorf("unknown page event: %q, must be %q", event, eventPageConsoleAPICalled) +func (p *Page) On(event string, handler func(any)) error { + switch event { + case EventPageConsoleAPICalled: + case EventPageMetricCalled: + default: + return fmt.Errorf("unknown page event: %q", event) } p.eventHandlersMu.Lock() defer p.eventHandlersMu.Unlock() - if _, ok := p.eventHandlers[eventPageConsoleAPICalled]; !ok { - p.eventHandlers[eventPageConsoleAPICalled] = make([]consoleEventHandlerFunc, 0, 1) + if _, ok := p.eventHandlers[event]; !ok { + p.eventHandlers[event] = make([]func(any), 0, 1) } - p.eventHandlers[eventPageConsoleAPICalled] = append(p.eventHandlers[eventPageConsoleAPICalled], handler) + p.eventHandlers[event] = append(p.eventHandlers[event], handler) return nil } @@ -1382,7 +1487,7 @@ func (p *Page) TargetID() string { func (p *Page) onConsoleAPICalled(event *cdpruntime.EventConsoleAPICalled) { // If there are no handlers for EventConsoleAPICalled, return p.eventHandlersMu.RLock() - if _, ok := p.eventHandlers[eventPageConsoleAPICalled]; !ok { + if _, ok := p.eventHandlers[EventPageConsoleAPICalled]; !ok { p.eventHandlersMu.RUnlock() return } @@ -1396,7 +1501,7 @@ func (p *Page) onConsoleAPICalled(event *cdpruntime.EventConsoleAPICalled) { p.eventHandlersMu.RLock() defer p.eventHandlersMu.RUnlock() - for _, h := range p.eventHandlers[eventPageConsoleAPICalled] { + for _, h := range p.eventHandlers[EventPageConsoleAPICalled] { h := h h(m) } diff --git a/examples/pageon-metric.js b/examples/pageon-metric.js new file mode 100644 index 000000000..bb82b20f0 --- /dev/null +++ b/examples/pageon-metric.js @@ -0,0 +1,33 @@ +import { browser } from 'k6/x/browser/async'; + +export const options = { + scenarios: { + ui: { + executor: 'shared-iterations', + options: { + browser: { + type: 'chromium', + }, + }, + }, + }, +} + +export default async function() { + const page = await browser.newPage(); + + page.on('metric', (metric) => { + metric.Tag({ + urls: [ + {url: /^https:\/\/test\.k6\.io\/\?q=[0-9a-z]+$/, name:'test'}, + ] + }); + }); + + try { + await page.goto('https://test.k6.io/?q=abc123'); + await page.goto('https://test.k6.io/?q=def456'); + } finally { + await page.close(); + } +} diff --git a/tests/page_test.go b/tests/page_test.go index b9d342b68..280d66449 100644 --- a/tests/page_test.go +++ b/tests/page_test.go @@ -11,14 +11,17 @@ import ( "net/http/httptest" "os" "strconv" + "sync/atomic" "testing" "time" "github.com/grafana/sobek" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + k6metrics "go.k6.io/k6/metrics" "github.com/grafana/xk6-browser/common" + "github.com/grafana/xk6-browser/k6ext/k6test" ) type emulateMediaOpts struct { @@ -1248,12 +1251,18 @@ func TestPageOn(t *testing.T) { ) // Console Messages should be multiplexed for every registered handler - eventHandlerOne := func(cm *common.ConsoleMessage) { + eventHandlerOne := func(a any) { + cm, ok := a.(*common.ConsoleMessage) + assert.True(t, ok) + defer close(done1) tc.assertFn(t, cm) } - eventHandlerTwo := func(cm *common.ConsoleMessage) { + eventHandlerTwo := func(a any) { + cm, ok := a.(*common.ConsoleMessage) + assert.True(t, ok) + defer close(done2) tc.assertFn(t, cm) } @@ -1873,3 +1882,189 @@ func TestPageGetAttributeEmpty(t *testing.T) { require.True(t, ok) assert.Equal(t, "", got) } + +func TestPageOnMetric(t *testing.T) { + t.Parallel() + + // This page will perform many pings with a changing h query parameter. + // This URL should be grouped according to how page.on('metric') is used. + tb := newTestBrowser(t, withHTTPServer()) + tb.withHandler("/home", func(w http.ResponseWriter, r *http.Request) { + _, err := fmt.Fprintf(w, ` + + + + + + `) + require.NoError(t, err) + }) + tb.withHandler("/ping", func(w http.ResponseWriter, r *http.Request) { + _, err := fmt.Fprintf(w, `pong`) + require.NoError(t, err) + }) + + ignoreURLs := map[string]any{ + tb.url("/home"): nil, + tb.url("/favicon.ico"): nil, + } + + tests := []struct { + name string + fun string + want string + }{ + { + // Just a single page.on. + name: "single_page.on", + fun: `page.on('metric', (metric) => { + metric.Tag({ + urls: [ + {url: /^http:\/\/127\.0\.0\.1\:[0-9]+\/ping\?h=[0-9a-z]+$/, name:'ping-1'}, + ] + }); + });`, + want: "ping-1", + }, + { + // A single page.on but with multiple calls to Tag. + name: "multi_tag", + fun: `page.on('metric', (metric) => { + metric.Tag({ + urls: [ + {url: /^http:\/\/127\.0\.0\.1\:[0-9]+\/ping\?h=[0-9a-z]+$/, name:'ping-1'}, + ] + }); + metric.Tag({ + urls: [ + {url: /^http:\/\/127\.0\.0\.1\:[0-9]+\/ping\?h=[0-9a-z]+$/, name:'ping-2'}, + ] + }); + });`, + want: "ping-2", + }, + { + // Two page.on and in one of them multiple calls to Tag. + name: "multi_tag_page.on", + fun: `page.on('metric', (metric) => { + metric.Tag({ + urls: [ + {url: /^http:\/\/127\.0\.0\.1\:[0-9]+\/ping\?h=[0-9a-z]+$/, name:'ping-1'}, + ] + }); + metric.Tag({ + urls: [ + {url: /^http:\/\/127\.0\.0\.1\:[0-9]+\/ping\?h=[0-9a-z]+$/, name:'ping-2'}, + ] + }); + }); + page.on('metric', (metric) => { + metric.Tag({ + urls: [ + {url: /^http:\/\/127\.0\.0\.1\:[0-9]+\/ping\?h=[0-9a-z]+$/, name:'ping-3'}, + ] + }); + });`, + want: "ping-3", + }, + { + // A single page.on but within it another page.on. + name: "multi_page.on_call", + fun: `page.on('metric', (metric) => { + metric.Tag({ + urls: [ + {url: /^http:\/\/127\.0\.0\.1\:[0-9]+\/ping\?h=[0-9a-z]+$/, name:'ping-1'}, + ] + }); + page.on('metric', (metric) => { + metric.Tag({ + urls: [ + {url: /^http:\/\/127\.0\.0\.1\:[0-9]+\/ping\?h=[0-9a-z]+$/, name:'ping-4'}, + ] + }); + }); + });`, + want: "ping-4", + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var foundAmended atomic.Int32 + var foundUnamended atomic.Int32 + + done := make(chan bool) + + samples := make(chan k6metrics.SampleContainer) + go func() { + defer close(done) + for e := range samples { + ss := e.GetSamples() + for _, s := range ss { + // At the moment all metrics that the browser emits contains + // both a url and name tag on each metric. + u, ok := s.TimeSeries.Tags.Get("url") + assert.True(t, ok) + n, ok := s.TimeSeries.Tags.Get("name") + assert.True(t, ok) + + // The name and url tags should have the same value. + assert.Equal(t, u, n) + + // If the url is in the ignoreURLs map then this will + // not have been matched on by the regex, so continue. + if _, ok := ignoreURLs[u]; ok { + foundUnamended.Add(1) + continue + } + + // Url shouldn't contain any of the hash values, and should + // instead take the name that was supplied in the Tag + // function on metric in page.on. + assert.Equal(t, tt.want, u) + + foundAmended.Add(1) + } + } + }() + + vu, _, _, cleanUp := startIteration(t, k6test.WithSamples(samples)) + defer cleanUp() + + // Some of the business logic is in the mapping layer unfortunately. + // To test everything is wried up correctly, we're required to work + // with RunPromise. + got := vu.RunPromise(t, ` + const page = await browser.newPage() + + %s + + await page.goto('%s', {waitUntil: 'networkidle'}); + + await page.close() + `, tt.fun, tb.url("/home")) + assert.True(t, got.Result().Equals(sobek.Null())) + + close(samples) + + <-done + + // We want to make sure that we found at least one occurrence + // of a metric which matches our expectations. + assert.True(t, foundAmended.Load() > 0) + + // We want to make sure that we found at least one occurrence + // of a metric which didn't match our expectations. + assert.True(t, foundUnamended.Load() > 0) + }) + } +} diff --git a/tests/remote_obj_test.go b/tests/remote_obj_test.go index 5d2d6e6f6..50a07eb94 100644 --- a/tests/remote_obj_test.go +++ b/tests/remote_obj_test.go @@ -81,7 +81,10 @@ func TestConsoleLogParse(t *testing.T) { done := make(chan bool) - eventHandler := func(cm *common.ConsoleMessage) { + eventHandler := func(a any) { + cm, ok := a.(*common.ConsoleMessage) + assert.True(t, ok) + defer close(done) assert.Equal(t, tt.want, cm.Text) }