Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add web vital metrics #836

Merged
merged 19 commits into from
Mar 29, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion api/browser.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ type Browser interface {
Close()
Contexts() []BrowserContext
IsConnected() bool
NewContext(opts goja.Value) BrowserContext
NewContext(opts goja.Value) (BrowserContext, error)
NewPage(opts goja.Value) (Page, error)
On(string) (bool, error)
UserAgent() string
Expand Down
2 changes: 1 addition & 1 deletion api/browser_context.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import (
// BrowserContext is the public interface of a CDP browser context.
type BrowserContext interface {
AddCookies(cookies goja.Value)
AddInitScript(script goja.Value, arg goja.Value)
AddInitScript(script goja.Value, arg goja.Value) error
Browser() Browser
ClearCookies()
ClearPermissions()
Expand Down
9 changes: 6 additions & 3 deletions browser/mapping.go
Original file line number Diff line number Diff line change
Expand Up @@ -687,10 +687,13 @@ func mapBrowser(vu moduleVU, b api.Browser) mapping {
},
"userAgent": b.UserAgent,
"version": b.Version,
"newContext": func(opts goja.Value) *goja.Object {
bctx := b.NewContext(opts)
"newContext": func(opts goja.Value) (*goja.Object, error) {
bctx, err := b.NewContext(opts)
if err != nil {
return nil, err //nolint:wrapcheck
}
m := mapBrowserContext(vu, bctx)
return rt.ToValue(m).ToObject(rt)
return rt.ToValue(m).ToObject(rt), nil
},
"newPage": func(opts goja.Value) (mapping, error) {
page, err := b.NewPage(opts)
Expand Down
20 changes: 15 additions & 5 deletions common/browser.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,10 @@ func (b *Browser) connect() error {
b.conn = conn

// We don't need to lock this because `connect()` is called only in NewBrowser
b.defaultContext = NewBrowserContext(b.ctx, b, "", NewBrowserContextOptions(), b.logger)
b.defaultContext, err = NewBrowserContext(b.ctx, b, "", NewBrowserContextOptions(), b.logger)
if err != nil {
return fmt.Errorf("browser connect: %w", err)
}

return b.initEvents()
}
Expand Down Expand Up @@ -475,7 +478,7 @@ func (b *Browser) IsConnected() bool {
}

// NewContext creates a new incognito-like browser context.
func (b *Browser) NewContext(opts goja.Value) api.BrowserContext {
func (b *Browser) NewContext(opts goja.Value) (api.BrowserContext, error) {
action := target.CreateBrowserContext().WithDisposeOnDetach(true)
browserContextID, err := action.Do(cdp.WithExecutor(b.ctx, b.conn))
b.logger.Debugf("Browser:NewContext", "bctxid:%v", browserContextID)
Expand All @@ -490,15 +493,22 @@ func (b *Browser) NewContext(opts goja.Value) api.BrowserContext {

b.contextsMu.Lock()
defer b.contextsMu.Unlock()
browserCtx := NewBrowserContext(b.ctx, b, browserContextID, browserCtxOpts, b.logger)
browserCtx, err := NewBrowserContext(b.ctx, b, browserContextID, browserCtxOpts, b.logger)
if err != nil {
return nil, fmt.Errorf("new context: %w", err)
}
b.contexts[browserContextID] = browserCtx

return browserCtx
return browserCtx, nil
}

// NewPage creates a new tab in the browser window.
func (b *Browser) NewPage(opts goja.Value) (api.Page, error) {
browserCtx := b.NewContext(opts)
browserCtx, err := b.NewContext(opts)
if err != nil {
return nil, fmt.Errorf("new page: %w", err)
}

return browserCtx.NewPage()
}

Expand Down
36 changes: 31 additions & 5 deletions common/browser_context.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"time"

"github.com/grafana/xk6-browser/api"
"github.com/grafana/xk6-browser/common/js"
"github.com/grafana/xk6-browser/k6error"
"github.com/grafana/xk6-browser/k6ext"
"github.com/grafana/xk6-browser/log"
Expand Down Expand Up @@ -48,7 +49,7 @@ type BrowserContext struct {
// NewBrowserContext creates a new browser context.
func NewBrowserContext(
ctx context.Context, browser *Browser, id cdp.BrowserContextID, opts *BrowserContextOptions, logger *log.Logger,
) *BrowserContext {
) (*BrowserContext, error) {
b := BrowserContext{
BaseEventEmitter: NewBaseEventEmitter(ctx),
ctx: ctx,
Expand All @@ -64,7 +65,18 @@ func NewBrowserContext(
b.GrantPermissions(opts.Permissions, nil)
}

return &b
rt := b.vu.Runtime()
wv := rt.ToValue(js.WebVitalIIFEScript)
wvi := rt.ToValue(js.WebVitalInitScript)

if err := b.AddInitScript(wv, nil); err != nil {
return nil, fmt.Errorf("adding web vital script to new browser context: %w", err)
}
if err := b.AddInitScript(wvi, nil); err != nil {
return nil, fmt.Errorf("adding web vital init script to new browser context: %w", err)
}

return &b, nil
}

// AddCookies adds cookies into this browser context.
Expand All @@ -79,13 +91,13 @@ func (b *BrowserContext) AddCookies(cookies goja.Value) {
}

// AddInitScript adds a script that will be initialized on all new pages.
func (b *BrowserContext) AddInitScript(script goja.Value, arg goja.Value) {
func (b *BrowserContext) AddInitScript(script goja.Value, arg goja.Value) error {
b.logger.Debugf("BrowserContext:AddInitScript", "bctxid:%v", b.id)

rt := b.vu.Runtime()

source := ""
if script != nil && !goja.IsUndefined(script) && !goja.IsNull(script) {
if gojaValueExists(script) {
switch script.ExportType() {
case reflect.TypeOf(string("")):
source = script.String()
Expand All @@ -110,8 +122,22 @@ func (b *BrowserContext) AddInitScript(script goja.Value, arg goja.Value) {
b.evaluateOnNewDocumentSources = append(b.evaluateOnNewDocumentSources, source)

for _, p := range b.browser.getPages() {
p.evaluateOnNewDocument(source)
if err := p.evaluateOnNewDocument(source); err != nil {
return fmt.Errorf("adding init script to browser context: %w", err)
}
}

return nil
}

func (b *BrowserContext) applyAllInitScripts(p *Page) error {
for _, source := range b.evaluateOnNewDocumentSources {
if err := p.evaluateOnNewDocument(source); err != nil {
return fmt.Errorf("adding init script to browser context: %w", err)
}
}

return nil
}

// Browser returns the browser instance that this browser context belongs to.
Expand Down
44 changes: 44 additions & 0 deletions common/browser_context_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package common

import (
"context"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/grafana/xk6-browser/common/js"
"github.com/grafana/xk6-browser/k6ext"
"github.com/grafana/xk6-browser/k6ext/k6test"
"github.com/grafana/xk6-browser/log"
)

func TestNewBrowserContext(t *testing.T) {
t.Run("add_web_vital_js_scripts_to_context", func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
logger := log.NewNullLogger()
b := newBrowser(ctx, cancel, nil, NewLaunchOptions(), logger)

vu := k6test.NewVU(t)
ctx = k6ext.WithVU(ctx, vu)

bc, err := NewBrowserContext(ctx, b, "some-id", nil, nil)
require.NoError(t, err)

webVitalIIFEScriptFound := false
webVitalInitScriptFound := false
for _, script := range bc.evaluateOnNewDocumentSources {
switch script {
case js.WebVitalIIFEScript:
webVitalIIFEScriptFound = true
case js.WebVitalInitScript:
webVitalInitScriptFound = true
default:
assert.Fail(t, "script is neither WebVitalIIFEScript nor WebVitalInitScript")
}
}

assert.True(t, webVitalIIFEScriptFound, "WebVitalIIFEScript was not initialized in the context")
assert.True(t, webVitalInitScriptFound, "WebVitalInitScript was not initialized in the context")
})
}
8 changes: 7 additions & 1 deletion common/browser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import (
"github.com/mailru/easyjson"
"github.com/stretchr/testify/require"

"github.com/grafana/xk6-browser/k6ext"
"github.com/grafana/xk6-browser/k6ext/k6test"
"github.com/grafana/xk6-browser/log"
)

Expand All @@ -26,7 +28,11 @@ func TestBrowserNewPageInContext(t *testing.T) {
logger := log.NewNullLogger()
b := newBrowser(ctx, cancel, nil, NewLaunchOptions(), logger)
// set a new browser context in the browser with `id`, so that newPageInContext can find it.
b.contexts[id] = NewBrowserContext(ctx, b, id, nil, nil)
var err error
vu := k6test.NewVU(t)
ctx = k6ext.WithVU(ctx, vu)
b.contexts[id], err = NewBrowserContext(ctx, b, id, nil, nil)
require.NoError(t, err)
return &testCase{
b: b,
bc: b.contexts[id],
Expand Down
74 changes: 73 additions & 1 deletion common/frame_session.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"runtime"
"strings"
"sync"
"time"

"github.com/grafana/xk6-browser/api"
"github.com/grafana/xk6-browser/k6ext"
Expand Down Expand Up @@ -261,12 +262,83 @@ func (fs *FrameSession) initEvents() {
fs.onDetachedFromTarget(ev)
case *cdppage.EventJavascriptDialogOpening:
fs.onEventJavascriptDialogOpening(ev)
case *cdpruntime.EventBindingCalled:
fs.onEventBindingCalled(ev)
}
}
}
}()
}

func (fs *FrameSession) onEventBindingCalled(event *cdpruntime.EventBindingCalled) {
fs.logger.Debugf("FrameSessions:onEventBindingCalled",
"sid:%v tid:%v name:%s payload:%s",
fs.session.ID(), fs.targetID, event.Name, event.Payload)

err := fs.parseAndEmitWebVitalMetric(event.Payload)
if err != nil {
fs.logger.Errorf("FrameSession:onEventBindingCalled", "failed to emit web vital metric: %v", err)
}
}

func (fs *FrameSession) parseAndEmitWebVitalMetric(object string) error {
fs.logger.Debugf("FrameSession:parseAndEmitWebVitalMetric", "object:%s", object)

wv := struct {
ID string
Name string
Value json.Number
Rating string
Delta json.Number
NumEntries json.Number
NavigationType string
URL string
}{}

if err := json.Unmarshal([]byte(object), &wv); err != nil {
return fmt.Errorf("json couldn't be parsed: %w", err)
}

metric, ok := fs.k6Metrics.WebVitals[wv.Name]
if !ok {
return fmt.Errorf("metric not registered %q", wv.Name)
}

metricRating, ok := fs.k6Metrics.WebVitals[k6ext.ConcatWebVitalNameRating(wv.Name, wv.Rating)]
if !ok {
return fmt.Errorf("metric not registered %q", k6ext.ConcatWebVitalNameRating(wv.Name, wv.Rating))
}

value, err := wv.Value.Float64()
if err != nil {
return fmt.Errorf("value couldn't be parsed %q", wv.Value)
}

state := fs.vu.State()
tags := state.Tags.GetCurrentValues().Tags
if state.Options.SystemTags.Has(k6metrics.TagURL) {
tags = tags.With("url", wv.URL)
}

now := time.Now()
k6metrics.PushIfNotDone(fs.ctx, state.Samples, k6metrics.ConnectedSamples{
Samples: []k6metrics.Sample{
{
TimeSeries: k6metrics.TimeSeries{Metric: metric, Tags: tags},
Value: value,
Time: now,
},
{
TimeSeries: k6metrics.TimeSeries{Metric: metricRating, Tags: tags},
Value: 1,
Time: now,
},
},
})

return nil
}

func (fs *FrameSession) onEventJavascriptDialogOpening(event *cdppage.EventJavascriptDialogOpening) {
fs.logger.Debugf("FrameSession:onEventJavascriptDialogOpening",
"sid:%v tid:%v url:%v dialogType:%s",
Expand Down Expand Up @@ -471,6 +543,7 @@ func (fs *FrameSession) initRendererEvents() {
cdproto.EventRuntimeExecutionContextsCleared,
cdproto.EventTargetAttachedToTarget,
cdproto.EventTargetDetachedFromTarget,
cdproto.EventRuntimeBindingCalled,
}
fs.session.on(fs.ctx, events, fs.eventCh)
}
Expand Down Expand Up @@ -735,7 +808,6 @@ func (fs *FrameSession) onPageLifecycle(event *cdppage.EventLifecycleEvent) {
"load": fs.k6Metrics.BrowserLoaded,
"DOMContentLoaded": fs.k6Metrics.BrowserDOMContentLoaded,
"firstPaint": fs.k6Metrics.BrowserFirstPaint,
"firstContentfulPaint": fs.k6Metrics.BrowserFirstContentfulPaint,
"firstMeaningfulPaint": fs.k6Metrics.BrowserFirstMeaningfulPaint,
}

Expand Down
19 changes: 19 additions & 0 deletions common/js/web_vital.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package js

import (
_ "embed"
)

// WebVitalIIFEScript was downloaded from
// https://unpkg.com/web-vitals@3/dist/web-vitals.iife.js.
// Repo: https://github.com/GoogleChrome/web-vitals
//
//go:embed web_vital_iife.js
var WebVitalIIFEScript string

// WebVitalInitScript uses WebVitalIIFEScript
// and applies it to the current website that
// this init script is used against.
//
//go:embed web_vital_init.js
var WebVitalInitScript string
1 change: 1 addition & 0 deletions common/js/web_vital_iife.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

25 changes: 25 additions & 0 deletions common/js/web_vital_init.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
function print(metric) {
const m = {
id: metric.id,
name: metric.name,
value: metric.value,
rating: metric.rating,
delta: metric.delta,
numEntries: metric.entries.length,
navigationType: metric.navigationType,
url: window.location.href,
}
window.k6browserSendWebVitalMetric(JSON.stringify(m))
}

function load() {
webVitals.onCLS(print);
webVitals.onFID(print);
webVitals.onLCP(print);

webVitals.onFCP(print);
webVitals.onINP(print);
webVitals.onTTFB(print);
}

load();