From e424621feb1ffb289270138386317e05fbd2d6f1 Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Wed, 27 May 2026 13:11:32 -0300 Subject: [PATCH 01/28] update sdk --- cmd/auth_connections.go | 4 ++-- go.mod | 2 +- go.sum | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/cmd/auth_connections.go b/cmd/auth_connections.go index 72d5c5d..8ec6ef9 100644 --- a/cmd/auth_connections.go +++ b/cmd/auth_connections.go @@ -207,7 +207,7 @@ func printManagedAuthSummary(auth *kernel.ManagedAuth) { {"Can Reauth", fmt.Sprintf("%t", auth.CanReauth)}, } if auth.CanReauthReason != "" { - tableData = append(tableData, []string{"Can Reauth Reason", auth.CanReauthReason}) + tableData = append(tableData, []string{"Can Reauth Reason", string(auth.CanReauthReason)}) } if auth.Credential.Name != "" { tableData = append(tableData, []string{"Credential Name", auth.Credential.Name}) @@ -326,7 +326,7 @@ func (c AuthConnectionCmd) Get(ctx context.Context, in AuthConnectionGetInput) e {"Can Reauth", fmt.Sprintf("%t", auth.CanReauth)}, } if auth.CanReauthReason != "" { - tableData = append(tableData, []string{"Can Reauth Reason", auth.CanReauthReason}) + tableData = append(tableData, []string{"Can Reauth Reason", string(auth.CanReauthReason)}) } if auth.Credential.Name != "" { tableData = append(tableData, []string{"Credential Name", auth.Credential.Name}) diff --git a/go.mod b/go.mod index 62cf135..e2a1b3a 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,7 @@ require ( github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.1 github.com/golang-jwt/jwt/v5 v5.2.2 github.com/joho/godotenv v1.5.1 - github.com/kernel/kernel-go-sdk v0.53.0 + github.com/kernel/kernel-go-sdk v0.57.0 github.com/klauspost/compress v1.18.5 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/pterm/pterm v0.12.80 diff --git a/go.sum b/go.sum index b29cf9b..af09979 100644 --- a/go.sum +++ b/go.sum @@ -64,8 +64,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/kernel/kernel-go-sdk v0.53.0 h1:XgcuJv3G4a6nr9LYBZ21gLUWvsIDLSG4YhZAngNrqE0= -github.com/kernel/kernel-go-sdk v0.53.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.57.0 h1:UGAhG7JSHw7Kg1jacw1n3eYg2OFLKIN0eF8uvSTl+Fg= +github.com/kernel/kernel-go-sdk v0.57.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= From baa3558c6dbd1ff5f280d332c242606dcd9fa9da Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Wed, 27 May 2026 13:53:19 -0300 Subject: [PATCH 02/28] feat: add PrintCompactJSONLine helper for NDJSON output --- pkg/util/json.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/pkg/util/json.go b/pkg/util/json.go index aa20d3b..6a8fccd 100644 --- a/pkg/util/json.go +++ b/pkg/util/json.go @@ -29,6 +29,22 @@ func PrintPrettyJSON(v RawJSONProvider) error { return nil } +// PrintCompactJSONLine prints v as a single compact JSON line followed by a +// newline. Use inside SSE loops where downstream tooling (jq -c, log shippers) +// expects newline-delimited JSON. +func PrintCompactJSONLine(v RawJSONProvider) error { + raw := v.RawJSON() + if raw == "" { + return nil + } + var buf bytes.Buffer + if err := json.Compact(&buf, []byte(raw)); err != nil { + return err + } + fmt.Println(buf.String()) + return nil +} + // PrintPrettyJSONSlice prints a slice of SDK response types as a JSON array. // Each element must implement RawJSONProvider. func PrintPrettyJSONSlice[T RawJSONProvider](items []T) error { From 6f8006f544395732c7ccd1ffa00abdf5ed5d817b Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Wed, 27 May 2026 13:55:42 -0300 Subject: [PATCH 03/28] feat: add --telemetry flag on browsers create --- cmd/browsers.go | 8 ++++++++ cmd/browsers_test.go | 31 +++++++++++++++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/cmd/browsers.go b/cmd/browsers.go index 7457d51..b34cab7 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -187,6 +187,7 @@ type BrowsersCreateInput struct { StartURL string Extensions []string Viewport string + TelemetryEnabled bool Output string } @@ -422,6 +423,10 @@ func (b BrowsersCmd) Create(ctx context.Context, in BrowsersCreateInput) error { } } + if in.TelemetryEnabled { + params.Telemetry = kernel.BrowserTelemetryRequestConfigParam{Enabled: kernel.Opt(true)} + } + browser, err := b.browsers.New(ctx, params) if err != nil { return util.CleanedUpSdkError{Err: err} @@ -2506,6 +2511,7 @@ func init() { browsersCreateCmd.Flags().Bool("viewport-interactive", false, "Interactively select viewport size from list") browsersCreateCmd.Flags().String("pool-id", "", "Browser pool ID to acquire from (mutually exclusive with --pool-name)") browsersCreateCmd.Flags().String("pool-name", "", "Browser pool name to acquire from (mutually exclusive with --pool-id)") + browsersCreateCmd.Flags().Bool("telemetry", false, "Enable telemetry capture on the new session") // curl curlCmd := &cobra.Command{ @@ -2575,6 +2581,7 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { viewportInteractive, _ := cmd.Flags().GetBool("viewport-interactive") poolID, _ := cmd.Flags().GetString("pool-id") poolName, _ := cmd.Flags().GetString("pool-name") + telemetry, _ := cmd.Flags().GetBool("telemetry") output, _ := cmd.Flags().GetString("output") if poolID != "" && poolName != "" { @@ -2684,6 +2691,7 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { StartURL: startURL, Extensions: extensions, Viewport: viewport, + TelemetryEnabled: telemetry, Output: output, } diff --git a/cmd/browsers_test.go b/cmd/browsers_test.go index 36190f4..98a4dab 100644 --- a/cmd/browsers_test.go +++ b/cmd/browsers_test.go @@ -1643,6 +1643,37 @@ func TestBrowsersCreate_RejectsStartURLFlagToken(t *testing.T) { assert.False(t, called) } +func TestBrowsersCreate_WithTelemetry(t *testing.T) { + setupStdoutCapture(t) + var captured kernel.BrowserNewParams + fake := &FakeBrowsersService{NewFunc: func(ctx context.Context, body kernel.BrowserNewParams, opts ...option.RequestOption) (*kernel.BrowserNewResponse, error) { + captured = body + return &kernel.BrowserNewResponse{SessionID: "session123", CdpWsURL: "ws://example"}, nil + }} + b := BrowsersCmd{browsers: fake} + + err := b.Create(context.Background(), BrowsersCreateInput{TelemetryEnabled: true}) + + assert.NoError(t, err) + assert.True(t, captured.Telemetry.Enabled.Valid()) + assert.True(t, captured.Telemetry.Enabled.Value) +} + +func TestBrowsersCreate_WithoutTelemetry(t *testing.T) { + setupStdoutCapture(t) + var captured kernel.BrowserNewParams + fake := &FakeBrowsersService{NewFunc: func(ctx context.Context, body kernel.BrowserNewParams, opts ...option.RequestOption) (*kernel.BrowserNewResponse, error) { + captured = body + return &kernel.BrowserNewResponse{SessionID: "session123", CdpWsURL: "ws://example"}, nil + }} + b := BrowsersCmd{browsers: fake} + + err := b.Create(context.Background(), BrowsersCreateInput{}) + + assert.NoError(t, err) + assert.False(t, captured.Telemetry.Enabled.Valid()) +} + func TestBrowsersCreate_WithInvalidViewport(t *testing.T) { setupStdoutCapture(t) fake := &FakeBrowsersService{} From 4540e3ff97b41bc9c41ccf93978bae491f01d910 Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Wed, 27 May 2026 14:01:25 -0300 Subject: [PATCH 04/28] feat: add browsers telemetry stop subcommand --- cmd/browsers.go | 37 +++++++++++++++++++++++++++++++++++++ cmd/browsers_test.go | 30 ++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/cmd/browsers.go b/cmd/browsers.go index b34cab7..6d19cb1 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -218,6 +218,11 @@ type BrowsersUpdateInput struct { Output string } +type BrowsersTelemetryStopInput struct { + Identifier string + Output string +} + // BrowsersCmd is a cobra-independent command handler for browsers operations. type BrowsersCmd struct { browsers BrowsersService @@ -656,6 +661,23 @@ func (b BrowsersCmd) Update(ctx context.Context, in BrowsersUpdateInput) error { return nil } +func (b BrowsersCmd) TelemetryStop(ctx context.Context, in BrowsersTelemetryStopInput) error { + if in.Output != "" && in.Output != "json" { + return fmt.Errorf("unsupported --output value: use 'json'") + } + res, err := b.browsers.Update(ctx, in.Identifier, kernel.BrowserUpdateParams{ + Telemetry: kernel.BrowserTelemetryRequestConfigParam{Enabled: kernel.Opt(false)}, + }) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + if in.Output == "json" { + return util.PrintPrettyJSON(res) + } + pterm.Success.Printf("Stopped telemetry for browser %s\n", in.Identifier) + return nil +} + // Logs type BrowsersLogsStreamInput struct { Identifier string @@ -2282,6 +2304,13 @@ func init() { replaysRoot.AddCommand(replaysList, replaysStart, replaysStop, replaysDownload) browsersCmd.AddCommand(replaysRoot) + // telemetry + telemetryRoot := &cobra.Command{Use: "telemetry", Short: "Browser telemetry operations"} + telemetryStop := &cobra.Command{Use: "stop ", Short: "Stop telemetry capture", Args: cobra.ExactArgs(1), RunE: runBrowsersTelemetryStop} + telemetryStop.Flags().StringP("output", "o", "", "Output format: json for raw API response") + telemetryRoot.AddCommand(telemetryStop) + browsersCmd.AddCommand(telemetryRoot) + // process procRoot := &cobra.Command{Use: "process", Short: "Manage processes inside the browser VM"} procExec := &cobra.Command{Use: "exec [--] [command...]", Short: "Execute a command synchronously", Args: cobra.MinimumNArgs(1), RunE: runBrowsersProcessExec} @@ -2816,6 +2845,14 @@ func runBrowsersReplaysDownload(cmd *cobra.Command, args []string) error { return b.ReplaysDownload(cmd.Context(), BrowsersReplaysDownloadInput{Identifier: args[0], ReplayID: args[1], Output: out}) } +func runBrowsersTelemetryStop(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + svc := client.Browsers + out, _ := cmd.Flags().GetString("output") + b := BrowsersCmd{browsers: &svc} + return b.TelemetryStop(cmd.Context(), BrowsersTelemetryStopInput{Identifier: args[0], Output: out}) +} + func runBrowsersProcessExec(cmd *cobra.Command, args []string) error { client := getKernelClient(cmd) svc := client.Browsers diff --git a/cmd/browsers_test.go b/cmd/browsers_test.go index 98a4dab..438214f 100644 --- a/cmd/browsers_test.go +++ b/cmd/browsers_test.go @@ -1761,3 +1761,33 @@ func TestBrowsersUpdate_ForceWithProxyButNoViewport_Errors(t *testing.T) { assert.Error(t, err) assert.Contains(t, err.Error(), "--force requires --viewport") } + +func TestBrowsersTelemetryStop_SendsDisablePayload(t *testing.T) { + setupStdoutCapture(t) + var capturedID string + var captured kernel.BrowserUpdateParams + fake := &FakeBrowsersService{UpdateFunc: func(ctx context.Context, id string, body kernel.BrowserUpdateParams, opts ...option.RequestOption) (*kernel.BrowserUpdateResponse, error) { + capturedID = id + captured = body + return &kernel.BrowserUpdateResponse{SessionID: id}, nil + }} + b := BrowsersCmd{browsers: fake} + + err := b.TelemetryStop(context.Background(), BrowsersTelemetryStopInput{Identifier: "session123"}) + + assert.NoError(t, err) + assert.Equal(t, "session123", capturedID) + assert.True(t, captured.Telemetry.Enabled.Valid()) + assert.False(t, captured.Telemetry.Enabled.Value) + assert.Contains(t, outBuf.String(), "Stopped telemetry for browser session123") +} + +func TestBrowsersTelemetryStop_UnsupportedOutputErrors(t *testing.T) { + fake := &FakeBrowsersService{} + b := BrowsersCmd{browsers: fake} + + err := b.TelemetryStop(context.Background(), BrowsersTelemetryStopInput{Identifier: "session123", Output: "yaml"}) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "unsupported --output value") +} From e97378f1cc68caf0ec24614b64fac3f5f9c2abd0 Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Wed, 27 May 2026 14:05:09 -0300 Subject: [PATCH 05/28] feat: add browsers telemetry stream subcommand with NDJSON output --- cmd/browsers.go | 91 +++++++++++++++++++++++++++++++++++++++++++- cmd/browsers_test.go | 37 ++++++++++++++++++ 2 files changed, 127 insertions(+), 1 deletion(-) diff --git a/cmd/browsers.go b/cmd/browsers.go index 6d19cb1..5ce5b12 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -12,6 +12,7 @@ import ( "os" "path/filepath" "regexp" + "slices" "strconv" "strings" "time" @@ -87,6 +88,11 @@ type BrowserLogService interface { StreamStreaming(ctx context.Context, id string, query kernel.BrowserLogStreamParams, opts ...option.RequestOption) (stream *ssestream.Stream[shared.LogEvent]) } +// BrowserTelemetryService defines the subset we use for browser telemetry streaming. +type BrowserTelemetryService interface { + StreamStreaming(ctx context.Context, id string, query kernel.BrowserTelemetryStreamParams, opts ...option.RequestOption) (stream *ssestream.Stream[kernel.BrowserTelemetryStreamResponse]) +} + // BrowserPlaywrightService defines the subset we use for Playwright execution. type BrowserPlaywrightService interface { Execute(ctx context.Context, id string, body kernel.BrowserPlaywrightExecuteParams, opts ...option.RequestOption) (res *kernel.BrowserPlaywrightExecuteResponse, err error) @@ -223,6 +229,14 @@ type BrowsersTelemetryStopInput struct { Output string } +type BrowsersTelemetryStreamInput struct { + Identifier string + Categories []string + Types []string + Seq int64 + Output string +} + // BrowsersCmd is a cobra-independent command handler for browsers operations. type BrowsersCmd struct { browsers BrowsersService @@ -233,6 +247,7 @@ type BrowsersCmd struct { logs BrowserLogService computer BrowserComputerService playwright BrowserPlaywrightService + telemetry BrowserTelemetryService } type BrowsersListInput struct { @@ -678,6 +693,58 @@ func (b BrowsersCmd) TelemetryStop(ctx context.Context, in BrowsersTelemetryStop return nil } +// eventCategory derives the category prefix from a telemetry event type string. +// e.g. "network_response" -> "network", "monitor_screenshot" -> "monitor". +func eventCategory(eventType string) string { + if i := strings.Index(eventType, "_"); i > 0 { + return eventType[:i] + } + return eventType +} + +// shouldEmit applies client-side category/type filters to a telemetry event. +func shouldEmit(eventType string, categories, types []string) bool { + if len(categories) > 0 && !slices.Contains(categories, eventCategory(eventType)) { + return false + } + if len(types) > 0 && !slices.Contains(types, eventType) { + return false + } + return true +} + +func (b BrowsersCmd) TelemetryStream(ctx context.Context, in BrowsersTelemetryStreamInput) error { + if in.Output != "" && in.Output != "json" { + return fmt.Errorf("unsupported --output value: use 'json'") + } + br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + params := kernel.BrowserTelemetryStreamParams{} + if in.Seq > 0 { + params.LastEventID = kernel.Opt(strconv.FormatInt(in.Seq, 10)) + } + stream := b.telemetry.StreamStreaming(ctx, br.SessionID, params) + defer stream.Close() + for stream.Next() { + ev := stream.Current() + if !shouldEmit(ev.Event.Type, in.Categories, in.Types) { + continue + } + if in.Output == "json" { + _ = util.PrintCompactJSONLine(ev) + continue + } + ts := time.UnixMicro(ev.Event.Ts).Local().Format("15:04:05") + pterm.Printf("%s [%s] %s\n", ts, eventCategory(ev.Event.Type), ev.Event.Type) + } + if err := stream.Err(); err != nil { + return util.CleanedUpSdkError{Err: err} + } + return nil +} + // Logs type BrowsersLogsStreamInput struct { Identifier string @@ -2306,9 +2373,14 @@ func init() { // telemetry telemetryRoot := &cobra.Command{Use: "telemetry", Short: "Browser telemetry operations"} + telemetryStream := &cobra.Command{Use: "stream ", Short: "Stream live telemetry events", Args: cobra.ExactArgs(1), RunE: runBrowsersTelemetryStream} + telemetryStream.Flags().StringSlice("categories", []string{}, "Filter by category (console,network,page,interaction,monitor)") + telemetryStream.Flags().StringSlice("types", []string{}, "Filter by event type (e.g. network_response,console_error)") + telemetryStream.Flags().Int64("seq", 0, "Resume stream from sequence number (Last-Event-ID)") + telemetryStream.Flags().StringP("output", "o", "", "Output format: json for newline-delimited JSON envelopes") telemetryStop := &cobra.Command{Use: "stop ", Short: "Stop telemetry capture", Args: cobra.ExactArgs(1), RunE: runBrowsersTelemetryStop} telemetryStop.Flags().StringP("output", "o", "", "Output format: json for raw API response") - telemetryRoot.AddCommand(telemetryStop) + telemetryRoot.AddCommand(telemetryStream, telemetryStop) browsersCmd.AddCommand(telemetryRoot) // process @@ -2853,6 +2925,23 @@ func runBrowsersTelemetryStop(cmd *cobra.Command, args []string) error { return b.TelemetryStop(cmd.Context(), BrowsersTelemetryStopInput{Identifier: args[0], Output: out}) } +func runBrowsersTelemetryStream(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + svc := client.Browsers + out, _ := cmd.Flags().GetString("output") + categories, _ := cmd.Flags().GetStringSlice("categories") + types, _ := cmd.Flags().GetStringSlice("types") + seq, _ := cmd.Flags().GetInt64("seq") + b := BrowsersCmd{browsers: &svc, telemetry: &svc.Telemetry} + return b.TelemetryStream(cmd.Context(), BrowsersTelemetryStreamInput{ + Identifier: args[0], + Categories: categories, + Types: types, + Seq: seq, + Output: out, + }) +} + func runBrowsersProcessExec(cmd *cobra.Command, args []string) error { client := getKernelClient(cmd) svc := client.Browsers diff --git a/cmd/browsers_test.go b/cmd/browsers_test.go index 438214f..8602d47 100644 --- a/cmd/browsers_test.go +++ b/cmd/browsers_test.go @@ -1791,3 +1791,40 @@ func TestBrowsersTelemetryStop_UnsupportedOutputErrors(t *testing.T) { assert.Error(t, err) assert.Contains(t, err.Error(), "unsupported --output value") } + +func TestEventCategory(t *testing.T) { + cases := map[string]string{ + "network_response": "network", + "monitor_screenshot": "monitor", + "console_log": "console", + "page_navigation": "page", + "interaction_click": "interaction", + "nounderscore": "nounderscore", + } + for input, want := range cases { + assert.Equal(t, want, eventCategory(input), "eventCategory(%q)", input) + } +} + +func TestShouldEmit(t *testing.T) { + cases := []struct { + name string + eventType string + categories []string + types []string + want bool + }{ + {"no filters passes", "network_response", nil, nil, true}, + {"matching category passes", "network_response", []string{"network"}, nil, true}, + {"non-matching category drops", "console_log", []string{"network"}, nil, false}, + {"matching type passes", "console_log", nil, []string{"console_log"}, true}, + {"non-matching type drops", "network_response", nil, []string{"console_log"}, false}, + {"both filters pass when both match", "network_response", []string{"network"}, []string{"network_response"}, true}, + {"both filters drop when type misses", "network_response", []string{"network"}, []string{"console_log"}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, shouldEmit(tc.eventType, tc.categories, tc.types)) + }) + } +} From a4d8cd9c584e1cfbd8a64f6c95e067f26b3204f8 Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Wed, 27 May 2026 14:17:23 -0300 Subject: [PATCH 06/28] feat: add browsers telemetry start subcommand --- cmd/browsers.go | 34 +++++++++++++++++++++++++++++++++- cmd/browsers_test.go | 20 ++++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/cmd/browsers.go b/cmd/browsers.go index 5ce5b12..28ddefe 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -224,6 +224,11 @@ type BrowsersUpdateInput struct { Output string } +type BrowsersTelemetryStartInput struct { + Identifier string + Output string +} + type BrowsersTelemetryStopInput struct { Identifier string Output string @@ -676,6 +681,23 @@ func (b BrowsersCmd) Update(ctx context.Context, in BrowsersUpdateInput) error { return nil } +func (b BrowsersCmd) TelemetryStart(ctx context.Context, in BrowsersTelemetryStartInput) error { + if in.Output != "" && in.Output != "json" { + return fmt.Errorf("unsupported --output value: use 'json'") + } + res, err := b.browsers.Update(ctx, in.Identifier, kernel.BrowserUpdateParams{ + Telemetry: kernel.BrowserTelemetryRequestConfigParam{Enabled: kernel.Opt(true)}, + }) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + if in.Output == "json" { + return util.PrintPrettyJSON(res) + } + pterm.Success.Printf("Started telemetry for browser %s\n", in.Identifier) + return nil +} + func (b BrowsersCmd) TelemetryStop(ctx context.Context, in BrowsersTelemetryStopInput) error { if in.Output != "" && in.Output != "json" { return fmt.Errorf("unsupported --output value: use 'json'") @@ -2378,9 +2400,11 @@ func init() { telemetryStream.Flags().StringSlice("types", []string{}, "Filter by event type (e.g. network_response,console_error)") telemetryStream.Flags().Int64("seq", 0, "Resume stream from sequence number (Last-Event-ID)") telemetryStream.Flags().StringP("output", "o", "", "Output format: json for newline-delimited JSON envelopes") + telemetryStart := &cobra.Command{Use: "start ", Short: "Start telemetry capture", Args: cobra.ExactArgs(1), RunE: runBrowsersTelemetryStart} + telemetryStart.Flags().StringP("output", "o", "", "Output format: json for raw API response") telemetryStop := &cobra.Command{Use: "stop ", Short: "Stop telemetry capture", Args: cobra.ExactArgs(1), RunE: runBrowsersTelemetryStop} telemetryStop.Flags().StringP("output", "o", "", "Output format: json for raw API response") - telemetryRoot.AddCommand(telemetryStream, telemetryStop) + telemetryRoot.AddCommand(telemetryStream, telemetryStart, telemetryStop) browsersCmd.AddCommand(telemetryRoot) // process @@ -2917,6 +2941,14 @@ func runBrowsersReplaysDownload(cmd *cobra.Command, args []string) error { return b.ReplaysDownload(cmd.Context(), BrowsersReplaysDownloadInput{Identifier: args[0], ReplayID: args[1], Output: out}) } +func runBrowsersTelemetryStart(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + svc := client.Browsers + out, _ := cmd.Flags().GetString("output") + b := BrowsersCmd{browsers: &svc} + return b.TelemetryStart(cmd.Context(), BrowsersTelemetryStartInput{Identifier: args[0], Output: out}) +} + func runBrowsersTelemetryStop(cmd *cobra.Command, args []string) error { client := getKernelClient(cmd) svc := client.Browsers diff --git a/cmd/browsers_test.go b/cmd/browsers_test.go index 8602d47..5035108 100644 --- a/cmd/browsers_test.go +++ b/cmd/browsers_test.go @@ -1762,6 +1762,26 @@ func TestBrowsersUpdate_ForceWithProxyButNoViewport_Errors(t *testing.T) { assert.Contains(t, err.Error(), "--force requires --viewport") } +func TestBrowsersTelemetryStart_SendsEnablePayload(t *testing.T) { + setupStdoutCapture(t) + var capturedID string + var captured kernel.BrowserUpdateParams + fake := &FakeBrowsersService{UpdateFunc: func(ctx context.Context, id string, body kernel.BrowserUpdateParams, opts ...option.RequestOption) (*kernel.BrowserUpdateResponse, error) { + capturedID = id + captured = body + return &kernel.BrowserUpdateResponse{SessionID: id}, nil + }} + b := BrowsersCmd{browsers: fake} + + err := b.TelemetryStart(context.Background(), BrowsersTelemetryStartInput{Identifier: "session123"}) + + assert.NoError(t, err) + assert.Equal(t, "session123", capturedID) + assert.True(t, captured.Telemetry.Enabled.Valid()) + assert.True(t, captured.Telemetry.Enabled.Value) + assert.Contains(t, outBuf.String(), "Started telemetry for browser session123") +} + func TestBrowsersTelemetryStop_SendsDisablePayload(t *testing.T) { setupStdoutCapture(t) var capturedID string From 1ccc8c936d34d0ad5cb02c0e57ed30b1e3858d54 Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Wed, 27 May 2026 14:17:23 -0300 Subject: [PATCH 07/28] chore: document browser telemetry create flag, start, stop, and stream --- README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/README.md b/README.md index 2da35f3..810e2c6 100644 --- a/README.md +++ b/README.md @@ -211,6 +211,7 @@ Commands with JSON output support: - `--start-url ` - Initial page to open on launch - `--pool-id ` - Acquire a browser from the specified pool (mutually exclusive with --pool-name; ignores other session flags) - `--pool-name ` - Acquire a browser from the pool name (mutually exclusive with --pool-id; ignores other session flags) + - `--telemetry` - Enable telemetry capture on the new session (see `kernel browsers telemetry stream`) - `--output json`, `-o json` - Output raw JSON object - _Note: When a pool is specified, omit other session configuration flags—pool settings determine profile, proxy, viewport, etc._ - `kernel browsers delete ` - Delete a browser @@ -280,6 +281,18 @@ Commands with JSON output support: - `kernel browsers replays download ` - Download a replay video - `-f, --output-file ` - Output file path for the replay video +### Browser Telemetry + +- `kernel browsers telemetry stream ` - Stream live telemetry events + - `--categories ` - Filter by category (console,network,page,interaction,monitor) + - `--types ` - Filter by event type (e.g. network_response,console_error) + - `--seq ` - Resume stream from sequence number (Last-Event-ID) + - `-o, --output json` - Output newline-delimited JSON envelopes +- `kernel browsers telemetry start ` - Start telemetry capture on a running session + - `-o, --output json` - Output raw API response +- `kernel browsers telemetry stop ` - Stop telemetry capture on a running session + - `-o, --output json` - Output raw API response + ### Browser Process Control - `kernel browsers process exec [--] [command...]` - Execute a command synchronously From 9e4ef958b6404bd39686cfb2e10d7cb04c1e9f55 Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Wed, 27 May 2026 15:37:32 -0300 Subject: [PATCH 08/28] feat: add browsers telemetry set, status subcommands and simplify start/stop --- README.md | 15 ++++-- cmd/browsers.go | 122 ++++++++++++++++++++++++++++++++++++++++--- cmd/browsers_test.go | 67 ++++++++++++++++++++++++ 3 files changed, 191 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 810e2c6..32cab6b 100644 --- a/README.md +++ b/README.md @@ -211,7 +211,7 @@ Commands with JSON output support: - `--start-url ` - Initial page to open on launch - `--pool-id ` - Acquire a browser from the specified pool (mutually exclusive with --pool-name; ignores other session flags) - `--pool-name ` - Acquire a browser from the pool name (mutually exclusive with --pool-id; ignores other session flags) - - `--telemetry` - Enable telemetry capture on the new session (see `kernel browsers telemetry stream`) + - `--telemetry` - Enable telemetry capture on the new session (`enabled: true`) - `--output json`, `-o json` - Output raw JSON object - _Note: When a pool is specified, omit other session configuration flags—pool settings determine profile, proxy, viewport, etc._ - `kernel browsers delete ` - Delete a browser @@ -283,15 +283,20 @@ Commands with JSON output support: ### Browser Telemetry +- `kernel browsers telemetry start ` - Start telemetry capture (`enabled: true`) + - `-o, --output json` - Output raw API response +- `kernel browsers telemetry stop ` - Stop telemetry capture (`enabled: false`) + - `-o, --output json` - Output raw API response +- `kernel browsers telemetry set ` - Set per-category telemetry config + - `--categories ` - Comma-separated `name=on|off` pairs, e.g. `network=on,page=off` (console, interaction, network, page) + - `-o, --output json` - Output raw API response +- `kernel browsers telemetry status ` - Show current telemetry configuration + - `-o, --output json` - Output raw JSON telemetry config - `kernel browsers telemetry stream ` - Stream live telemetry events - `--categories ` - Filter by category (console,network,page,interaction,monitor) - `--types ` - Filter by event type (e.g. network_response,console_error) - `--seq ` - Resume stream from sequence number (Last-Event-ID) - `-o, --output json` - Output newline-delimited JSON envelopes -- `kernel browsers telemetry start ` - Start telemetry capture on a running session - - `-o, --output json` - Output raw API response -- `kernel browsers telemetry stop ` - Stop telemetry capture on a running session - - `-o, --output json` - Output raw API response ### Browser Process Control diff --git a/cmd/browsers.go b/cmd/browsers.go index 28ddefe..5be9a6f 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -193,8 +193,8 @@ type BrowsersCreateInput struct { StartURL string Extensions []string Viewport string - TelemetryEnabled bool - Output string + TelemetryEnabled bool + Output string } type BrowsersDeleteInput struct { @@ -234,6 +234,17 @@ type BrowsersTelemetryStopInput struct { Output string } +type BrowsersTelemetrySetInput struct { + Identifier string + Categories string // e.g. "network=on,page=off" + Output string +} + +type BrowsersTelemetryStatusInput struct { + Identifier string + Output string +} + type BrowsersTelemetryStreamInput struct { Identifier string Categories []string @@ -715,6 +726,78 @@ func (b BrowsersCmd) TelemetryStop(ctx context.Context, in BrowsersTelemetryStop return nil } +func (b BrowsersCmd) TelemetrySet(ctx context.Context, in BrowsersTelemetrySetInput) error { + if in.Output != "" && in.Output != "json" { + return fmt.Errorf("unsupported --output value: use 'json'") + } + p := kernel.BrowserTelemetryCategoriesConfigParam{} + for _, part := range strings.Split(in.Categories, ",") { + kv := strings.SplitN(strings.TrimSpace(part), "=", 2) + if len(kv) != 2 { + return fmt.Errorf("invalid category assignment %q: expected name=on or name=off", part) + } + name, val := strings.TrimSpace(kv[0]), strings.TrimSpace(kv[1]) + var enabled bool + switch val { + case "on": + enabled = true + case "off": + enabled = false + default: + return fmt.Errorf("invalid value %q for category %q: must be 'on' or 'off'", val, name) + } + switch name { + case "console": + p.Console = kernel.BrowserTelemetryCategoryConfigParam{Enabled: kernel.Opt(enabled)} + case "interaction": + p.Interaction = kernel.BrowserTelemetryCategoryConfigParam{Enabled: kernel.Opt(enabled)} + case "network": + p.Network = kernel.BrowserTelemetryCategoryConfigParam{Enabled: kernel.Opt(enabled)} + case "page": + p.Page = kernel.BrowserTelemetryCategoryConfigParam{Enabled: kernel.Opt(enabled)} + default: + return fmt.Errorf("unknown category %q: must be one of console, interaction, network, page", name) + } + } + res, err := b.browsers.Update(ctx, in.Identifier, kernel.BrowserUpdateParams{ + Telemetry: kernel.BrowserTelemetryRequestConfigParam{Browser: p}, + }) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + if in.Output == "json" { + return util.PrintPrettyJSON(res) + } + pterm.Success.Printf("Updated telemetry categories for browser %s\n", in.Identifier) + return nil +} + +func (b BrowsersCmd) TelemetryStatus(ctx context.Context, in BrowsersTelemetryStatusInput) error { + if in.Output != "" && in.Output != "json" { + return fmt.Errorf("unsupported --output value: use 'json'") + } + browser, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + if in.Output == "json" { + return util.PrintPrettyJSON(browser.Telemetry) + } + cfg := browser.Telemetry.Browser + pterm.Printf("console: %s\n", onOff(cfg.Console.Enabled)) + pterm.Printf("interaction: %s\n", onOff(cfg.Interaction.Enabled)) + pterm.Printf("network: %s\n", onOff(cfg.Network.Enabled)) + pterm.Printf("page: %s\n", onOff(cfg.Page.Enabled)) + return nil +} + +func onOff(v bool) string { + if v { + return "on" + } + return "off" +} + // eventCategory derives the category prefix from a telemetry event type string. // e.g. "network_response" -> "network", "monitor_screenshot" -> "monitor". func eventCategory(eventType string) string { @@ -2400,11 +2483,17 @@ func init() { telemetryStream.Flags().StringSlice("types", []string{}, "Filter by event type (e.g. network_response,console_error)") telemetryStream.Flags().Int64("seq", 0, "Resume stream from sequence number (Last-Event-ID)") telemetryStream.Flags().StringP("output", "o", "", "Output format: json for newline-delimited JSON envelopes") - telemetryStart := &cobra.Command{Use: "start ", Short: "Start telemetry capture", Args: cobra.ExactArgs(1), RunE: runBrowsersTelemetryStart} + telemetryStart := &cobra.Command{Use: "start ", Short: "Start telemetry capture (enabled: true)", Args: cobra.ExactArgs(1), RunE: runBrowsersTelemetryStart} telemetryStart.Flags().StringP("output", "o", "", "Output format: json for raw API response") - telemetryStop := &cobra.Command{Use: "stop ", Short: "Stop telemetry capture", Args: cobra.ExactArgs(1), RunE: runBrowsersTelemetryStop} + telemetryStop := &cobra.Command{Use: "stop ", Short: "Stop telemetry capture (enabled: false)", Args: cobra.ExactArgs(1), RunE: runBrowsersTelemetryStop} telemetryStop.Flags().StringP("output", "o", "", "Output format: json for raw API response") - telemetryRoot.AddCommand(telemetryStream, telemetryStart, telemetryStop) + telemetrySet := &cobra.Command{Use: "set ", Short: "Set per-category telemetry config", Args: cobra.ExactArgs(1), RunE: runBrowsersTelemetrySet} + telemetrySet.Flags().String("categories", "", "Per-category assignments, e.g. network=on,page=off (console, interaction, network, page)") + _ = telemetrySet.MarkFlagRequired("categories") + telemetrySet.Flags().StringP("output", "o", "", "Output format: json for raw API response") + telemetryStatus := &cobra.Command{Use: "status ", Short: "Show current telemetry configuration", Args: cobra.ExactArgs(1), RunE: runBrowsersTelemetryStatus} + telemetryStatus.Flags().StringP("output", "o", "", "Output format: json for raw API response") + telemetryRoot.AddCommand(telemetryStream, telemetryStart, telemetryStop, telemetrySet, telemetryStatus) browsersCmd.AddCommand(telemetryRoot) // process @@ -2636,7 +2725,7 @@ func init() { browsersCreateCmd.Flags().Bool("viewport-interactive", false, "Interactively select viewport size from list") browsersCreateCmd.Flags().String("pool-id", "", "Browser pool ID to acquire from (mutually exclusive with --pool-name)") browsersCreateCmd.Flags().String("pool-name", "", "Browser pool name to acquire from (mutually exclusive with --pool-id)") - browsersCreateCmd.Flags().Bool("telemetry", false, "Enable telemetry capture on the new session") + browsersCreateCmd.Flags().Bool("telemetry", false, "Enable telemetry capture on the new session (enabled: true)") // curl curlCmd := &cobra.Command{ @@ -2816,8 +2905,8 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { StartURL: startURL, Extensions: extensions, Viewport: viewport, - TelemetryEnabled: telemetry, - Output: output, + TelemetryEnabled: telemetry, + Output: output, } svc := client.Browsers @@ -2957,6 +3046,23 @@ func runBrowsersTelemetryStop(cmd *cobra.Command, args []string) error { return b.TelemetryStop(cmd.Context(), BrowsersTelemetryStopInput{Identifier: args[0], Output: out}) } +func runBrowsersTelemetrySet(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + svc := client.Browsers + out, _ := cmd.Flags().GetString("output") + categories, _ := cmd.Flags().GetString("categories") + b := BrowsersCmd{browsers: &svc} + return b.TelemetrySet(cmd.Context(), BrowsersTelemetrySetInput{Identifier: args[0], Categories: categories, Output: out}) +} + +func runBrowsersTelemetryStatus(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + svc := client.Browsers + out, _ := cmd.Flags().GetString("output") + b := BrowsersCmd{browsers: &svc} + return b.TelemetryStatus(cmd.Context(), BrowsersTelemetryStatusInput{Identifier: args[0], Output: out}) +} + func runBrowsersTelemetryStream(cmd *cobra.Command, args []string) error { client := getKernelClient(cmd) svc := client.Browsers diff --git a/cmd/browsers_test.go b/cmd/browsers_test.go index 5035108..c16fa9f 100644 --- a/cmd/browsers_test.go +++ b/cmd/browsers_test.go @@ -1802,6 +1802,46 @@ func TestBrowsersTelemetryStop_SendsDisablePayload(t *testing.T) { assert.Contains(t, outBuf.String(), "Stopped telemetry for browser session123") } +func TestBrowsersTelemetrySet_PartialCategories(t *testing.T) { + setupStdoutCapture(t) + var captured kernel.BrowserUpdateParams + fake := &FakeBrowsersService{UpdateFunc: func(ctx context.Context, id string, body kernel.BrowserUpdateParams, opts ...option.RequestOption) (*kernel.BrowserUpdateResponse, error) { + captured = body + return &kernel.BrowserUpdateResponse{SessionID: id}, nil + }} + b := BrowsersCmd{browsers: fake} + + err := b.TelemetrySet(context.Background(), BrowsersTelemetrySetInput{Identifier: "session123", Categories: "network=on,page=off"}) + + assert.NoError(t, err) + assert.False(t, captured.Telemetry.Enabled.Valid()) + assert.True(t, captured.Telemetry.Browser.Network.Enabled.Valid()) + assert.True(t, captured.Telemetry.Browser.Network.Enabled.Value) + assert.True(t, captured.Telemetry.Browser.Page.Enabled.Valid()) + assert.False(t, captured.Telemetry.Browser.Page.Enabled.Value) + // Unspecified categories omitted — server retains their state + assert.False(t, captured.Telemetry.Browser.Console.Enabled.Valid()) + assert.False(t, captured.Telemetry.Browser.Interaction.Enabled.Valid()) +} + +func TestBrowsersTelemetrySet_InvalidCategory(t *testing.T) { + b := BrowsersCmd{browsers: &FakeBrowsersService{}} + + err := b.TelemetrySet(context.Background(), BrowsersTelemetrySetInput{Identifier: "session123", Categories: "foo=on"}) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "unknown category") +} + +func TestBrowsersTelemetrySet_InvalidValue(t *testing.T) { + b := BrowsersCmd{browsers: &FakeBrowsersService{}} + + err := b.TelemetrySet(context.Background(), BrowsersTelemetrySetInput{Identifier: "session123", Categories: "network=yes"}) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "must be 'on' or 'off'") +} + func TestBrowsersTelemetryStop_UnsupportedOutputErrors(t *testing.T) { fake := &FakeBrowsersService{} b := BrowsersCmd{browsers: fake} @@ -1812,6 +1852,33 @@ func TestBrowsersTelemetryStop_UnsupportedOutputErrors(t *testing.T) { assert.Contains(t, err.Error(), "unsupported --output value") } +func TestBrowsersTelemetryStatus_PrintsCategories(t *testing.T) { + setupStdoutCapture(t) + fake := &FakeBrowsersService{GetFunc: func(ctx context.Context, id string, query kernel.BrowserGetParams, opts ...option.RequestOption) (*kernel.BrowserGetResponse, error) { + return &kernel.BrowserGetResponse{ + SessionID: id, + Telemetry: kernel.BrowserTelemetryConfig{ + Browser: kernel.BrowserTelemetryCategoriesConfig{ + Console: kernel.BrowserTelemetryCategoryConfig{Enabled: true}, + Interaction: kernel.BrowserTelemetryCategoryConfig{Enabled: false}, + Network: kernel.BrowserTelemetryCategoryConfig{Enabled: true}, + Page: kernel.BrowserTelemetryCategoryConfig{Enabled: false}, + }, + }, + }, nil + }} + b := BrowsersCmd{browsers: fake} + + err := b.TelemetryStatus(context.Background(), BrowsersTelemetryStatusInput{Identifier: "session123"}) + + assert.NoError(t, err) + out := outBuf.String() + assert.Contains(t, out, "console: on") + assert.Contains(t, out, "interaction: off") + assert.Contains(t, out, "network: on") + assert.Contains(t, out, "page: off") +} + func TestEventCategory(t *testing.T) { cases := map[string]string{ "network_response": "network", From 116851b4fcbd3fdf5d3eca4ef929a1b78fa77c31 Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Wed, 27 May 2026 16:16:52 -0300 Subject: [PATCH 09/28] review: address browser telemetry code review feedback --- README.md | 8 ++-- cmd/browsers.go | 78 +++++++++++++++++++++++--------- cmd/browsers_test.go | 105 +++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 163 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 32cab6b..be9c992 100644 --- a/README.md +++ b/README.md @@ -211,7 +211,8 @@ Commands with JSON output support: - `--start-url ` - Initial page to open on launch - `--pool-id ` - Acquire a browser from the specified pool (mutually exclusive with --pool-name; ignores other session flags) - `--pool-name ` - Acquire a browser from the pool name (mutually exclusive with --pool-id; ignores other session flags) - - `--telemetry` - Enable telemetry capture on the new session (`enabled: true`) + - `--telemetry` - Enable telemetry for all categories (`enabled: true`) + - `--telemetry=` - Per-category config at create time, e.g. `--telemetry=network=on,page=off` (same syntax as `telemetry set`) - `--output json`, `-o json` - Output raw JSON object - _Note: When a pool is specified, omit other session configuration flags—pool settings determine profile, proxy, viewport, etc._ - `kernel browsers delete ` - Delete a browser @@ -287,13 +288,12 @@ Commands with JSON output support: - `-o, --output json` - Output raw API response - `kernel browsers telemetry stop ` - Stop telemetry capture (`enabled: false`) - `-o, --output json` - Output raw API response -- `kernel browsers telemetry set ` - Set per-category telemetry config - - `--categories ` - Comma-separated `name=on|off` pairs, e.g. `network=on,page=off` (console, interaction, network, page) +- `kernel browsers telemetry set ...` - Set per-category telemetry config, e.g. `network=on page=off` - `-o, --output json` - Output raw API response - `kernel browsers telemetry status ` - Show current telemetry configuration - `-o, --output json` - Output raw JSON telemetry config - `kernel browsers telemetry stream ` - Stream live telemetry events - - `--categories ` - Filter by category (console,network,page,interaction,monitor) + - `--categories ` - Filter by category (console,network,page,interaction,monitor); `monitor` is always-on and filter-only — cannot be toggled via `telemetry set` - `--types ` - Filter by event type (e.g. network_response,console_error) - `--seq ` - Resume stream from sequence number (Last-Event-ID) - `-o, --output json` - Output newline-delimited JSON envelopes diff --git a/cmd/browsers.go b/cmd/browsers.go index 5be9a6f..de1b431 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -193,8 +193,8 @@ type BrowsersCreateInput struct { StartURL string Extensions []string Viewport string - TelemetryEnabled bool - Output string + Telemetry string + Output string } type BrowsersDeleteInput struct { @@ -459,8 +459,14 @@ func (b BrowsersCmd) Create(ctx context.Context, in BrowsersCreateInput) error { } } - if in.TelemetryEnabled { + if in.Telemetry == "all" { params.Telemetry = kernel.BrowserTelemetryRequestConfigParam{Enabled: kernel.Opt(true)} + } else if in.Telemetry != "" { + p, err := parseTelemetryCategories(in.Telemetry) + if err != nil { + return err + } + params.Telemetry = kernel.BrowserTelemetryRequestConfigParam{Browser: p} } browser, err := b.browsers.New(ctx, params) @@ -726,15 +732,14 @@ func (b BrowsersCmd) TelemetryStop(ctx context.Context, in BrowsersTelemetryStop return nil } -func (b BrowsersCmd) TelemetrySet(ctx context.Context, in BrowsersTelemetrySetInput) error { - if in.Output != "" && in.Output != "json" { - return fmt.Errorf("unsupported --output value: use 'json'") - } +// parseTelemetryCategories parses a comma-separated "name=on|off" string into +// a BrowserTelemetryCategoriesConfigParam. Unmentioned categories are omitted. +func parseTelemetryCategories(s string) (kernel.BrowserTelemetryCategoriesConfigParam, error) { p := kernel.BrowserTelemetryCategoriesConfigParam{} - for _, part := range strings.Split(in.Categories, ",") { + for _, part := range strings.Split(s, ",") { kv := strings.SplitN(strings.TrimSpace(part), "=", 2) if len(kv) != 2 { - return fmt.Errorf("invalid category assignment %q: expected name=on or name=off", part) + return p, fmt.Errorf("invalid category assignment %q: expected name=on or name=off", part) } name, val := strings.TrimSpace(kv[0]), strings.TrimSpace(kv[1]) var enabled bool @@ -744,7 +749,7 @@ func (b BrowsersCmd) TelemetrySet(ctx context.Context, in BrowsersTelemetrySetIn case "off": enabled = false default: - return fmt.Errorf("invalid value %q for category %q: must be 'on' or 'off'", val, name) + return p, fmt.Errorf("invalid value %q for category %q: must be 'on' or 'off'", val, name) } switch name { case "console": @@ -756,9 +761,20 @@ func (b BrowsersCmd) TelemetrySet(ctx context.Context, in BrowsersTelemetrySetIn case "page": p.Page = kernel.BrowserTelemetryCategoryConfigParam{Enabled: kernel.Opt(enabled)} default: - return fmt.Errorf("unknown category %q: must be one of console, interaction, network, page", name) + return p, fmt.Errorf("unknown category %q: must be one of console, interaction, network, page", name) } } + return p, nil +} + +func (b BrowsersCmd) TelemetrySet(ctx context.Context, in BrowsersTelemetrySetInput) error { + if in.Output != "" && in.Output != "json" { + return fmt.Errorf("unsupported --output value: use 'json'") + } + p, err := parseTelemetryCategories(in.Categories) + if err != nil { + return err + } res, err := b.browsers.Update(ctx, in.Identifier, kernel.BrowserUpdateParams{ Telemetry: kernel.BrowserTelemetryRequestConfigParam{Browser: p}, }) @@ -818,10 +834,32 @@ func shouldEmit(eventType string, categories, types []string) bool { return true } +var knownTelemetryCategories = []string{"console", "network", "page", "interaction", "monitor"} + +var knownTelemetryTypes = []string{ + "console_log", "console_error", + "network_request", "network_response", "network_loading_failed", "network_idle", + "page_navigation", "page_dom_content_loaded", "page_load", "page_tab_opened", + "page_layout_shift", "page_lcp", "page_layout_settled", "page_navigation_settled", + "interaction_click", "interaction_key", "interaction_scroll_settled", + "monitor_screenshot", "monitor_disconnected", "monitor_reconnected", + "monitor_reconnect_failed", "monitor_init_failed", +} + func (b BrowsersCmd) TelemetryStream(ctx context.Context, in BrowsersTelemetryStreamInput) error { if in.Output != "" && in.Output != "json" { return fmt.Errorf("unsupported --output value: use 'json'") } + for _, c := range in.Categories { + if !slices.Contains(knownTelemetryCategories, c) { + return fmt.Errorf("unknown category %q: must be one of %s", c, strings.Join(knownTelemetryCategories, ", ")) + } + } + for _, t := range in.Types { + if !slices.Contains(knownTelemetryTypes, t) { + pterm.Warning.Printf("unrecognized event type %q — no events will match\n", t) + } + } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { return util.CleanedUpSdkError{Err: err} @@ -2479,7 +2517,7 @@ func init() { // telemetry telemetryRoot := &cobra.Command{Use: "telemetry", Short: "Browser telemetry operations"} telemetryStream := &cobra.Command{Use: "stream ", Short: "Stream live telemetry events", Args: cobra.ExactArgs(1), RunE: runBrowsersTelemetryStream} - telemetryStream.Flags().StringSlice("categories", []string{}, "Filter by category (console,network,page,interaction,monitor)") + telemetryStream.Flags().StringSlice("categories", []string{}, "Filter by category (console,network,page,interaction,monitor); monitor is always-on and filter-only — it cannot be toggled via telemetry set") telemetryStream.Flags().StringSlice("types", []string{}, "Filter by event type (e.g. network_response,console_error)") telemetryStream.Flags().Int64("seq", 0, "Resume stream from sequence number (Last-Event-ID)") telemetryStream.Flags().StringP("output", "o", "", "Output format: json for newline-delimited JSON envelopes") @@ -2487,9 +2525,7 @@ func init() { telemetryStart.Flags().StringP("output", "o", "", "Output format: json for raw API response") telemetryStop := &cobra.Command{Use: "stop ", Short: "Stop telemetry capture (enabled: false)", Args: cobra.ExactArgs(1), RunE: runBrowsersTelemetryStop} telemetryStop.Flags().StringP("output", "o", "", "Output format: json for raw API response") - telemetrySet := &cobra.Command{Use: "set ", Short: "Set per-category telemetry config", Args: cobra.ExactArgs(1), RunE: runBrowsersTelemetrySet} - telemetrySet.Flags().String("categories", "", "Per-category assignments, e.g. network=on,page=off (console, interaction, network, page)") - _ = telemetrySet.MarkFlagRequired("categories") + telemetrySet := &cobra.Command{Use: "set ...", Short: "Set per-category telemetry config", Args: cobra.MinimumNArgs(2), RunE: runBrowsersTelemetrySet} telemetrySet.Flags().StringP("output", "o", "", "Output format: json for raw API response") telemetryStatus := &cobra.Command{Use: "status ", Short: "Show current telemetry configuration", Args: cobra.ExactArgs(1), RunE: runBrowsersTelemetryStatus} telemetryStatus.Flags().StringP("output", "o", "", "Output format: json for raw API response") @@ -2725,7 +2761,8 @@ func init() { browsersCreateCmd.Flags().Bool("viewport-interactive", false, "Interactively select viewport size from list") browsersCreateCmd.Flags().String("pool-id", "", "Browser pool ID to acquire from (mutually exclusive with --pool-name)") browsersCreateCmd.Flags().String("pool-name", "", "Browser pool name to acquire from (mutually exclusive with --pool-id)") - browsersCreateCmd.Flags().Bool("telemetry", false, "Enable telemetry capture on the new session (enabled: true)") + browsersCreateCmd.Flags().String("telemetry", "", "Enable telemetry: --telemetry for all, --telemetry=network=on,page=off for per-category") + browsersCreateCmd.Flag("telemetry").NoOptDefVal = "all" // curl curlCmd := &cobra.Command{ @@ -2795,7 +2832,7 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { viewportInteractive, _ := cmd.Flags().GetBool("viewport-interactive") poolID, _ := cmd.Flags().GetString("pool-id") poolName, _ := cmd.Flags().GetString("pool-name") - telemetry, _ := cmd.Flags().GetBool("telemetry") + telemetry, _ := cmd.Flags().GetString("telemetry") output, _ := cmd.Flags().GetString("output") if poolID != "" && poolName != "" { @@ -2905,8 +2942,8 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { StartURL: startURL, Extensions: extensions, Viewport: viewport, - TelemetryEnabled: telemetry, - Output: output, + Telemetry: telemetry, + Output: output, } svc := client.Browsers @@ -3050,9 +3087,8 @@ func runBrowsersTelemetrySet(cmd *cobra.Command, args []string) error { client := getKernelClient(cmd) svc := client.Browsers out, _ := cmd.Flags().GetString("output") - categories, _ := cmd.Flags().GetString("categories") b := BrowsersCmd{browsers: &svc} - return b.TelemetrySet(cmd.Context(), BrowsersTelemetrySetInput{Identifier: args[0], Categories: categories, Output: out}) + return b.TelemetrySet(cmd.Context(), BrowsersTelemetrySetInput{Identifier: args[0], Categories: strings.Join(args[1:], ","), Output: out}) } func runBrowsersTelemetryStatus(cmd *cobra.Command, args []string) error { diff --git a/cmd/browsers_test.go b/cmd/browsers_test.go index c16fa9f..4f3558d 100644 --- a/cmd/browsers_test.go +++ b/cmd/browsers_test.go @@ -911,6 +911,12 @@ func (f *FakeProcessService) StdoutStreamStreaming(ctx context.Context, processI return makeStream([]kernel.BrowserProcessStdoutStreamResponse{{Stream: kernel.BrowserProcessStdoutStreamResponseStreamStdout, DataB64: "aGVsbG8=", Event: ""}, {Event: "exit", ExitCode: 0}}) } +type FakeBrowserTelemetryService struct{} + +func (f *FakeBrowserTelemetryService) StreamStreaming(ctx context.Context, id string, query kernel.BrowserTelemetryStreamParams, opts ...option.RequestOption) *ssestream.Stream[kernel.BrowserTelemetryStreamResponse] { + return makeStream([]kernel.BrowserTelemetryStreamResponse{}) +} + type FakeLogService struct { StreamFunc func(ctx context.Context, id string, query kernel.BrowserLogStreamParams, opts ...option.RequestOption) *ssestream.Stream[shared.LogEvent] } @@ -1652,13 +1658,34 @@ func TestBrowsersCreate_WithTelemetry(t *testing.T) { }} b := BrowsersCmd{browsers: fake} - err := b.Create(context.Background(), BrowsersCreateInput{TelemetryEnabled: true}) + err := b.Create(context.Background(), BrowsersCreateInput{Telemetry: "all"}) assert.NoError(t, err) assert.True(t, captured.Telemetry.Enabled.Valid()) assert.True(t, captured.Telemetry.Enabled.Value) } +func TestBrowsersCreate_WithTelemetryCategories(t *testing.T) { + setupStdoutCapture(t) + var captured kernel.BrowserNewParams + fake := &FakeBrowsersService{NewFunc: func(ctx context.Context, body kernel.BrowserNewParams, opts ...option.RequestOption) (*kernel.BrowserNewResponse, error) { + captured = body + return &kernel.BrowserNewResponse{SessionID: "session123", CdpWsURL: "ws://example"}, nil + }} + b := BrowsersCmd{browsers: fake} + + err := b.Create(context.Background(), BrowsersCreateInput{Telemetry: "network=on,page=off"}) + + assert.NoError(t, err) + assert.False(t, captured.Telemetry.Enabled.Valid()) + assert.True(t, captured.Telemetry.Browser.Network.Enabled.Valid()) + assert.True(t, captured.Telemetry.Browser.Network.Enabled.Value) + assert.True(t, captured.Telemetry.Browser.Page.Enabled.Valid()) + assert.False(t, captured.Telemetry.Browser.Page.Enabled.Value) + assert.False(t, captured.Telemetry.Browser.Console.Enabled.Valid()) + assert.False(t, captured.Telemetry.Browser.Interaction.Enabled.Valid()) +} + func TestBrowsersCreate_WithoutTelemetry(t *testing.T) { setupStdoutCapture(t) var captured kernel.BrowserNewParams @@ -1842,9 +1869,17 @@ func TestBrowsersTelemetrySet_InvalidValue(t *testing.T) { assert.Contains(t, err.Error(), "must be 'on' or 'off'") } +func TestBrowsersTelemetryStart_UnsupportedOutputErrors(t *testing.T) { + b := BrowsersCmd{browsers: &FakeBrowsersService{}} + + err := b.TelemetryStart(context.Background(), BrowsersTelemetryStartInput{Identifier: "session123", Output: "yaml"}) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "unsupported --output value") +} + func TestBrowsersTelemetryStop_UnsupportedOutputErrors(t *testing.T) { - fake := &FakeBrowsersService{} - b := BrowsersCmd{browsers: fake} + b := BrowsersCmd{browsers: &FakeBrowsersService{}} err := b.TelemetryStop(context.Background(), BrowsersTelemetryStopInput{Identifier: "session123", Output: "yaml"}) @@ -1852,6 +1887,42 @@ func TestBrowsersTelemetryStop_UnsupportedOutputErrors(t *testing.T) { assert.Contains(t, err.Error(), "unsupported --output value") } +func TestBrowsersTelemetrySet_UnsupportedOutputErrors(t *testing.T) { + b := BrowsersCmd{browsers: &FakeBrowsersService{}} + + err := b.TelemetrySet(context.Background(), BrowsersTelemetrySetInput{Identifier: "session123", Categories: "network=on", Output: "yaml"}) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "unsupported --output value") +} + +func TestBrowsersTelemetryStatus_UnsupportedOutputErrors(t *testing.T) { + b := BrowsersCmd{browsers: &FakeBrowsersService{}} + + err := b.TelemetryStatus(context.Background(), BrowsersTelemetryStatusInput{Identifier: "session123", Output: "yaml"}) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "unsupported --output value") +} + +func TestBrowsersTelemetrySet_WhitespaceTolerance(t *testing.T) { + setupStdoutCapture(t) + var captured kernel.BrowserUpdateParams + fake := &FakeBrowsersService{UpdateFunc: func(ctx context.Context, id string, body kernel.BrowserUpdateParams, opts ...option.RequestOption) (*kernel.BrowserUpdateResponse, error) { + captured = body + return &kernel.BrowserUpdateResponse{SessionID: id}, nil + }} + b := BrowsersCmd{browsers: fake} + + err := b.TelemetrySet(context.Background(), BrowsersTelemetrySetInput{Identifier: "session123", Categories: " network = on , page = off "}) + + assert.NoError(t, err) + assert.True(t, captured.Telemetry.Browser.Network.Enabled.Valid()) + assert.True(t, captured.Telemetry.Browser.Network.Enabled.Value) + assert.True(t, captured.Telemetry.Browser.Page.Enabled.Valid()) + assert.False(t, captured.Telemetry.Browser.Page.Enabled.Value) +} + func TestBrowsersTelemetryStatus_PrintsCategories(t *testing.T) { setupStdoutCapture(t) fake := &FakeBrowsersService{GetFunc: func(ctx context.Context, id string, query kernel.BrowserGetParams, opts ...option.RequestOption) (*kernel.BrowserGetResponse, error) { @@ -1879,6 +1950,34 @@ func TestBrowsersTelemetryStatus_PrintsCategories(t *testing.T) { assert.Contains(t, out, "page: off") } +func TestTelemetryStream_UnknownCategoryErrors(t *testing.T) { + b := BrowsersCmd{browsers: &FakeBrowsersService{}} + + err := b.TelemetryStream(context.Background(), BrowsersTelemetryStreamInput{ + Identifier: "session123", + Categories: []string{"invalid"}, + }) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "unknown category") +} + +func TestTelemetryStream_UnknownTypeWarns(t *testing.T) { + setupStdoutCapture(t) + fake := &FakeBrowsersService{GetFunc: func(ctx context.Context, id string, query kernel.BrowserGetParams, opts ...option.RequestOption) (*kernel.BrowserGetResponse, error) { + return &kernel.BrowserGetResponse{SessionID: id}, nil + }} + b := BrowsersCmd{browsers: fake, telemetry: &FakeBrowserTelemetryService{}} + + err := b.TelemetryStream(context.Background(), BrowsersTelemetryStreamInput{ + Identifier: "session123", + Types: []string{"invalid_type"}, + }) + + assert.NoError(t, err) + assert.Contains(t, outBuf.String(), "unrecognized event type") +} + func TestEventCategory(t *testing.T) { cases := map[string]string{ "network_response": "network", From bd84338db1a1a3d5428ff1b89598c49457ebc5af Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Wed, 27 May 2026 16:40:14 -0300 Subject: [PATCH 10/28] fix: status misreports off when telemetry uses VM defaults --- cmd/browsers.go | 53 ++++++++++++++++--------- cmd/browsers_test.go | 92 +++++++++++++++++++++++++++++--------------- 2 files changed, 96 insertions(+), 49 deletions(-) diff --git a/cmd/browsers.go b/cmd/browsers.go index de1b431..52c0000 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -800,41 +800,56 @@ func (b BrowsersCmd) TelemetryStatus(ctx context.Context, in BrowsersTelemetrySt return util.PrintPrettyJSON(browser.Telemetry) } cfg := browser.Telemetry.Browser - pterm.Printf("console: %s\n", onOff(cfg.Console.Enabled)) - pterm.Printf("interaction: %s\n", onOff(cfg.Interaction.Enabled)) - pterm.Printf("network: %s\n", onOff(cfg.Network.Enabled)) - pterm.Printf("page: %s\n", onOff(cfg.Page.Enabled)) + pterm.Printf("console: %s\n", categoryOnOff(cfg.Console)) + pterm.Printf("interaction: %s\n", categoryOnOff(cfg.Interaction)) + pterm.Printf("network: %s\n", categoryOnOff(cfg.Network)) + pterm.Printf("page: %s\n", categoryOnOff(cfg.Page)) return nil } -func onOff(v bool) string { - if v { +// categoryOnOff returns "on" or "off" for a category config, respecting the SDK +// default: if the enabled field is absent from the response, it defaults to true. +func categoryOnOff(c kernel.BrowserTelemetryCategoryConfig) string { + if !c.JSON.Enabled.Valid() { + return "on" + } + if c.Enabled { return "on" } return "off" } -// eventCategory derives the category prefix from a telemetry event type string. -// e.g. "network_response" -> "network", "monitor_screenshot" -> "monitor". -func eventCategory(eventType string) string { - if i := strings.Index(eventType, "_"); i > 0 { - return eventType[:i] +// eventCategoryFromRaw reads the category field directly from the raw event JSON. +// Falls back to prefix derivation (split on first _) when the field is absent. +func eventCategoryFromRaw(ev kernel.BrowserTelemetryEventUnion) string { + var obj struct { + Category string `json:"category"` + } + if raw := ev.RawJSON(); raw != "" { + if err := json.Unmarshal([]byte(raw), &obj); err == nil && obj.Category != "" { + return obj.Category + } + } + // fallback: derive from type prefix (e.g. "network_response" -> "network") + if i := strings.Index(ev.Type, "_"); i > 0 { + return ev.Type[:i] } - return eventType + return ev.Type } // shouldEmit applies client-side category/type filters to a telemetry event. -func shouldEmit(eventType string, categories, types []string) bool { - if len(categories) > 0 && !slices.Contains(categories, eventCategory(eventType)) { +func shouldEmit(ev kernel.BrowserTelemetryEventUnion, categories, types []string) bool { + if len(categories) > 0 && !slices.Contains(categories, eventCategoryFromRaw(ev)) { return false } - if len(types) > 0 && !slices.Contains(types, eventType) { + if len(types) > 0 && !slices.Contains(types, ev.Type) { return false } return true } -var knownTelemetryCategories = []string{"console", "network", "page", "interaction", "monitor"} +// knownTelemetryCategories are the real API event categories observable on stream. +var knownTelemetryCategories = []string{"console", "network", "page", "interaction", "system", "api"} var knownTelemetryTypes = []string{ "console_log", "console_error", @@ -872,7 +887,7 @@ func (b BrowsersCmd) TelemetryStream(ctx context.Context, in BrowsersTelemetrySt defer stream.Close() for stream.Next() { ev := stream.Current() - if !shouldEmit(ev.Event.Type, in.Categories, in.Types) { + if !shouldEmit(ev.Event, in.Categories, in.Types) { continue } if in.Output == "json" { @@ -880,7 +895,7 @@ func (b BrowsersCmd) TelemetryStream(ctx context.Context, in BrowsersTelemetrySt continue } ts := time.UnixMicro(ev.Event.Ts).Local().Format("15:04:05") - pterm.Printf("%s [%s] %s\n", ts, eventCategory(ev.Event.Type), ev.Event.Type) + pterm.Printf("%s [%s] %s\n", ts, eventCategoryFromRaw(ev.Event), ev.Event.Type) } if err := stream.Err(); err != nil { return util.CleanedUpSdkError{Err: err} @@ -2517,7 +2532,7 @@ func init() { // telemetry telemetryRoot := &cobra.Command{Use: "telemetry", Short: "Browser telemetry operations"} telemetryStream := &cobra.Command{Use: "stream ", Short: "Stream live telemetry events", Args: cobra.ExactArgs(1), RunE: runBrowsersTelemetryStream} - telemetryStream.Flags().StringSlice("categories", []string{}, "Filter by category (console,network,page,interaction,monitor); monitor is always-on and filter-only — it cannot be toggled via telemetry set") + telemetryStream.Flags().StringSlice("categories", []string{}, "Filter by API event category (console,network,page,interaction,system,api); system covers monitor_* and cdp_* events") telemetryStream.Flags().StringSlice("types", []string{}, "Filter by event type (e.g. network_response,console_error)") telemetryStream.Flags().Int64("seq", 0, "Resume stream from sequence number (Last-Event-ID)") telemetryStream.Flags().StringP("output", "o", "", "Output format: json for newline-delimited JSON envelopes") diff --git a/cmd/browsers_test.go b/cmd/browsers_test.go index 4f3558d..c694547 100644 --- a/cmd/browsers_test.go +++ b/cmd/browsers_test.go @@ -1926,17 +1926,11 @@ func TestBrowsersTelemetrySet_WhitespaceTolerance(t *testing.T) { func TestBrowsersTelemetryStatus_PrintsCategories(t *testing.T) { setupStdoutCapture(t) fake := &FakeBrowsersService{GetFunc: func(ctx context.Context, id string, query kernel.BrowserGetParams, opts ...option.RequestOption) (*kernel.BrowserGetResponse, error) { - return &kernel.BrowserGetResponse{ - SessionID: id, - Telemetry: kernel.BrowserTelemetryConfig{ - Browser: kernel.BrowserTelemetryCategoriesConfig{ - Console: kernel.BrowserTelemetryCategoryConfig{Enabled: true}, - Interaction: kernel.BrowserTelemetryCategoryConfig{Enabled: false}, - Network: kernel.BrowserTelemetryCategoryConfig{Enabled: true}, - Page: kernel.BrowserTelemetryCategoryConfig{Enabled: false}, - }, - }, - }, nil + var resp kernel.BrowserGetResponse + if err := json.Unmarshal([]byte(`{"session_id":"session123","telemetry":{"browser":{"console":{"enabled":true},"interaction":{"enabled":false},"network":{"enabled":true},"page":{"enabled":false}}}}`), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + return &resp, nil }} b := BrowsersCmd{browsers: fake} @@ -1950,6 +1944,28 @@ func TestBrowsersTelemetryStatus_PrintsCategories(t *testing.T) { assert.Contains(t, out, "page: off") } +func TestBrowsersTelemetryStatus_VMDefaultsAllOn(t *testing.T) { + setupStdoutCapture(t) + fake := &FakeBrowsersService{GetFunc: func(ctx context.Context, id string, query kernel.BrowserGetParams, opts ...option.RequestOption) (*kernel.BrowserGetResponse, error) { + // API returns browser:{} when using VM defaults — enabled field absent means true per SDK + var resp kernel.BrowserGetResponse + if err := json.Unmarshal([]byte(`{"session_id":"session123","telemetry":{"browser":{}}}`), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + return &resp, nil + }} + b := BrowsersCmd{browsers: fake} + + err := b.TelemetryStatus(context.Background(), BrowsersTelemetryStatusInput{Identifier: "session123"}) + + assert.NoError(t, err) + out := outBuf.String() + assert.Contains(t, out, "console: on") + assert.Contains(t, out, "interaction: on") + assert.Contains(t, out, "network: on") + assert.Contains(t, out, "page: on") +} + func TestTelemetryStream_UnknownCategoryErrors(t *testing.T) { b := BrowsersCmd{browsers: &FakeBrowsersService{}} @@ -1978,39 +1994,55 @@ func TestTelemetryStream_UnknownTypeWarns(t *testing.T) { assert.Contains(t, outBuf.String(), "unrecognized event type") } -func TestEventCategory(t *testing.T) { - cases := map[string]string{ - "network_response": "network", - "monitor_screenshot": "monitor", - "console_log": "console", - "page_navigation": "page", - "interaction_click": "interaction", - "nounderscore": "nounderscore", +func makeEvent(t *testing.T, raw string) kernel.BrowserTelemetryEventUnion { + t.Helper() + var ev kernel.BrowserTelemetryEventUnion + if err := json.Unmarshal([]byte(raw), &ev); err != nil { + t.Fatalf("makeEvent: %v", err) + } + return ev +} + +func TestEventCategoryFromRaw(t *testing.T) { + cases := []struct { + raw string + want string + }{ + // real category field present — used directly + {`{"type":"monitor_screenshot","category":"system","ts":0}`, "system"}, + {`{"type":"network_response","category":"network","ts":0}`, "network"}, + // no category field — falls back to type prefix + {`{"type":"console_log","ts":0}`, "console"}, + {`{"type":"page_navigation","ts":0}`, "page"}, + {`{"type":"nounderscore","ts":0}`, "nounderscore"}, } - for input, want := range cases { - assert.Equal(t, want, eventCategory(input), "eventCategory(%q)", input) + for _, tc := range cases { + ev := makeEvent(t, tc.raw) + assert.Equal(t, tc.want, eventCategoryFromRaw(ev), "raw=%s", tc.raw) } } func TestShouldEmit(t *testing.T) { cases := []struct { name string - eventType string + raw string categories []string types []string want bool }{ - {"no filters passes", "network_response", nil, nil, true}, - {"matching category passes", "network_response", []string{"network"}, nil, true}, - {"non-matching category drops", "console_log", []string{"network"}, nil, false}, - {"matching type passes", "console_log", nil, []string{"console_log"}, true}, - {"non-matching type drops", "network_response", nil, []string{"console_log"}, false}, - {"both filters pass when both match", "network_response", []string{"network"}, []string{"network_response"}, true}, - {"both filters drop when type misses", "network_response", []string{"network"}, []string{"console_log"}, false}, + {"no filters passes", `{"type":"network_response","category":"network","ts":0}`, nil, nil, true}, + {"matching category passes", `{"type":"network_response","category":"network","ts":0}`, []string{"network"}, nil, true}, + {"non-matching category drops", `{"type":"console_log","category":"console","ts":0}`, []string{"network"}, nil, false}, + {"system category matches monitor_screenshot", `{"type":"monitor_screenshot","category":"system","ts":0}`, []string{"system"}, nil, true}, + {"matching type passes", `{"type":"console_log","category":"console","ts":0}`, nil, []string{"console_log"}, true}, + {"non-matching type drops", `{"type":"network_response","category":"network","ts":0}`, nil, []string{"console_log"}, false}, + {"both filters pass when both match", `{"type":"network_response","category":"network","ts":0}`, []string{"network"}, []string{"network_response"}, true}, + {"both filters drop when type misses", `{"type":"network_response","category":"network","ts":0}`, []string{"network"}, []string{"console_log"}, false}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - assert.Equal(t, tc.want, shouldEmit(tc.eventType, tc.categories, tc.types)) + ev := makeEvent(t, tc.raw) + assert.Equal(t, tc.want, shouldEmit(ev, tc.categories, tc.types)) }) } } From 832790fc8e71b70140cdadc0b958b3922c89bffe Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Wed, 27 May 2026 16:40:23 -0300 Subject: [PATCH 11/28] fix: stream --categories uses API category field and corrects known category list --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index be9c992..8a012d3 100644 --- a/README.md +++ b/README.md @@ -293,7 +293,7 @@ Commands with JSON output support: - `kernel browsers telemetry status ` - Show current telemetry configuration - `-o, --output json` - Output raw JSON telemetry config - `kernel browsers telemetry stream ` - Stream live telemetry events - - `--categories ` - Filter by category (console,network,page,interaction,monitor); `monitor` is always-on and filter-only — cannot be toggled via `telemetry set` + - `--categories ` - Filter by API event category (console,network,page,interaction,system,api); `system` covers `monitor_*` and `cdp_*` events - `--types ` - Filter by event type (e.g. network_response,console_error) - `--seq ` - Resume stream from sequence number (Last-Event-ID) - `-o, --output json` - Output newline-delimited JSON envelopes From ba5ea92858690f1025842cd93f94471b265a6225 Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Wed, 27 May 2026 16:41:47 -0300 Subject: [PATCH 12/28] fix: remove incorrect api category, validated against kernel-images --- README.md | 2 +- cmd/browsers.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 8a012d3..2dd2a9f 100644 --- a/README.md +++ b/README.md @@ -293,7 +293,7 @@ Commands with JSON output support: - `kernel browsers telemetry status ` - Show current telemetry configuration - `-o, --output json` - Output raw JSON telemetry config - `kernel browsers telemetry stream ` - Stream live telemetry events - - `--categories ` - Filter by API event category (console,network,page,interaction,system,api); `system` covers `monitor_*` and `cdp_*` events + - `--categories ` - Filter by API event category (console,network,page,interaction,system); `system` covers all `monitor_*` events - `--types ` - Filter by event type (e.g. network_response,console_error) - `--seq ` - Resume stream from sequence number (Last-Event-ID) - `-o, --output json` - Output newline-delimited JSON envelopes diff --git a/cmd/browsers.go b/cmd/browsers.go index 52c0000..34aefad 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -849,7 +849,7 @@ func shouldEmit(ev kernel.BrowserTelemetryEventUnion, categories, types []string } // knownTelemetryCategories are the real API event categories observable on stream. -var knownTelemetryCategories = []string{"console", "network", "page", "interaction", "system", "api"} +var knownTelemetryCategories = []string{"console", "network", "page", "interaction", "system"} var knownTelemetryTypes = []string{ "console_log", "console_error", @@ -2532,7 +2532,7 @@ func init() { // telemetry telemetryRoot := &cobra.Command{Use: "telemetry", Short: "Browser telemetry operations"} telemetryStream := &cobra.Command{Use: "stream ", Short: "Stream live telemetry events", Args: cobra.ExactArgs(1), RunE: runBrowsersTelemetryStream} - telemetryStream.Flags().StringSlice("categories", []string{}, "Filter by API event category (console,network,page,interaction,system,api); system covers monitor_* and cdp_* events") + telemetryStream.Flags().StringSlice("categories", []string{}, "Filter by API event category (console,network,page,interaction,system); system covers all monitor_* events") telemetryStream.Flags().StringSlice("types", []string{}, "Filter by event type (e.g. network_response,console_error)") telemetryStream.Flags().Int64("seq", 0, "Resume stream from sequence number (Last-Event-ID)") telemetryStream.Flags().StringP("output", "o", "", "Output format: json for newline-delimited JSON envelopes") From 1cbcb1190fa41bebdf9f089c21c4d82928484d19 Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Wed, 27 May 2026 17:07:00 -0300 Subject: [PATCH 13/28] fix: telemetry status shows enabled on/off and skips categories when stopped --- cmd/browsers.go | 7 +++++++ cmd/browsers_test.go | 23 +++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/cmd/browsers.go b/cmd/browsers.go index 34aefad..88845d1 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -799,6 +799,13 @@ func (b BrowsersCmd) TelemetryStatus(ctx context.Context, in BrowsersTelemetrySt if in.Output == "json" { return util.PrintPrettyJSON(browser.Telemetry) } + telemetryEnabled := browser.Telemetry.JSON.Browser.Valid() + if telemetryEnabled { + pterm.Printf("enabled: on\n") + } else { + pterm.Printf("enabled: off\n") + return nil + } cfg := browser.Telemetry.Browser pterm.Printf("console: %s\n", categoryOnOff(cfg.Console)) pterm.Printf("interaction: %s\n", categoryOnOff(cfg.Interaction)) diff --git a/cmd/browsers_test.go b/cmd/browsers_test.go index c694547..9ace3d3 100644 --- a/cmd/browsers_test.go +++ b/cmd/browsers_test.go @@ -1938,6 +1938,7 @@ func TestBrowsersTelemetryStatus_PrintsCategories(t *testing.T) { assert.NoError(t, err) out := outBuf.String() + assert.Contains(t, out, "enabled: on") assert.Contains(t, out, "console: on") assert.Contains(t, out, "interaction: off") assert.Contains(t, out, "network: on") @@ -1960,12 +1961,34 @@ func TestBrowsersTelemetryStatus_VMDefaultsAllOn(t *testing.T) { assert.NoError(t, err) out := outBuf.String() + assert.Contains(t, out, "enabled: on") assert.Contains(t, out, "console: on") assert.Contains(t, out, "interaction: on") assert.Contains(t, out, "network: on") assert.Contains(t, out, "page: on") } +func TestBrowsersTelemetryStatus_StoppedShowsDisabled(t *testing.T) { + setupStdoutCapture(t) + fake := &FakeBrowsersService{GetFunc: func(ctx context.Context, id string, query kernel.BrowserGetParams, opts ...option.RequestOption) (*kernel.BrowserGetResponse, error) { + // API returns {} (no browser key) after telemetry stop + var resp kernel.BrowserGetResponse + if err := json.Unmarshal([]byte(`{"session_id":"session123","telemetry":{}}`), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + return &resp, nil + }} + b := BrowsersCmd{browsers: fake} + + err := b.TelemetryStatus(context.Background(), BrowsersTelemetryStatusInput{Identifier: "session123"}) + + assert.NoError(t, err) + out := outBuf.String() + assert.Contains(t, out, "enabled: off") + assert.NotContains(t, out, "console:") + assert.NotContains(t, out, "network:") +} + func TestTelemetryStream_UnknownCategoryErrors(t *testing.T) { b := BrowsersCmd{browsers: &FakeBrowsersService{}} From 3ac29acc6a8788b935cae512f10b164d6f60f6ab Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Wed, 27 May 2026 17:25:13 -0300 Subject: [PATCH 14/28] chore: split browser telemetry into cmd/browsers_telemetry.go --- cmd/browsers.go | 313 ------------------------------- cmd/browsers_telemetry.go | 333 +++++++++++++++++++++++++++++++++ cmd/browsers_telemetry_test.go | 299 +++++++++++++++++++++++++++++ cmd/browsers_test.go | 285 ---------------------------- 4 files changed, 632 insertions(+), 598 deletions(-) create mode 100644 cmd/browsers_telemetry.go create mode 100644 cmd/browsers_telemetry_test.go diff --git a/cmd/browsers.go b/cmd/browsers.go index 88845d1..097fc64 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -12,7 +12,6 @@ import ( "os" "path/filepath" "regexp" - "slices" "strconv" "strings" "time" @@ -88,11 +87,6 @@ type BrowserLogService interface { StreamStreaming(ctx context.Context, id string, query kernel.BrowserLogStreamParams, opts ...option.RequestOption) (stream *ssestream.Stream[shared.LogEvent]) } -// BrowserTelemetryService defines the subset we use for browser telemetry streaming. -type BrowserTelemetryService interface { - StreamStreaming(ctx context.Context, id string, query kernel.BrowserTelemetryStreamParams, opts ...option.RequestOption) (stream *ssestream.Stream[kernel.BrowserTelemetryStreamResponse]) -} - // BrowserPlaywrightService defines the subset we use for Playwright execution. type BrowserPlaywrightService interface { Execute(ctx context.Context, id string, body kernel.BrowserPlaywrightExecuteParams, opts ...option.RequestOption) (res *kernel.BrowserPlaywrightExecuteResponse, err error) @@ -224,34 +218,6 @@ type BrowsersUpdateInput struct { Output string } -type BrowsersTelemetryStartInput struct { - Identifier string - Output string -} - -type BrowsersTelemetryStopInput struct { - Identifier string - Output string -} - -type BrowsersTelemetrySetInput struct { - Identifier string - Categories string // e.g. "network=on,page=off" - Output string -} - -type BrowsersTelemetryStatusInput struct { - Identifier string - Output string -} - -type BrowsersTelemetryStreamInput struct { - Identifier string - Categories []string - Types []string - Seq int64 - Output string -} // BrowsersCmd is a cobra-independent command handler for browsers operations. type BrowsersCmd struct { @@ -698,218 +664,6 @@ func (b BrowsersCmd) Update(ctx context.Context, in BrowsersUpdateInput) error { return nil } -func (b BrowsersCmd) TelemetryStart(ctx context.Context, in BrowsersTelemetryStartInput) error { - if in.Output != "" && in.Output != "json" { - return fmt.Errorf("unsupported --output value: use 'json'") - } - res, err := b.browsers.Update(ctx, in.Identifier, kernel.BrowserUpdateParams{ - Telemetry: kernel.BrowserTelemetryRequestConfigParam{Enabled: kernel.Opt(true)}, - }) - if err != nil { - return util.CleanedUpSdkError{Err: err} - } - if in.Output == "json" { - return util.PrintPrettyJSON(res) - } - pterm.Success.Printf("Started telemetry for browser %s\n", in.Identifier) - return nil -} - -func (b BrowsersCmd) TelemetryStop(ctx context.Context, in BrowsersTelemetryStopInput) error { - if in.Output != "" && in.Output != "json" { - return fmt.Errorf("unsupported --output value: use 'json'") - } - res, err := b.browsers.Update(ctx, in.Identifier, kernel.BrowserUpdateParams{ - Telemetry: kernel.BrowserTelemetryRequestConfigParam{Enabled: kernel.Opt(false)}, - }) - if err != nil { - return util.CleanedUpSdkError{Err: err} - } - if in.Output == "json" { - return util.PrintPrettyJSON(res) - } - pterm.Success.Printf("Stopped telemetry for browser %s\n", in.Identifier) - return nil -} - -// parseTelemetryCategories parses a comma-separated "name=on|off" string into -// a BrowserTelemetryCategoriesConfigParam. Unmentioned categories are omitted. -func parseTelemetryCategories(s string) (kernel.BrowserTelemetryCategoriesConfigParam, error) { - p := kernel.BrowserTelemetryCategoriesConfigParam{} - for _, part := range strings.Split(s, ",") { - kv := strings.SplitN(strings.TrimSpace(part), "=", 2) - if len(kv) != 2 { - return p, fmt.Errorf("invalid category assignment %q: expected name=on or name=off", part) - } - name, val := strings.TrimSpace(kv[0]), strings.TrimSpace(kv[1]) - var enabled bool - switch val { - case "on": - enabled = true - case "off": - enabled = false - default: - return p, fmt.Errorf("invalid value %q for category %q: must be 'on' or 'off'", val, name) - } - switch name { - case "console": - p.Console = kernel.BrowserTelemetryCategoryConfigParam{Enabled: kernel.Opt(enabled)} - case "interaction": - p.Interaction = kernel.BrowserTelemetryCategoryConfigParam{Enabled: kernel.Opt(enabled)} - case "network": - p.Network = kernel.BrowserTelemetryCategoryConfigParam{Enabled: kernel.Opt(enabled)} - case "page": - p.Page = kernel.BrowserTelemetryCategoryConfigParam{Enabled: kernel.Opt(enabled)} - default: - return p, fmt.Errorf("unknown category %q: must be one of console, interaction, network, page", name) - } - } - return p, nil -} - -func (b BrowsersCmd) TelemetrySet(ctx context.Context, in BrowsersTelemetrySetInput) error { - if in.Output != "" && in.Output != "json" { - return fmt.Errorf("unsupported --output value: use 'json'") - } - p, err := parseTelemetryCategories(in.Categories) - if err != nil { - return err - } - res, err := b.browsers.Update(ctx, in.Identifier, kernel.BrowserUpdateParams{ - Telemetry: kernel.BrowserTelemetryRequestConfigParam{Browser: p}, - }) - if err != nil { - return util.CleanedUpSdkError{Err: err} - } - if in.Output == "json" { - return util.PrintPrettyJSON(res) - } - pterm.Success.Printf("Updated telemetry categories for browser %s\n", in.Identifier) - return nil -} - -func (b BrowsersCmd) TelemetryStatus(ctx context.Context, in BrowsersTelemetryStatusInput) error { - if in.Output != "" && in.Output != "json" { - return fmt.Errorf("unsupported --output value: use 'json'") - } - browser, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) - if err != nil { - return util.CleanedUpSdkError{Err: err} - } - if in.Output == "json" { - return util.PrintPrettyJSON(browser.Telemetry) - } - telemetryEnabled := browser.Telemetry.JSON.Browser.Valid() - if telemetryEnabled { - pterm.Printf("enabled: on\n") - } else { - pterm.Printf("enabled: off\n") - return nil - } - cfg := browser.Telemetry.Browser - pterm.Printf("console: %s\n", categoryOnOff(cfg.Console)) - pterm.Printf("interaction: %s\n", categoryOnOff(cfg.Interaction)) - pterm.Printf("network: %s\n", categoryOnOff(cfg.Network)) - pterm.Printf("page: %s\n", categoryOnOff(cfg.Page)) - return nil -} - -// categoryOnOff returns "on" or "off" for a category config, respecting the SDK -// default: if the enabled field is absent from the response, it defaults to true. -func categoryOnOff(c kernel.BrowserTelemetryCategoryConfig) string { - if !c.JSON.Enabled.Valid() { - return "on" - } - if c.Enabled { - return "on" - } - return "off" -} - -// eventCategoryFromRaw reads the category field directly from the raw event JSON. -// Falls back to prefix derivation (split on first _) when the field is absent. -func eventCategoryFromRaw(ev kernel.BrowserTelemetryEventUnion) string { - var obj struct { - Category string `json:"category"` - } - if raw := ev.RawJSON(); raw != "" { - if err := json.Unmarshal([]byte(raw), &obj); err == nil && obj.Category != "" { - return obj.Category - } - } - // fallback: derive from type prefix (e.g. "network_response" -> "network") - if i := strings.Index(ev.Type, "_"); i > 0 { - return ev.Type[:i] - } - return ev.Type -} - -// shouldEmit applies client-side category/type filters to a telemetry event. -func shouldEmit(ev kernel.BrowserTelemetryEventUnion, categories, types []string) bool { - if len(categories) > 0 && !slices.Contains(categories, eventCategoryFromRaw(ev)) { - return false - } - if len(types) > 0 && !slices.Contains(types, ev.Type) { - return false - } - return true -} - -// knownTelemetryCategories are the real API event categories observable on stream. -var knownTelemetryCategories = []string{"console", "network", "page", "interaction", "system"} - -var knownTelemetryTypes = []string{ - "console_log", "console_error", - "network_request", "network_response", "network_loading_failed", "network_idle", - "page_navigation", "page_dom_content_loaded", "page_load", "page_tab_opened", - "page_layout_shift", "page_lcp", "page_layout_settled", "page_navigation_settled", - "interaction_click", "interaction_key", "interaction_scroll_settled", - "monitor_screenshot", "monitor_disconnected", "monitor_reconnected", - "monitor_reconnect_failed", "monitor_init_failed", -} - -func (b BrowsersCmd) TelemetryStream(ctx context.Context, in BrowsersTelemetryStreamInput) error { - if in.Output != "" && in.Output != "json" { - return fmt.Errorf("unsupported --output value: use 'json'") - } - for _, c := range in.Categories { - if !slices.Contains(knownTelemetryCategories, c) { - return fmt.Errorf("unknown category %q: must be one of %s", c, strings.Join(knownTelemetryCategories, ", ")) - } - } - for _, t := range in.Types { - if !slices.Contains(knownTelemetryTypes, t) { - pterm.Warning.Printf("unrecognized event type %q — no events will match\n", t) - } - } - br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) - if err != nil { - return util.CleanedUpSdkError{Err: err} - } - params := kernel.BrowserTelemetryStreamParams{} - if in.Seq > 0 { - params.LastEventID = kernel.Opt(strconv.FormatInt(in.Seq, 10)) - } - stream := b.telemetry.StreamStreaming(ctx, br.SessionID, params) - defer stream.Close() - for stream.Next() { - ev := stream.Current() - if !shouldEmit(ev.Event, in.Categories, in.Types) { - continue - } - if in.Output == "json" { - _ = util.PrintCompactJSONLine(ev) - continue - } - ts := time.UnixMicro(ev.Event.Ts).Local().Format("15:04:05") - pterm.Printf("%s [%s] %s\n", ts, eventCategoryFromRaw(ev.Event), ev.Event.Type) - } - if err := stream.Err(); err != nil { - return util.CleanedUpSdkError{Err: err} - } - return nil -} - // Logs type BrowsersLogsStreamInput struct { Identifier string @@ -2536,24 +2290,6 @@ func init() { replaysRoot.AddCommand(replaysList, replaysStart, replaysStop, replaysDownload) browsersCmd.AddCommand(replaysRoot) - // telemetry - telemetryRoot := &cobra.Command{Use: "telemetry", Short: "Browser telemetry operations"} - telemetryStream := &cobra.Command{Use: "stream ", Short: "Stream live telemetry events", Args: cobra.ExactArgs(1), RunE: runBrowsersTelemetryStream} - telemetryStream.Flags().StringSlice("categories", []string{}, "Filter by API event category (console,network,page,interaction,system); system covers all monitor_* events") - telemetryStream.Flags().StringSlice("types", []string{}, "Filter by event type (e.g. network_response,console_error)") - telemetryStream.Flags().Int64("seq", 0, "Resume stream from sequence number (Last-Event-ID)") - telemetryStream.Flags().StringP("output", "o", "", "Output format: json for newline-delimited JSON envelopes") - telemetryStart := &cobra.Command{Use: "start ", Short: "Start telemetry capture (enabled: true)", Args: cobra.ExactArgs(1), RunE: runBrowsersTelemetryStart} - telemetryStart.Flags().StringP("output", "o", "", "Output format: json for raw API response") - telemetryStop := &cobra.Command{Use: "stop ", Short: "Stop telemetry capture (enabled: false)", Args: cobra.ExactArgs(1), RunE: runBrowsersTelemetryStop} - telemetryStop.Flags().StringP("output", "o", "", "Output format: json for raw API response") - telemetrySet := &cobra.Command{Use: "set ...", Short: "Set per-category telemetry config", Args: cobra.MinimumNArgs(2), RunE: runBrowsersTelemetrySet} - telemetrySet.Flags().StringP("output", "o", "", "Output format: json for raw API response") - telemetryStatus := &cobra.Command{Use: "status ", Short: "Show current telemetry configuration", Args: cobra.ExactArgs(1), RunE: runBrowsersTelemetryStatus} - telemetryStatus.Flags().StringP("output", "o", "", "Output format: json for raw API response") - telemetryRoot.AddCommand(telemetryStream, telemetryStart, telemetryStop, telemetrySet, telemetryStatus) - browsersCmd.AddCommand(telemetryRoot) - // process procRoot := &cobra.Command{Use: "process", Short: "Manage processes inside the browser VM"} procExec := &cobra.Command{Use: "exec [--] [command...]", Short: "Execute a command synchronously", Args: cobra.MinimumNArgs(1), RunE: runBrowsersProcessExec} @@ -3089,55 +2825,6 @@ func runBrowsersReplaysDownload(cmd *cobra.Command, args []string) error { return b.ReplaysDownload(cmd.Context(), BrowsersReplaysDownloadInput{Identifier: args[0], ReplayID: args[1], Output: out}) } -func runBrowsersTelemetryStart(cmd *cobra.Command, args []string) error { - client := getKernelClient(cmd) - svc := client.Browsers - out, _ := cmd.Flags().GetString("output") - b := BrowsersCmd{browsers: &svc} - return b.TelemetryStart(cmd.Context(), BrowsersTelemetryStartInput{Identifier: args[0], Output: out}) -} - -func runBrowsersTelemetryStop(cmd *cobra.Command, args []string) error { - client := getKernelClient(cmd) - svc := client.Browsers - out, _ := cmd.Flags().GetString("output") - b := BrowsersCmd{browsers: &svc} - return b.TelemetryStop(cmd.Context(), BrowsersTelemetryStopInput{Identifier: args[0], Output: out}) -} - -func runBrowsersTelemetrySet(cmd *cobra.Command, args []string) error { - client := getKernelClient(cmd) - svc := client.Browsers - out, _ := cmd.Flags().GetString("output") - b := BrowsersCmd{browsers: &svc} - return b.TelemetrySet(cmd.Context(), BrowsersTelemetrySetInput{Identifier: args[0], Categories: strings.Join(args[1:], ","), Output: out}) -} - -func runBrowsersTelemetryStatus(cmd *cobra.Command, args []string) error { - client := getKernelClient(cmd) - svc := client.Browsers - out, _ := cmd.Flags().GetString("output") - b := BrowsersCmd{browsers: &svc} - return b.TelemetryStatus(cmd.Context(), BrowsersTelemetryStatusInput{Identifier: args[0], Output: out}) -} - -func runBrowsersTelemetryStream(cmd *cobra.Command, args []string) error { - client := getKernelClient(cmd) - svc := client.Browsers - out, _ := cmd.Flags().GetString("output") - categories, _ := cmd.Flags().GetStringSlice("categories") - types, _ := cmd.Flags().GetStringSlice("types") - seq, _ := cmd.Flags().GetInt64("seq") - b := BrowsersCmd{browsers: &svc, telemetry: &svc.Telemetry} - return b.TelemetryStream(cmd.Context(), BrowsersTelemetryStreamInput{ - Identifier: args[0], - Categories: categories, - Types: types, - Seq: seq, - Output: out, - }) -} - func runBrowsersProcessExec(cmd *cobra.Command, args []string) error { client := getKernelClient(cmd) svc := client.Browsers diff --git a/cmd/browsers_telemetry.go b/cmd/browsers_telemetry.go new file mode 100644 index 0000000..4f2863d --- /dev/null +++ b/cmd/browsers_telemetry.go @@ -0,0 +1,333 @@ +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "slices" + "strconv" + "strings" + "time" + + "github.com/kernel/cli/pkg/util" + kernel "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/option" + "github.com/kernel/kernel-go-sdk/packages/ssestream" + "github.com/pterm/pterm" + "github.com/spf13/cobra" +) + +// BrowserTelemetryService defines the subset we use for browser telemetry streaming. +type BrowserTelemetryService interface { + StreamStreaming(ctx context.Context, id string, query kernel.BrowserTelemetryStreamParams, opts ...option.RequestOption) (stream *ssestream.Stream[kernel.BrowserTelemetryStreamResponse]) +} + +type BrowsersTelemetryStartInput struct { + Identifier string + Output string +} + +type BrowsersTelemetryStopInput struct { + Identifier string + Output string +} + +type BrowsersTelemetrySetInput struct { + Identifier string + Categories string // e.g. "network=on,page=off" + Output string +} + +type BrowsersTelemetryStatusInput struct { + Identifier string + Output string +} + +type BrowsersTelemetryStreamInput struct { + Identifier string + Categories []string + Types []string + Seq int64 + Output string +} + +func (b BrowsersCmd) TelemetryStart(ctx context.Context, in BrowsersTelemetryStartInput) error { + if in.Output != "" && in.Output != "json" { + return fmt.Errorf("unsupported --output value: use 'json'") + } + res, err := b.browsers.Update(ctx, in.Identifier, kernel.BrowserUpdateParams{ + Telemetry: kernel.BrowserTelemetryRequestConfigParam{Enabled: kernel.Opt(true)}, + }) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + if in.Output == "json" { + return util.PrintPrettyJSON(res) + } + pterm.Success.Printf("Started telemetry for browser %s\n", in.Identifier) + return nil +} + +func (b BrowsersCmd) TelemetryStop(ctx context.Context, in BrowsersTelemetryStopInput) error { + if in.Output != "" && in.Output != "json" { + return fmt.Errorf("unsupported --output value: use 'json'") + } + res, err := b.browsers.Update(ctx, in.Identifier, kernel.BrowserUpdateParams{ + Telemetry: kernel.BrowserTelemetryRequestConfigParam{Enabled: kernel.Opt(false)}, + }) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + if in.Output == "json" { + return util.PrintPrettyJSON(res) + } + pterm.Success.Printf("Stopped telemetry for browser %s\n", in.Identifier) + return nil +} + +// parseTelemetryCategories parses a comma-separated "name=on|off" string into +// a BrowserTelemetryCategoriesConfigParam. Unmentioned categories are omitted. +func parseTelemetryCategories(s string) (kernel.BrowserTelemetryCategoriesConfigParam, error) { + p := kernel.BrowserTelemetryCategoriesConfigParam{} + for _, part := range strings.Split(s, ",") { + kv := strings.SplitN(strings.TrimSpace(part), "=", 2) + if len(kv) != 2 { + return p, fmt.Errorf("invalid category assignment %q: expected name=on or name=off", part) + } + name, val := strings.TrimSpace(kv[0]), strings.TrimSpace(kv[1]) + var enabled bool + switch val { + case "on": + enabled = true + case "off": + enabled = false + default: + return p, fmt.Errorf("invalid value %q for category %q: must be 'on' or 'off'", val, name) + } + switch name { + case "console": + p.Console = kernel.BrowserTelemetryCategoryConfigParam{Enabled: kernel.Opt(enabled)} + case "interaction": + p.Interaction = kernel.BrowserTelemetryCategoryConfigParam{Enabled: kernel.Opt(enabled)} + case "network": + p.Network = kernel.BrowserTelemetryCategoryConfigParam{Enabled: kernel.Opt(enabled)} + case "page": + p.Page = kernel.BrowserTelemetryCategoryConfigParam{Enabled: kernel.Opt(enabled)} + default: + return p, fmt.Errorf("unknown category %q: must be one of console, interaction, network, page", name) + } + } + return p, nil +} + +func (b BrowsersCmd) TelemetrySet(ctx context.Context, in BrowsersTelemetrySetInput) error { + if in.Output != "" && in.Output != "json" { + return fmt.Errorf("unsupported --output value: use 'json'") + } + p, err := parseTelemetryCategories(in.Categories) + if err != nil { + return err + } + res, err := b.browsers.Update(ctx, in.Identifier, kernel.BrowserUpdateParams{ + Telemetry: kernel.BrowserTelemetryRequestConfigParam{Browser: p}, + }) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + if in.Output == "json" { + return util.PrintPrettyJSON(res) + } + pterm.Success.Printf("Updated telemetry categories for browser %s\n", in.Identifier) + return nil +} + +func (b BrowsersCmd) TelemetryStatus(ctx context.Context, in BrowsersTelemetryStatusInput) error { + if in.Output != "" && in.Output != "json" { + return fmt.Errorf("unsupported --output value: use 'json'") + } + browser, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + if in.Output == "json" { + return util.PrintPrettyJSON(browser.Telemetry) + } + telemetryEnabled := browser.Telemetry.JSON.Browser.Valid() + if telemetryEnabled { + pterm.Printf("enabled: on\n") + } else { + pterm.Printf("enabled: off\n") + return nil + } + cfg := browser.Telemetry.Browser + pterm.Printf("console: %s\n", categoryOnOff(cfg.Console)) + pterm.Printf("interaction: %s\n", categoryOnOff(cfg.Interaction)) + pterm.Printf("network: %s\n", categoryOnOff(cfg.Network)) + pterm.Printf("page: %s\n", categoryOnOff(cfg.Page)) + return nil +} + +// categoryOnOff returns "on" or "off" for a category config, respecting the SDK +// default: if the enabled field is absent from the response, it defaults to true. +func categoryOnOff(c kernel.BrowserTelemetryCategoryConfig) string { + if !c.JSON.Enabled.Valid() { + return "on" + } + if c.Enabled { + return "on" + } + return "off" +} + +// eventCategoryFromRaw reads the category field directly from the raw event JSON. +// Falls back to prefix derivation (split on first _) when the field is absent. +func eventCategoryFromRaw(ev kernel.BrowserTelemetryEventUnion) string { + var obj struct { + Category string `json:"category"` + } + if raw := ev.RawJSON(); raw != "" { + if err := json.Unmarshal([]byte(raw), &obj); err == nil && obj.Category != "" { + return obj.Category + } + } + // fallback: derive from type prefix (e.g. "network_response" -> "network") + if i := strings.Index(ev.Type, "_"); i > 0 { + return ev.Type[:i] + } + return ev.Type +} + +// shouldEmit applies client-side category/type filters to a telemetry event. +func shouldEmit(ev kernel.BrowserTelemetryEventUnion, categories, types []string) bool { + if len(categories) > 0 && !slices.Contains(categories, eventCategoryFromRaw(ev)) { + return false + } + if len(types) > 0 && !slices.Contains(types, ev.Type) { + return false + } + return true +} + +// knownTelemetryCategories are the real API event categories observable on stream. +var knownTelemetryCategories = []string{"console", "network", "page", "interaction", "system"} + +var knownTelemetryTypes = []string{ + "console_log", "console_error", + "network_request", "network_response", "network_loading_failed", "network_idle", + "page_navigation", "page_dom_content_loaded", "page_load", "page_tab_opened", + "page_layout_shift", "page_lcp", "page_layout_settled", "page_navigation_settled", + "interaction_click", "interaction_key", "interaction_scroll_settled", + "monitor_screenshot", "monitor_disconnected", "monitor_reconnected", + "monitor_reconnect_failed", "monitor_init_failed", +} + +func (b BrowsersCmd) TelemetryStream(ctx context.Context, in BrowsersTelemetryStreamInput) error { + if in.Output != "" && in.Output != "json" { + return fmt.Errorf("unsupported --output value: use 'json'") + } + for _, c := range in.Categories { + if !slices.Contains(knownTelemetryCategories, c) { + return fmt.Errorf("unknown category %q: must be one of %s", c, strings.Join(knownTelemetryCategories, ", ")) + } + } + for _, t := range in.Types { + if !slices.Contains(knownTelemetryTypes, t) { + pterm.Warning.Printf("unrecognized event type %q — no events will match\n", t) + } + } + br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + params := kernel.BrowserTelemetryStreamParams{} + if in.Seq > 0 { + params.LastEventID = kernel.Opt(strconv.FormatInt(in.Seq, 10)) + } + stream := b.telemetry.StreamStreaming(ctx, br.SessionID, params) + defer stream.Close() + for stream.Next() { + ev := stream.Current() + if !shouldEmit(ev.Event, in.Categories, in.Types) { + continue + } + if in.Output == "json" { + _ = util.PrintCompactJSONLine(ev) + continue + } + ts := time.UnixMicro(ev.Event.Ts).Local().Format("15:04:05") + pterm.Printf("%s [%s] %s\n", ts, eventCategoryFromRaw(ev.Event), ev.Event.Type) + } + if err := stream.Err(); err != nil { + return util.CleanedUpSdkError{Err: err} + } + return nil +} + +func init() { + // browsersCmd is a package-level var (browsers.go), initialized before init() runs. + telemetryRoot := &cobra.Command{Use: "telemetry", Short: "Browser telemetry operations"} + telemetryStream := &cobra.Command{Use: "stream ", Short: "Stream live telemetry events", Args: cobra.ExactArgs(1), RunE: runBrowsersTelemetryStream} + telemetryStream.Flags().StringSlice("categories", []string{}, "Filter by API event category (console,network,page,interaction,system); system covers all monitor_* events") + telemetryStream.Flags().StringSlice("types", []string{}, "Filter by event type (e.g. network_response,console_error)") + telemetryStream.Flags().Int64("seq", 0, "Resume stream from sequence number (Last-Event-ID)") + telemetryStream.Flags().StringP("output", "o", "", "Output format: json for newline-delimited JSON envelopes") + telemetryStart := &cobra.Command{Use: "start ", Short: "Start telemetry capture (enabled: true)", Args: cobra.ExactArgs(1), RunE: runBrowsersTelemetryStart} + telemetryStart.Flags().StringP("output", "o", "", "Output format: json for raw API response") + telemetryStop := &cobra.Command{Use: "stop ", Short: "Stop telemetry capture (enabled: false)", Args: cobra.ExactArgs(1), RunE: runBrowsersTelemetryStop} + telemetryStop.Flags().StringP("output", "o", "", "Output format: json for raw API response") + telemetrySet := &cobra.Command{Use: "set ...", Short: "Set per-category telemetry config", Args: cobra.MinimumNArgs(2), RunE: runBrowsersTelemetrySet} + telemetrySet.Flags().StringP("output", "o", "", "Output format: json for raw API response") + telemetryStatus := &cobra.Command{Use: "status ", Short: "Show current telemetry configuration", Args: cobra.ExactArgs(1), RunE: runBrowsersTelemetryStatus} + telemetryStatus.Flags().StringP("output", "o", "", "Output format: json for raw API response") + telemetryRoot.AddCommand(telemetryStream, telemetryStart, telemetryStop, telemetrySet, telemetryStatus) + browsersCmd.AddCommand(telemetryRoot) +} + +func runBrowsersTelemetryStart(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + svc := client.Browsers + out, _ := cmd.Flags().GetString("output") + b := BrowsersCmd{browsers: &svc} + return b.TelemetryStart(cmd.Context(), BrowsersTelemetryStartInput{Identifier: args[0], Output: out}) +} + +func runBrowsersTelemetryStop(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + svc := client.Browsers + out, _ := cmd.Flags().GetString("output") + b := BrowsersCmd{browsers: &svc} + return b.TelemetryStop(cmd.Context(), BrowsersTelemetryStopInput{Identifier: args[0], Output: out}) +} + +func runBrowsersTelemetrySet(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + svc := client.Browsers + out, _ := cmd.Flags().GetString("output") + b := BrowsersCmd{browsers: &svc} + return b.TelemetrySet(cmd.Context(), BrowsersTelemetrySetInput{Identifier: args[0], Categories: strings.Join(args[1:], ","), Output: out}) +} + +func runBrowsersTelemetryStatus(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + svc := client.Browsers + out, _ := cmd.Flags().GetString("output") + b := BrowsersCmd{browsers: &svc} + return b.TelemetryStatus(cmd.Context(), BrowsersTelemetryStatusInput{Identifier: args[0], Output: out}) +} + +func runBrowsersTelemetryStream(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + svc := client.Browsers + out, _ := cmd.Flags().GetString("output") + categories, _ := cmd.Flags().GetStringSlice("categories") + types, _ := cmd.Flags().GetStringSlice("types") + seq, _ := cmd.Flags().GetInt64("seq") + b := BrowsersCmd{browsers: &svc, telemetry: &svc.Telemetry} + return b.TelemetryStream(cmd.Context(), BrowsersTelemetryStreamInput{ + Identifier: args[0], + Categories: categories, + Types: types, + Seq: seq, + Output: out, + }) +} diff --git a/cmd/browsers_telemetry_test.go b/cmd/browsers_telemetry_test.go new file mode 100644 index 0000000..38317a7 --- /dev/null +++ b/cmd/browsers_telemetry_test.go @@ -0,0 +1,299 @@ +package cmd + +import ( + "context" + "encoding/json" + "testing" + + kernel "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/option" + "github.com/kernel/kernel-go-sdk/packages/ssestream" + "github.com/stretchr/testify/assert" +) + +type FakeBrowserTelemetryService struct{} + +func (f *FakeBrowserTelemetryService) StreamStreaming(ctx context.Context, id string, query kernel.BrowserTelemetryStreamParams, opts ...option.RequestOption) *ssestream.Stream[kernel.BrowserTelemetryStreamResponse] { + return makeStream([]kernel.BrowserTelemetryStreamResponse{}) +} + +func TestBrowsersTelemetryStart_SendsEnablePayload(t *testing.T) { + setupStdoutCapture(t) + var capturedID string + var captured kernel.BrowserUpdateParams + fake := &FakeBrowsersService{UpdateFunc: func(ctx context.Context, id string, body kernel.BrowserUpdateParams, opts ...option.RequestOption) (*kernel.BrowserUpdateResponse, error) { + capturedID = id + captured = body + return &kernel.BrowserUpdateResponse{SessionID: id}, nil + }} + b := BrowsersCmd{browsers: fake} + + err := b.TelemetryStart(context.Background(), BrowsersTelemetryStartInput{Identifier: "session123"}) + + assert.NoError(t, err) + assert.Equal(t, "session123", capturedID) + assert.True(t, captured.Telemetry.Enabled.Valid()) + assert.True(t, captured.Telemetry.Enabled.Value) + assert.Contains(t, outBuf.String(), "Started telemetry for browser session123") +} + +func TestBrowsersTelemetryStop_SendsDisablePayload(t *testing.T) { + setupStdoutCapture(t) + var capturedID string + var captured kernel.BrowserUpdateParams + fake := &FakeBrowsersService{UpdateFunc: func(ctx context.Context, id string, body kernel.BrowserUpdateParams, opts ...option.RequestOption) (*kernel.BrowserUpdateResponse, error) { + capturedID = id + captured = body + return &kernel.BrowserUpdateResponse{SessionID: id}, nil + }} + b := BrowsersCmd{browsers: fake} + + err := b.TelemetryStop(context.Background(), BrowsersTelemetryStopInput{Identifier: "session123"}) + + assert.NoError(t, err) + assert.Equal(t, "session123", capturedID) + assert.True(t, captured.Telemetry.Enabled.Valid()) + assert.False(t, captured.Telemetry.Enabled.Value) + assert.Contains(t, outBuf.String(), "Stopped telemetry for browser session123") +} + +func TestBrowsersTelemetrySet_PartialCategories(t *testing.T) { + setupStdoutCapture(t) + var captured kernel.BrowserUpdateParams + fake := &FakeBrowsersService{UpdateFunc: func(ctx context.Context, id string, body kernel.BrowserUpdateParams, opts ...option.RequestOption) (*kernel.BrowserUpdateResponse, error) { + captured = body + return &kernel.BrowserUpdateResponse{SessionID: id}, nil + }} + b := BrowsersCmd{browsers: fake} + + err := b.TelemetrySet(context.Background(), BrowsersTelemetrySetInput{Identifier: "session123", Categories: "network=on,page=off"}) + + assert.NoError(t, err) + assert.False(t, captured.Telemetry.Enabled.Valid()) + assert.True(t, captured.Telemetry.Browser.Network.Enabled.Valid()) + assert.True(t, captured.Telemetry.Browser.Network.Enabled.Value) + assert.True(t, captured.Telemetry.Browser.Page.Enabled.Valid()) + assert.False(t, captured.Telemetry.Browser.Page.Enabled.Value) + // Unspecified categories omitted — server retains their state + assert.False(t, captured.Telemetry.Browser.Console.Enabled.Valid()) + assert.False(t, captured.Telemetry.Browser.Interaction.Enabled.Valid()) +} + +func TestBrowsersTelemetrySet_InvalidCategory(t *testing.T) { + b := BrowsersCmd{browsers: &FakeBrowsersService{}} + + err := b.TelemetrySet(context.Background(), BrowsersTelemetrySetInput{Identifier: "session123", Categories: "foo=on"}) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "unknown category") +} + +func TestBrowsersTelemetrySet_InvalidValue(t *testing.T) { + b := BrowsersCmd{browsers: &FakeBrowsersService{}} + + err := b.TelemetrySet(context.Background(), BrowsersTelemetrySetInput{Identifier: "session123", Categories: "network=yes"}) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "must be 'on' or 'off'") +} + +func TestBrowsersTelemetryStart_UnsupportedOutputErrors(t *testing.T) { + b := BrowsersCmd{browsers: &FakeBrowsersService{}} + + err := b.TelemetryStart(context.Background(), BrowsersTelemetryStartInput{Identifier: "session123", Output: "yaml"}) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "unsupported --output value") +} + +func TestBrowsersTelemetryStop_UnsupportedOutputErrors(t *testing.T) { + b := BrowsersCmd{browsers: &FakeBrowsersService{}} + + err := b.TelemetryStop(context.Background(), BrowsersTelemetryStopInput{Identifier: "session123", Output: "yaml"}) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "unsupported --output value") +} + +func TestBrowsersTelemetrySet_UnsupportedOutputErrors(t *testing.T) { + b := BrowsersCmd{browsers: &FakeBrowsersService{}} + + err := b.TelemetrySet(context.Background(), BrowsersTelemetrySetInput{Identifier: "session123", Categories: "network=on", Output: "yaml"}) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "unsupported --output value") +} + +func TestBrowsersTelemetryStatus_UnsupportedOutputErrors(t *testing.T) { + b := BrowsersCmd{browsers: &FakeBrowsersService{}} + + err := b.TelemetryStatus(context.Background(), BrowsersTelemetryStatusInput{Identifier: "session123", Output: "yaml"}) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "unsupported --output value") +} + +func TestBrowsersTelemetrySet_WhitespaceTolerance(t *testing.T) { + setupStdoutCapture(t) + var captured kernel.BrowserUpdateParams + fake := &FakeBrowsersService{UpdateFunc: func(ctx context.Context, id string, body kernel.BrowserUpdateParams, opts ...option.RequestOption) (*kernel.BrowserUpdateResponse, error) { + captured = body + return &kernel.BrowserUpdateResponse{SessionID: id}, nil + }} + b := BrowsersCmd{browsers: fake} + + err := b.TelemetrySet(context.Background(), BrowsersTelemetrySetInput{Identifier: "session123", Categories: " network = on , page = off "}) + + assert.NoError(t, err) + assert.True(t, captured.Telemetry.Browser.Network.Enabled.Valid()) + assert.True(t, captured.Telemetry.Browser.Network.Enabled.Value) + assert.True(t, captured.Telemetry.Browser.Page.Enabled.Valid()) + assert.False(t, captured.Telemetry.Browser.Page.Enabled.Value) +} + +func TestBrowsersTelemetryStatus_PrintsCategories(t *testing.T) { + setupStdoutCapture(t) + fake := &FakeBrowsersService{GetFunc: func(ctx context.Context, id string, query kernel.BrowserGetParams, opts ...option.RequestOption) (*kernel.BrowserGetResponse, error) { + var resp kernel.BrowserGetResponse + if err := json.Unmarshal([]byte(`{"session_id":"session123","telemetry":{"browser":{"console":{"enabled":true},"interaction":{"enabled":false},"network":{"enabled":true},"page":{"enabled":false}}}}`), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + return &resp, nil + }} + b := BrowsersCmd{browsers: fake} + + err := b.TelemetryStatus(context.Background(), BrowsersTelemetryStatusInput{Identifier: "session123"}) + + assert.NoError(t, err) + out := outBuf.String() + assert.Contains(t, out, "enabled: on") + assert.Contains(t, out, "console: on") + assert.Contains(t, out, "interaction: off") + assert.Contains(t, out, "network: on") + assert.Contains(t, out, "page: off") +} + +func TestBrowsersTelemetryStatus_VMDefaultsAllOn(t *testing.T) { + setupStdoutCapture(t) + fake := &FakeBrowsersService{GetFunc: func(ctx context.Context, id string, query kernel.BrowserGetParams, opts ...option.RequestOption) (*kernel.BrowserGetResponse, error) { + // API returns browser:{} when using VM defaults — enabled field absent means true per SDK + var resp kernel.BrowserGetResponse + if err := json.Unmarshal([]byte(`{"session_id":"session123","telemetry":{"browser":{}}}`), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + return &resp, nil + }} + b := BrowsersCmd{browsers: fake} + + err := b.TelemetryStatus(context.Background(), BrowsersTelemetryStatusInput{Identifier: "session123"}) + + assert.NoError(t, err) + out := outBuf.String() + assert.Contains(t, out, "enabled: on") + assert.Contains(t, out, "console: on") + assert.Contains(t, out, "interaction: on") + assert.Contains(t, out, "network: on") + assert.Contains(t, out, "page: on") +} + +func TestBrowsersTelemetryStatus_StoppedShowsDisabled(t *testing.T) { + setupStdoutCapture(t) + fake := &FakeBrowsersService{GetFunc: func(ctx context.Context, id string, query kernel.BrowserGetParams, opts ...option.RequestOption) (*kernel.BrowserGetResponse, error) { + // API returns {} (no browser key) after telemetry stop + var resp kernel.BrowserGetResponse + if err := json.Unmarshal([]byte(`{"session_id":"session123","telemetry":{}}`), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + return &resp, nil + }} + b := BrowsersCmd{browsers: fake} + + err := b.TelemetryStatus(context.Background(), BrowsersTelemetryStatusInput{Identifier: "session123"}) + + assert.NoError(t, err) + out := outBuf.String() + assert.Contains(t, out, "enabled: off") + assert.NotContains(t, out, "console:") + assert.NotContains(t, out, "network:") +} + +func TestTelemetryStream_UnknownCategoryErrors(t *testing.T) { + b := BrowsersCmd{browsers: &FakeBrowsersService{}} + + err := b.TelemetryStream(context.Background(), BrowsersTelemetryStreamInput{ + Identifier: "session123", + Categories: []string{"invalid"}, + }) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "unknown category") +} + +func TestTelemetryStream_UnknownTypeWarns(t *testing.T) { + setupStdoutCapture(t) + fake := &FakeBrowsersService{GetFunc: func(ctx context.Context, id string, query kernel.BrowserGetParams, opts ...option.RequestOption) (*kernel.BrowserGetResponse, error) { + return &kernel.BrowserGetResponse{SessionID: id}, nil + }} + b := BrowsersCmd{browsers: fake, telemetry: &FakeBrowserTelemetryService{}} + + err := b.TelemetryStream(context.Background(), BrowsersTelemetryStreamInput{ + Identifier: "session123", + Types: []string{"invalid_type"}, + }) + + assert.NoError(t, err) + assert.Contains(t, outBuf.String(), "unrecognized event type") +} + +func makeEvent(t *testing.T, raw string) kernel.BrowserTelemetryEventUnion { + t.Helper() + var ev kernel.BrowserTelemetryEventUnion + if err := json.Unmarshal([]byte(raw), &ev); err != nil { + t.Fatalf("makeEvent: %v", err) + } + return ev +} + +func TestEventCategoryFromRaw(t *testing.T) { + cases := []struct { + raw string + want string + }{ + // real category field present — used directly + {`{"type":"monitor_screenshot","category":"system","ts":0}`, "system"}, + {`{"type":"network_response","category":"network","ts":0}`, "network"}, + // no category field — falls back to type prefix + {`{"type":"console_log","ts":0}`, "console"}, + {`{"type":"page_navigation","ts":0}`, "page"}, + {`{"type":"nounderscore","ts":0}`, "nounderscore"}, + } + for _, tc := range cases { + ev := makeEvent(t, tc.raw) + assert.Equal(t, tc.want, eventCategoryFromRaw(ev), "raw=%s", tc.raw) + } +} + +func TestShouldEmit(t *testing.T) { + cases := []struct { + name string + raw string + categories []string + types []string + want bool + }{ + {"no filters passes", `{"type":"network_response","category":"network","ts":0}`, nil, nil, true}, + {"matching category passes", `{"type":"network_response","category":"network","ts":0}`, []string{"network"}, nil, true}, + {"non-matching category drops", `{"type":"console_log","category":"console","ts":0}`, []string{"network"}, nil, false}, + {"system category matches monitor_screenshot", `{"type":"monitor_screenshot","category":"system","ts":0}`, []string{"system"}, nil, true}, + {"matching type passes", `{"type":"console_log","category":"console","ts":0}`, nil, []string{"console_log"}, true}, + {"non-matching type drops", `{"type":"network_response","category":"network","ts":0}`, nil, []string{"console_log"}, false}, + {"both filters pass when both match", `{"type":"network_response","category":"network","ts":0}`, []string{"network"}, []string{"network_response"}, true}, + {"both filters drop when type misses", `{"type":"network_response","category":"network","ts":0}`, []string{"network"}, []string{"console_log"}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + ev := makeEvent(t, tc.raw) + assert.Equal(t, tc.want, shouldEmit(ev, tc.categories, tc.types)) + }) + } +} diff --git a/cmd/browsers_test.go b/cmd/browsers_test.go index 9ace3d3..94d35fc 100644 --- a/cmd/browsers_test.go +++ b/cmd/browsers_test.go @@ -911,11 +911,6 @@ func (f *FakeProcessService) StdoutStreamStreaming(ctx context.Context, processI return makeStream([]kernel.BrowserProcessStdoutStreamResponse{{Stream: kernel.BrowserProcessStdoutStreamResponseStreamStdout, DataB64: "aGVsbG8=", Event: ""}, {Event: "exit", ExitCode: 0}}) } -type FakeBrowserTelemetryService struct{} - -func (f *FakeBrowserTelemetryService) StreamStreaming(ctx context.Context, id string, query kernel.BrowserTelemetryStreamParams, opts ...option.RequestOption) *ssestream.Stream[kernel.BrowserTelemetryStreamResponse] { - return makeStream([]kernel.BrowserTelemetryStreamResponse{}) -} type FakeLogService struct { StreamFunc func(ctx context.Context, id string, query kernel.BrowserLogStreamParams, opts ...option.RequestOption) *ssestream.Stream[shared.LogEvent] @@ -1789,283 +1784,3 @@ func TestBrowsersUpdate_ForceWithProxyButNoViewport_Errors(t *testing.T) { assert.Contains(t, err.Error(), "--force requires --viewport") } -func TestBrowsersTelemetryStart_SendsEnablePayload(t *testing.T) { - setupStdoutCapture(t) - var capturedID string - var captured kernel.BrowserUpdateParams - fake := &FakeBrowsersService{UpdateFunc: func(ctx context.Context, id string, body kernel.BrowserUpdateParams, opts ...option.RequestOption) (*kernel.BrowserUpdateResponse, error) { - capturedID = id - captured = body - return &kernel.BrowserUpdateResponse{SessionID: id}, nil - }} - b := BrowsersCmd{browsers: fake} - - err := b.TelemetryStart(context.Background(), BrowsersTelemetryStartInput{Identifier: "session123"}) - - assert.NoError(t, err) - assert.Equal(t, "session123", capturedID) - assert.True(t, captured.Telemetry.Enabled.Valid()) - assert.True(t, captured.Telemetry.Enabled.Value) - assert.Contains(t, outBuf.String(), "Started telemetry for browser session123") -} - -func TestBrowsersTelemetryStop_SendsDisablePayload(t *testing.T) { - setupStdoutCapture(t) - var capturedID string - var captured kernel.BrowserUpdateParams - fake := &FakeBrowsersService{UpdateFunc: func(ctx context.Context, id string, body kernel.BrowserUpdateParams, opts ...option.RequestOption) (*kernel.BrowserUpdateResponse, error) { - capturedID = id - captured = body - return &kernel.BrowserUpdateResponse{SessionID: id}, nil - }} - b := BrowsersCmd{browsers: fake} - - err := b.TelemetryStop(context.Background(), BrowsersTelemetryStopInput{Identifier: "session123"}) - - assert.NoError(t, err) - assert.Equal(t, "session123", capturedID) - assert.True(t, captured.Telemetry.Enabled.Valid()) - assert.False(t, captured.Telemetry.Enabled.Value) - assert.Contains(t, outBuf.String(), "Stopped telemetry for browser session123") -} - -func TestBrowsersTelemetrySet_PartialCategories(t *testing.T) { - setupStdoutCapture(t) - var captured kernel.BrowserUpdateParams - fake := &FakeBrowsersService{UpdateFunc: func(ctx context.Context, id string, body kernel.BrowserUpdateParams, opts ...option.RequestOption) (*kernel.BrowserUpdateResponse, error) { - captured = body - return &kernel.BrowserUpdateResponse{SessionID: id}, nil - }} - b := BrowsersCmd{browsers: fake} - - err := b.TelemetrySet(context.Background(), BrowsersTelemetrySetInput{Identifier: "session123", Categories: "network=on,page=off"}) - - assert.NoError(t, err) - assert.False(t, captured.Telemetry.Enabled.Valid()) - assert.True(t, captured.Telemetry.Browser.Network.Enabled.Valid()) - assert.True(t, captured.Telemetry.Browser.Network.Enabled.Value) - assert.True(t, captured.Telemetry.Browser.Page.Enabled.Valid()) - assert.False(t, captured.Telemetry.Browser.Page.Enabled.Value) - // Unspecified categories omitted — server retains their state - assert.False(t, captured.Telemetry.Browser.Console.Enabled.Valid()) - assert.False(t, captured.Telemetry.Browser.Interaction.Enabled.Valid()) -} - -func TestBrowsersTelemetrySet_InvalidCategory(t *testing.T) { - b := BrowsersCmd{browsers: &FakeBrowsersService{}} - - err := b.TelemetrySet(context.Background(), BrowsersTelemetrySetInput{Identifier: "session123", Categories: "foo=on"}) - - assert.Error(t, err) - assert.Contains(t, err.Error(), "unknown category") -} - -func TestBrowsersTelemetrySet_InvalidValue(t *testing.T) { - b := BrowsersCmd{browsers: &FakeBrowsersService{}} - - err := b.TelemetrySet(context.Background(), BrowsersTelemetrySetInput{Identifier: "session123", Categories: "network=yes"}) - - assert.Error(t, err) - assert.Contains(t, err.Error(), "must be 'on' or 'off'") -} - -func TestBrowsersTelemetryStart_UnsupportedOutputErrors(t *testing.T) { - b := BrowsersCmd{browsers: &FakeBrowsersService{}} - - err := b.TelemetryStart(context.Background(), BrowsersTelemetryStartInput{Identifier: "session123", Output: "yaml"}) - - assert.Error(t, err) - assert.Contains(t, err.Error(), "unsupported --output value") -} - -func TestBrowsersTelemetryStop_UnsupportedOutputErrors(t *testing.T) { - b := BrowsersCmd{browsers: &FakeBrowsersService{}} - - err := b.TelemetryStop(context.Background(), BrowsersTelemetryStopInput{Identifier: "session123", Output: "yaml"}) - - assert.Error(t, err) - assert.Contains(t, err.Error(), "unsupported --output value") -} - -func TestBrowsersTelemetrySet_UnsupportedOutputErrors(t *testing.T) { - b := BrowsersCmd{browsers: &FakeBrowsersService{}} - - err := b.TelemetrySet(context.Background(), BrowsersTelemetrySetInput{Identifier: "session123", Categories: "network=on", Output: "yaml"}) - - assert.Error(t, err) - assert.Contains(t, err.Error(), "unsupported --output value") -} - -func TestBrowsersTelemetryStatus_UnsupportedOutputErrors(t *testing.T) { - b := BrowsersCmd{browsers: &FakeBrowsersService{}} - - err := b.TelemetryStatus(context.Background(), BrowsersTelemetryStatusInput{Identifier: "session123", Output: "yaml"}) - - assert.Error(t, err) - assert.Contains(t, err.Error(), "unsupported --output value") -} - -func TestBrowsersTelemetrySet_WhitespaceTolerance(t *testing.T) { - setupStdoutCapture(t) - var captured kernel.BrowserUpdateParams - fake := &FakeBrowsersService{UpdateFunc: func(ctx context.Context, id string, body kernel.BrowserUpdateParams, opts ...option.RequestOption) (*kernel.BrowserUpdateResponse, error) { - captured = body - return &kernel.BrowserUpdateResponse{SessionID: id}, nil - }} - b := BrowsersCmd{browsers: fake} - - err := b.TelemetrySet(context.Background(), BrowsersTelemetrySetInput{Identifier: "session123", Categories: " network = on , page = off "}) - - assert.NoError(t, err) - assert.True(t, captured.Telemetry.Browser.Network.Enabled.Valid()) - assert.True(t, captured.Telemetry.Browser.Network.Enabled.Value) - assert.True(t, captured.Telemetry.Browser.Page.Enabled.Valid()) - assert.False(t, captured.Telemetry.Browser.Page.Enabled.Value) -} - -func TestBrowsersTelemetryStatus_PrintsCategories(t *testing.T) { - setupStdoutCapture(t) - fake := &FakeBrowsersService{GetFunc: func(ctx context.Context, id string, query kernel.BrowserGetParams, opts ...option.RequestOption) (*kernel.BrowserGetResponse, error) { - var resp kernel.BrowserGetResponse - if err := json.Unmarshal([]byte(`{"session_id":"session123","telemetry":{"browser":{"console":{"enabled":true},"interaction":{"enabled":false},"network":{"enabled":true},"page":{"enabled":false}}}}`), &resp); err != nil { - t.Fatalf("unmarshal: %v", err) - } - return &resp, nil - }} - b := BrowsersCmd{browsers: fake} - - err := b.TelemetryStatus(context.Background(), BrowsersTelemetryStatusInput{Identifier: "session123"}) - - assert.NoError(t, err) - out := outBuf.String() - assert.Contains(t, out, "enabled: on") - assert.Contains(t, out, "console: on") - assert.Contains(t, out, "interaction: off") - assert.Contains(t, out, "network: on") - assert.Contains(t, out, "page: off") -} - -func TestBrowsersTelemetryStatus_VMDefaultsAllOn(t *testing.T) { - setupStdoutCapture(t) - fake := &FakeBrowsersService{GetFunc: func(ctx context.Context, id string, query kernel.BrowserGetParams, opts ...option.RequestOption) (*kernel.BrowserGetResponse, error) { - // API returns browser:{} when using VM defaults — enabled field absent means true per SDK - var resp kernel.BrowserGetResponse - if err := json.Unmarshal([]byte(`{"session_id":"session123","telemetry":{"browser":{}}}`), &resp); err != nil { - t.Fatalf("unmarshal: %v", err) - } - return &resp, nil - }} - b := BrowsersCmd{browsers: fake} - - err := b.TelemetryStatus(context.Background(), BrowsersTelemetryStatusInput{Identifier: "session123"}) - - assert.NoError(t, err) - out := outBuf.String() - assert.Contains(t, out, "enabled: on") - assert.Contains(t, out, "console: on") - assert.Contains(t, out, "interaction: on") - assert.Contains(t, out, "network: on") - assert.Contains(t, out, "page: on") -} - -func TestBrowsersTelemetryStatus_StoppedShowsDisabled(t *testing.T) { - setupStdoutCapture(t) - fake := &FakeBrowsersService{GetFunc: func(ctx context.Context, id string, query kernel.BrowserGetParams, opts ...option.RequestOption) (*kernel.BrowserGetResponse, error) { - // API returns {} (no browser key) after telemetry stop - var resp kernel.BrowserGetResponse - if err := json.Unmarshal([]byte(`{"session_id":"session123","telemetry":{}}`), &resp); err != nil { - t.Fatalf("unmarshal: %v", err) - } - return &resp, nil - }} - b := BrowsersCmd{browsers: fake} - - err := b.TelemetryStatus(context.Background(), BrowsersTelemetryStatusInput{Identifier: "session123"}) - - assert.NoError(t, err) - out := outBuf.String() - assert.Contains(t, out, "enabled: off") - assert.NotContains(t, out, "console:") - assert.NotContains(t, out, "network:") -} - -func TestTelemetryStream_UnknownCategoryErrors(t *testing.T) { - b := BrowsersCmd{browsers: &FakeBrowsersService{}} - - err := b.TelemetryStream(context.Background(), BrowsersTelemetryStreamInput{ - Identifier: "session123", - Categories: []string{"invalid"}, - }) - - assert.Error(t, err) - assert.Contains(t, err.Error(), "unknown category") -} - -func TestTelemetryStream_UnknownTypeWarns(t *testing.T) { - setupStdoutCapture(t) - fake := &FakeBrowsersService{GetFunc: func(ctx context.Context, id string, query kernel.BrowserGetParams, opts ...option.RequestOption) (*kernel.BrowserGetResponse, error) { - return &kernel.BrowserGetResponse{SessionID: id}, nil - }} - b := BrowsersCmd{browsers: fake, telemetry: &FakeBrowserTelemetryService{}} - - err := b.TelemetryStream(context.Background(), BrowsersTelemetryStreamInput{ - Identifier: "session123", - Types: []string{"invalid_type"}, - }) - - assert.NoError(t, err) - assert.Contains(t, outBuf.String(), "unrecognized event type") -} - -func makeEvent(t *testing.T, raw string) kernel.BrowserTelemetryEventUnion { - t.Helper() - var ev kernel.BrowserTelemetryEventUnion - if err := json.Unmarshal([]byte(raw), &ev); err != nil { - t.Fatalf("makeEvent: %v", err) - } - return ev -} - -func TestEventCategoryFromRaw(t *testing.T) { - cases := []struct { - raw string - want string - }{ - // real category field present — used directly - {`{"type":"monitor_screenshot","category":"system","ts":0}`, "system"}, - {`{"type":"network_response","category":"network","ts":0}`, "network"}, - // no category field — falls back to type prefix - {`{"type":"console_log","ts":0}`, "console"}, - {`{"type":"page_navigation","ts":0}`, "page"}, - {`{"type":"nounderscore","ts":0}`, "nounderscore"}, - } - for _, tc := range cases { - ev := makeEvent(t, tc.raw) - assert.Equal(t, tc.want, eventCategoryFromRaw(ev), "raw=%s", tc.raw) - } -} - -func TestShouldEmit(t *testing.T) { - cases := []struct { - name string - raw string - categories []string - types []string - want bool - }{ - {"no filters passes", `{"type":"network_response","category":"network","ts":0}`, nil, nil, true}, - {"matching category passes", `{"type":"network_response","category":"network","ts":0}`, []string{"network"}, nil, true}, - {"non-matching category drops", `{"type":"console_log","category":"console","ts":0}`, []string{"network"}, nil, false}, - {"system category matches monitor_screenshot", `{"type":"monitor_screenshot","category":"system","ts":0}`, []string{"system"}, nil, true}, - {"matching type passes", `{"type":"console_log","category":"console","ts":0}`, nil, []string{"console_log"}, true}, - {"non-matching type drops", `{"type":"network_response","category":"network","ts":0}`, nil, []string{"console_log"}, false}, - {"both filters pass when both match", `{"type":"network_response","category":"network","ts":0}`, []string{"network"}, []string{"network_response"}, true}, - {"both filters drop when type misses", `{"type":"network_response","category":"network","ts":0}`, []string{"network"}, []string{"console_log"}, false}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - ev := makeEvent(t, tc.raw) - assert.Equal(t, tc.want, shouldEmit(ev, tc.categories, tc.types)) - }) - } -} From fc4affb19574978cdc11b9cf68b8f722b76b4c78 Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Wed, 27 May 2026 17:41:08 -0300 Subject: [PATCH 15/28] review: address browsers_telemetry code review feedback --- cmd/browsers_telemetry.go | 57 ++++++++++++++++++---------------- cmd/browsers_telemetry_test.go | 8 ++--- 2 files changed, 35 insertions(+), 30 deletions(-) diff --git a/cmd/browsers_telemetry.go b/cmd/browsers_telemetry.go index 4f2863d..4dccbde 100644 --- a/cmd/browsers_telemetry.go +++ b/cmd/browsers_telemetry.go @@ -3,6 +3,7 @@ package cmd import ( "context" "encoding/json" + "errors" "fmt" "slices" "strconv" @@ -51,9 +52,16 @@ type BrowsersTelemetryStreamInput struct { Output string } +func validateJSONOutput(out string) error { + if out != "" && out != "json" { + return errors.New("unsupported --output value: use 'json'") + } + return nil +} + func (b BrowsersCmd) TelemetryStart(ctx context.Context, in BrowsersTelemetryStartInput) error { - if in.Output != "" && in.Output != "json" { - return fmt.Errorf("unsupported --output value: use 'json'") + if err := validateJSONOutput(in.Output); err != nil { + return err } res, err := b.browsers.Update(ctx, in.Identifier, kernel.BrowserUpdateParams{ Telemetry: kernel.BrowserTelemetryRequestConfigParam{Enabled: kernel.Opt(true)}, @@ -69,8 +77,8 @@ func (b BrowsersCmd) TelemetryStart(ctx context.Context, in BrowsersTelemetrySta } func (b BrowsersCmd) TelemetryStop(ctx context.Context, in BrowsersTelemetryStopInput) error { - if in.Output != "" && in.Output != "json" { - return fmt.Errorf("unsupported --output value: use 'json'") + if err := validateJSONOutput(in.Output); err != nil { + return err } res, err := b.browsers.Update(ctx, in.Identifier, kernel.BrowserUpdateParams{ Telemetry: kernel.BrowserTelemetryRequestConfigParam{Enabled: kernel.Opt(false)}, @@ -90,11 +98,11 @@ func (b BrowsersCmd) TelemetryStop(ctx context.Context, in BrowsersTelemetryStop func parseTelemetryCategories(s string) (kernel.BrowserTelemetryCategoriesConfigParam, error) { p := kernel.BrowserTelemetryCategoriesConfigParam{} for _, part := range strings.Split(s, ",") { - kv := strings.SplitN(strings.TrimSpace(part), "=", 2) - if len(kv) != 2 { + name, val, ok := strings.Cut(strings.TrimSpace(part), "=") + if !ok { return p, fmt.Errorf("invalid category assignment %q: expected name=on or name=off", part) } - name, val := strings.TrimSpace(kv[0]), strings.TrimSpace(kv[1]) + name, val = strings.TrimSpace(name), strings.TrimSpace(val) var enabled bool switch val { case "on": @@ -114,15 +122,15 @@ func parseTelemetryCategories(s string) (kernel.BrowserTelemetryCategoriesConfig case "page": p.Page = kernel.BrowserTelemetryCategoryConfigParam{Enabled: kernel.Opt(enabled)} default: - return p, fmt.Errorf("unknown category %q: must be one of console, interaction, network, page", name) + return p, fmt.Errorf("unknown category %q: must be one of %s", name, strings.Join(settableCategories, ", ")) } } return p, nil } func (b BrowsersCmd) TelemetrySet(ctx context.Context, in BrowsersTelemetrySetInput) error { - if in.Output != "" && in.Output != "json" { - return fmt.Errorf("unsupported --output value: use 'json'") + if err := validateJSONOutput(in.Output); err != nil { + return err } p, err := parseTelemetryCategories(in.Categories) if err != nil { @@ -142,8 +150,8 @@ func (b BrowsersCmd) TelemetrySet(ctx context.Context, in BrowsersTelemetrySetIn } func (b BrowsersCmd) TelemetryStatus(ctx context.Context, in BrowsersTelemetryStatusInput) error { - if in.Output != "" && in.Output != "json" { - return fmt.Errorf("unsupported --output value: use 'json'") + if err := validateJSONOutput(in.Output); err != nil { + return err } browser, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { @@ -152,13 +160,11 @@ func (b BrowsersCmd) TelemetryStatus(ctx context.Context, in BrowsersTelemetrySt if in.Output == "json" { return util.PrintPrettyJSON(browser.Telemetry) } - telemetryEnabled := browser.Telemetry.JSON.Browser.Valid() - if telemetryEnabled { - pterm.Printf("enabled: on\n") - } else { - pterm.Printf("enabled: off\n") + if !browser.Telemetry.JSON.Browser.Valid() { + pterm.Println("enabled: off") return nil } + pterm.Println("enabled: on") cfg := browser.Telemetry.Browser pterm.Printf("console: %s\n", categoryOnOff(cfg.Console)) pterm.Printf("interaction: %s\n", categoryOnOff(cfg.Interaction)) @@ -180,21 +186,17 @@ func categoryOnOff(c kernel.BrowserTelemetryCategoryConfig) string { } // eventCategoryFromRaw reads the category field directly from the raw event JSON. -// Falls back to prefix derivation (split on first _) when the field is absent. +// Returns "" if the field is absent — callers that need a category must handle the empty case. func eventCategoryFromRaw(ev kernel.BrowserTelemetryEventUnion) string { var obj struct { Category string `json:"category"` } if raw := ev.RawJSON(); raw != "" { - if err := json.Unmarshal([]byte(raw), &obj); err == nil && obj.Category != "" { + if err := json.Unmarshal([]byte(raw), &obj); err == nil { return obj.Category } } - // fallback: derive from type prefix (e.g. "network_response" -> "network") - if i := strings.Index(ev.Type, "_"); i > 0 { - return ev.Type[:i] - } - return ev.Type + return "" } // shouldEmit applies client-side category/type filters to a telemetry event. @@ -208,6 +210,9 @@ func shouldEmit(ev kernel.BrowserTelemetryEventUnion, categories, types []string return true } +// settableCategories are the categories accepted by the set subcommand. +var settableCategories = []string{"console", "interaction", "network", "page"} + // knownTelemetryCategories are the real API event categories observable on stream. var knownTelemetryCategories = []string{"console", "network", "page", "interaction", "system"} @@ -222,8 +227,8 @@ var knownTelemetryTypes = []string{ } func (b BrowsersCmd) TelemetryStream(ctx context.Context, in BrowsersTelemetryStreamInput) error { - if in.Output != "" && in.Output != "json" { - return fmt.Errorf("unsupported --output value: use 'json'") + if err := validateJSONOutput(in.Output); err != nil { + return err } for _, c := range in.Categories { if !slices.Contains(knownTelemetryCategories, c) { diff --git a/cmd/browsers_telemetry_test.go b/cmd/browsers_telemetry_test.go index 38317a7..014ae1d 100644 --- a/cmd/browsers_telemetry_test.go +++ b/cmd/browsers_telemetry_test.go @@ -262,10 +262,10 @@ func TestEventCategoryFromRaw(t *testing.T) { // real category field present — used directly {`{"type":"monitor_screenshot","category":"system","ts":0}`, "system"}, {`{"type":"network_response","category":"network","ts":0}`, "network"}, - // no category field — falls back to type prefix - {`{"type":"console_log","ts":0}`, "console"}, - {`{"type":"page_navigation","ts":0}`, "page"}, - {`{"type":"nounderscore","ts":0}`, "nounderscore"}, + // no category field — returns "" + {`{"type":"console_log","ts":0}`, ""}, + {`{"type":"page_navigation","ts":0}`, ""}, + {`{"type":"nounderscore","ts":0}`, ""}, } for _, tc := range cases { ev := makeEvent(t, tc.raw) From 87922b3c4dc312998b549507064cf3b8ca6139d5 Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Wed, 27 May 2026 17:58:14 -0300 Subject: [PATCH 16/28] review: fix readme inaccuracies in telemetry documentation --- README.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 2dd2a9f..35eb6e9 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,7 @@ Commands with JSON output support: - **Apps**: `list`, `history` - **Deploy**: `deploy` (JSONL streaming), `history` - **Invoke**: `invoke` (JSONL streaming), `history` -- **Browser Sub-commands**: `replays list/start`, `process exec/spawn`, `fs file-info/list-files` +- **Browser Sub-commands**: `replays list/start`, `process exec/spawn`, `fs file-info/list-files`, `telemetry start/stop/set/status/stream` ### Authentication @@ -212,7 +212,7 @@ Commands with JSON output support: - `--pool-id ` - Acquire a browser from the specified pool (mutually exclusive with --pool-name; ignores other session flags) - `--pool-name ` - Acquire a browser from the pool name (mutually exclusive with --pool-id; ignores other session flags) - `--telemetry` - Enable telemetry for all categories (`enabled: true`) - - `--telemetry=` - Per-category config at create time, e.g. `--telemetry=network=on,page=off` (same syntax as `telemetry set`) + - `--telemetry=` - Per-category config at create time as a comma-separated list, e.g. `--telemetry=network=on,page=off` - `--output json`, `-o json` - Output raw JSON object - _Note: When a pool is specified, omit other session configuration flags—pool settings determine profile, proxy, viewport, etc._ - `kernel browsers delete ` - Delete a browser @@ -284,12 +284,12 @@ Commands with JSON output support: ### Browser Telemetry -- `kernel browsers telemetry start ` - Start telemetry capture (`enabled: true`) - - `-o, --output json` - Output raw API response -- `kernel browsers telemetry stop ` - Stop telemetry capture (`enabled: false`) - - `-o, --output json` - Output raw API response -- `kernel browsers telemetry set ...` - Set per-category telemetry config, e.g. `network=on page=off` - - `-o, --output json` - Output raw API response +- `kernel browsers telemetry start ` - Start telemetry capture + - `-o, --output json` - Output the full updated browser session as JSON +- `kernel browsers telemetry stop ` - Stop telemetry capture + - `-o, --output json` - Output the full updated browser session as JSON +- `kernel browsers telemetry set ...` - Set per-category telemetry config, e.g. `network=on page=off`. Valid categories: `console`, `interaction`, `network`, `page`. (The `system` category always emits and cannot be toggled.) + - `-o, --output json` - Output the full updated browser session as JSON - `kernel browsers telemetry status ` - Show current telemetry configuration - `-o, --output json` - Output raw JSON telemetry config - `kernel browsers telemetry stream ` - Stream live telemetry events From 4f4a8ae8b12981a7768110123c3f265614d28fa6 Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Thu, 28 May 2026 09:08:09 -0300 Subject: [PATCH 17/28] review: collapse telemetry start/stop/set/status into browsers update --telemetry --- README.md | 26 ++-- cmd/browsers.go | 25 +++- cmd/browsers_telemetry.go | 182 +++---------------------- cmd/browsers_telemetry_test.go | 237 +++++---------------------------- 4 files changed, 91 insertions(+), 379 deletions(-) diff --git a/README.md b/README.md index 35eb6e9..e144908 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,7 @@ Commands with JSON output support: - **Apps**: `list`, `history` - **Deploy**: `deploy` (JSONL streaming), `history` - **Invoke**: `invoke` (JSONL streaming), `history` -- **Browser Sub-commands**: `replays list/start`, `process exec/spawn`, `fs file-info/list-files`, `telemetry start/stop/set/status/stream` +- **Browser Sub-commands**: `replays list/start`, `process exec/spawn`, `fs file-info/list-files`, `telemetry stream` ### Authentication @@ -211,8 +211,8 @@ Commands with JSON output support: - `--start-url ` - Initial page to open on launch - `--pool-id ` - Acquire a browser from the specified pool (mutually exclusive with --pool-name; ignores other session flags) - `--pool-name ` - Acquire a browser from the pool name (mutually exclusive with --pool-id; ignores other session flags) - - `--telemetry` - Enable telemetry for all categories (`enabled: true`) - - `--telemetry=` - Per-category config at create time as a comma-separated list, e.g. `--telemetry=network=on,page=off` + - `--telemetry` - Enable telemetry for all categories + - `--telemetry=` - Per-category config, e.g. `--telemetry=network=on,page=off` - `--output json`, `-o json` - Output raw JSON object - _Note: When a pool is specified, omit other session configuration flags—pool settings determine profile, proxy, viewport, etc._ - `kernel browsers delete ` - Delete a browser @@ -220,6 +220,12 @@ Commands with JSON output support: - `--output json`, `-o json` - Output JSON with liveViewUrl - `kernel browsers get ` - Get detailed browser session info - `--output json`, `-o json` - Output raw JSON object +- `kernel browsers update ` - Update a running browser session + - `--proxy-id ` - Set proxy; `--clear-proxy` to remove + - `--profile-id ` / `--profile-name ` - Load a profile; `--save-changes` to persist on exit + - `--viewport ` - Resize viewport; `--force` to resize during active live view or recording + - `--telemetry` - Enable telemetry for all categories; `--telemetry=off` to disable; `--telemetry=network=on,page=off` for per-category + - `--output json`, `-o json` - Output raw JSON object - `kernel browsers curl ` - Make HTTP requests through a browser session's Chrome network stack - `-X, --request ` - HTTP method (default: GET; defaults to POST when `--data` is set) - `-H, --header
` - HTTP header, repeatable (`"Key: Value"` format) @@ -284,14 +290,12 @@ Commands with JSON output support: ### Browser Telemetry -- `kernel browsers telemetry start ` - Start telemetry capture - - `-o, --output json` - Output the full updated browser session as JSON -- `kernel browsers telemetry stop ` - Stop telemetry capture - - `-o, --output json` - Output the full updated browser session as JSON -- `kernel browsers telemetry set ...` - Set per-category telemetry config, e.g. `network=on page=off`. Valid categories: `console`, `interaction`, `network`, `page`. (The `system` category always emits and cannot be toggled.) - - `-o, --output json` - Output the full updated browser session as JSON -- `kernel browsers telemetry status ` - Show current telemetry configuration - - `-o, --output json` - Output raw JSON telemetry config +Telemetry config is a sub-field of the browser session. Use `browsers update` to enable, disable, or configure it, and `browsers get` to inspect the current state. + +- Enable all categories: `kernel browsers update --telemetry` +- Disable: `kernel browsers update --telemetry=off` +- Per-category: `kernel browsers update --telemetry=network=on,page=off` (valid: `console`, `interaction`, `network`, `page`; `system` always emits and cannot be toggled) + - `kernel browsers telemetry stream ` - Stream live telemetry events - `--categories ` - Filter by API event category (console,network,page,interaction,system); `system` covers all `monitor_*` events - `--types ` - Filter by event type (e.g. network_response,console_error) diff --git a/cmd/browsers.go b/cmd/browsers.go index 097fc64..e00888b 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -215,10 +215,10 @@ type BrowsersUpdateInput struct { ProfileSaveChanges BoolFlag Viewport string Force bool + Telemetry string Output string } - // BrowsersCmd is a cobra-independent command handler for browsers operations. type BrowsersCmd struct { browsers BrowsersService @@ -600,9 +600,11 @@ func (b BrowsersCmd) Update(ctx context.Context, in BrowsersUpdateInput) error { return fmt.Errorf("--force requires --viewport") } + hasTelemetryChange := in.Telemetry != "" + // Validate that at least one update option is provided - if !hasProxyChange && !hasProfileChange && !hasViewportChange { - return fmt.Errorf("must specify at least one of: --proxy-id, --clear-proxy, --profile-id, --profile-name, or --viewport") + if !hasProxyChange && !hasProfileChange && !hasViewportChange && !hasTelemetryChange { + return fmt.Errorf("must specify at least one of: --proxy-id, --clear-proxy, --profile-id, --profile-name, --viewport, or --telemetry") } params := kernel.BrowserUpdateParams{} @@ -627,6 +629,19 @@ func (b BrowsersCmd) Update(ctx context.Context, in BrowsersUpdateInput) error { } } + // Handle telemetry changes + if in.Telemetry == "all" { + params.Telemetry = kernel.BrowserTelemetryRequestConfigParam{Enabled: kernel.Opt(true)} + } else if in.Telemetry == "off" { + params.Telemetry = kernel.BrowserTelemetryRequestConfigParam{Enabled: kernel.Opt(false)} + } else if in.Telemetry != "" { + p, err := parseTelemetryCategories(in.Telemetry) + if err != nil { + return err + } + params.Telemetry = kernel.BrowserTelemetryRequestConfigParam{Enabled: kernel.Opt(true), Browser: p} + } + // Handle viewport changes if hasViewportChange { width, height, refreshRate, err := parseViewport(in.Viewport) @@ -2254,6 +2269,8 @@ func init() { browsersUpdateCmd.Flags().Bool("save-changes", false, "If set, save changes back to the profile when the session ends") browsersUpdateCmd.Flags().String("viewport", "", "Browser viewport size (e.g., 1920x1080@25). Supported: 2560x1440@10, 1920x1080@25, 1920x1200@25, 1440x900@25, 1024x768@60, 1200x800@60, 1280x800@60") browsersUpdateCmd.Flags().Bool("force", false, "Force viewport resize even when a live view or recording/replay is active") + browsersUpdateCmd.Flags().String("telemetry", "", "Update telemetry: --telemetry for all categories, --telemetry=off to disable, --telemetry=network=on,page=off for per-category") + browsersUpdateCmd.Flag("telemetry").NoOptDefVal = "all" browsersCmd.AddCommand(browsersListCmd) browsersCmd.AddCommand(browsersCreateCmd) @@ -2759,6 +2776,7 @@ func runBrowsersUpdate(cmd *cobra.Command, args []string) error { saveChanges, _ := cmd.Flags().GetBool("save-changes") viewport, _ := cmd.Flags().GetString("viewport") force, _ := cmd.Flags().GetBool("force") + telemetry, _ := cmd.Flags().GetString("telemetry") svc := client.Browsers b := BrowsersCmd{browsers: &svc} @@ -2771,6 +2789,7 @@ func runBrowsersUpdate(cmd *cobra.Command, args []string) error { ProfileSaveChanges: BoolFlag{Set: cmd.Flags().Changed("save-changes"), Value: saveChanges}, Viewport: viewport, Force: force, + Telemetry: telemetry, Output: out, }) } diff --git a/cmd/browsers_telemetry.go b/cmd/browsers_telemetry.go index 4dccbde..35c3395 100644 --- a/cmd/browsers_telemetry.go +++ b/cmd/browsers_telemetry.go @@ -23,27 +23,6 @@ type BrowserTelemetryService interface { StreamStreaming(ctx context.Context, id string, query kernel.BrowserTelemetryStreamParams, opts ...option.RequestOption) (stream *ssestream.Stream[kernel.BrowserTelemetryStreamResponse]) } -type BrowsersTelemetryStartInput struct { - Identifier string - Output string -} - -type BrowsersTelemetryStopInput struct { - Identifier string - Output string -} - -type BrowsersTelemetrySetInput struct { - Identifier string - Categories string // e.g. "network=on,page=off" - Output string -} - -type BrowsersTelemetryStatusInput struct { - Identifier string - Output string -} - type BrowsersTelemetryStreamInput struct { Identifier string Categories []string @@ -59,40 +38,6 @@ func validateJSONOutput(out string) error { return nil } -func (b BrowsersCmd) TelemetryStart(ctx context.Context, in BrowsersTelemetryStartInput) error { - if err := validateJSONOutput(in.Output); err != nil { - return err - } - res, err := b.browsers.Update(ctx, in.Identifier, kernel.BrowserUpdateParams{ - Telemetry: kernel.BrowserTelemetryRequestConfigParam{Enabled: kernel.Opt(true)}, - }) - if err != nil { - return util.CleanedUpSdkError{Err: err} - } - if in.Output == "json" { - return util.PrintPrettyJSON(res) - } - pterm.Success.Printf("Started telemetry for browser %s\n", in.Identifier) - return nil -} - -func (b BrowsersCmd) TelemetryStop(ctx context.Context, in BrowsersTelemetryStopInput) error { - if err := validateJSONOutput(in.Output); err != nil { - return err - } - res, err := b.browsers.Update(ctx, in.Identifier, kernel.BrowserUpdateParams{ - Telemetry: kernel.BrowserTelemetryRequestConfigParam{Enabled: kernel.Opt(false)}, - }) - if err != nil { - return util.CleanedUpSdkError{Err: err} - } - if in.Output == "json" { - return util.PrintPrettyJSON(res) - } - pterm.Success.Printf("Stopped telemetry for browser %s\n", in.Identifier) - return nil -} - // parseTelemetryCategories parses a comma-separated "name=on|off" string into // a BrowserTelemetryCategoriesConfigParam. Unmentioned categories are omitted. func parseTelemetryCategories(s string) (kernel.BrowserTelemetryCategoriesConfigParam, error) { @@ -128,61 +73,20 @@ func parseTelemetryCategories(s string) (kernel.BrowserTelemetryCategoriesConfig return p, nil } -func (b BrowsersCmd) TelemetrySet(ctx context.Context, in BrowsersTelemetrySetInput) error { - if err := validateJSONOutput(in.Output); err != nil { - return err - } - p, err := parseTelemetryCategories(in.Categories) - if err != nil { - return err - } - res, err := b.browsers.Update(ctx, in.Identifier, kernel.BrowserUpdateParams{ - Telemetry: kernel.BrowserTelemetryRequestConfigParam{Browser: p}, - }) - if err != nil { - return util.CleanedUpSdkError{Err: err} - } - if in.Output == "json" { - return util.PrintPrettyJSON(res) - } - pterm.Success.Printf("Updated telemetry categories for browser %s\n", in.Identifier) - return nil -} +// settableCategories are the categories accepted by --telemetry=. +var settableCategories = []string{"console", "interaction", "network", "page"} -func (b BrowsersCmd) TelemetryStatus(ctx context.Context, in BrowsersTelemetryStatusInput) error { - if err := validateJSONOutput(in.Output); err != nil { - return err - } - browser, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) - if err != nil { - return util.CleanedUpSdkError{Err: err} - } - if in.Output == "json" { - return util.PrintPrettyJSON(browser.Telemetry) - } - if !browser.Telemetry.JSON.Browser.Valid() { - pterm.Println("enabled: off") - return nil - } - pterm.Println("enabled: on") - cfg := browser.Telemetry.Browser - pterm.Printf("console: %s\n", categoryOnOff(cfg.Console)) - pterm.Printf("interaction: %s\n", categoryOnOff(cfg.Interaction)) - pterm.Printf("network: %s\n", categoryOnOff(cfg.Network)) - pterm.Printf("page: %s\n", categoryOnOff(cfg.Page)) - return nil -} +// knownTelemetryCategories are the real API event categories observable on stream. +var knownTelemetryCategories = []string{"console", "network", "page", "interaction", "system"} -// categoryOnOff returns "on" or "off" for a category config, respecting the SDK -// default: if the enabled field is absent from the response, it defaults to true. -func categoryOnOff(c kernel.BrowserTelemetryCategoryConfig) string { - if !c.JSON.Enabled.Valid() { - return "on" - } - if c.Enabled { - return "on" - } - return "off" +var knownTelemetryTypes = []string{ + "console_log", "console_error", + "network_request", "network_response", "network_loading_failed", "network_idle", + "page_navigation", "page_dom_content_loaded", "page_load", "page_tab_opened", + "page_layout_shift", "page_lcp", "page_layout_settled", "page_navigation_settled", + "interaction_click", "interaction_key", "interaction_scroll_settled", + "monitor_screenshot", "monitor_disconnected", "monitor_reconnected", + "monitor_reconnect_failed", "monitor_init_failed", } // eventCategoryFromRaw reads the category field directly from the raw event JSON. @@ -210,22 +114,6 @@ func shouldEmit(ev kernel.BrowserTelemetryEventUnion, categories, types []string return true } -// settableCategories are the categories accepted by the set subcommand. -var settableCategories = []string{"console", "interaction", "network", "page"} - -// knownTelemetryCategories are the real API event categories observable on stream. -var knownTelemetryCategories = []string{"console", "network", "page", "interaction", "system"} - -var knownTelemetryTypes = []string{ - "console_log", "console_error", - "network_request", "network_response", "network_loading_failed", "network_idle", - "page_navigation", "page_dom_content_loaded", "page_load", "page_tab_opened", - "page_layout_shift", "page_lcp", "page_layout_settled", "page_navigation_settled", - "interaction_click", "interaction_key", "interaction_scroll_settled", - "monitor_screenshot", "monitor_disconnected", "monitor_reconnected", - "monitor_reconnect_failed", "monitor_init_failed", -} - func (b BrowsersCmd) TelemetryStream(ctx context.Context, in BrowsersTelemetryStreamInput) error { if err := validateJSONOutput(in.Output); err != nil { return err @@ -240,6 +128,10 @@ func (b BrowsersCmd) TelemetryStream(ctx context.Context, in BrowsersTelemetrySt pterm.Warning.Printf("unrecognized event type %q — no events will match\n", t) } } + if b.telemetry == nil { + pterm.Error.Println("telemetry service not available") + return nil + } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { return util.CleanedUpSdkError{Err: err} @@ -276,50 +168,10 @@ func init() { telemetryStream.Flags().StringSlice("types", []string{}, "Filter by event type (e.g. network_response,console_error)") telemetryStream.Flags().Int64("seq", 0, "Resume stream from sequence number (Last-Event-ID)") telemetryStream.Flags().StringP("output", "o", "", "Output format: json for newline-delimited JSON envelopes") - telemetryStart := &cobra.Command{Use: "start ", Short: "Start telemetry capture (enabled: true)", Args: cobra.ExactArgs(1), RunE: runBrowsersTelemetryStart} - telemetryStart.Flags().StringP("output", "o", "", "Output format: json for raw API response") - telemetryStop := &cobra.Command{Use: "stop ", Short: "Stop telemetry capture (enabled: false)", Args: cobra.ExactArgs(1), RunE: runBrowsersTelemetryStop} - telemetryStop.Flags().StringP("output", "o", "", "Output format: json for raw API response") - telemetrySet := &cobra.Command{Use: "set ...", Short: "Set per-category telemetry config", Args: cobra.MinimumNArgs(2), RunE: runBrowsersTelemetrySet} - telemetrySet.Flags().StringP("output", "o", "", "Output format: json for raw API response") - telemetryStatus := &cobra.Command{Use: "status ", Short: "Show current telemetry configuration", Args: cobra.ExactArgs(1), RunE: runBrowsersTelemetryStatus} - telemetryStatus.Flags().StringP("output", "o", "", "Output format: json for raw API response") - telemetryRoot.AddCommand(telemetryStream, telemetryStart, telemetryStop, telemetrySet, telemetryStatus) + telemetryRoot.AddCommand(telemetryStream) browsersCmd.AddCommand(telemetryRoot) } -func runBrowsersTelemetryStart(cmd *cobra.Command, args []string) error { - client := getKernelClient(cmd) - svc := client.Browsers - out, _ := cmd.Flags().GetString("output") - b := BrowsersCmd{browsers: &svc} - return b.TelemetryStart(cmd.Context(), BrowsersTelemetryStartInput{Identifier: args[0], Output: out}) -} - -func runBrowsersTelemetryStop(cmd *cobra.Command, args []string) error { - client := getKernelClient(cmd) - svc := client.Browsers - out, _ := cmd.Flags().GetString("output") - b := BrowsersCmd{browsers: &svc} - return b.TelemetryStop(cmd.Context(), BrowsersTelemetryStopInput{Identifier: args[0], Output: out}) -} - -func runBrowsersTelemetrySet(cmd *cobra.Command, args []string) error { - client := getKernelClient(cmd) - svc := client.Browsers - out, _ := cmd.Flags().GetString("output") - b := BrowsersCmd{browsers: &svc} - return b.TelemetrySet(cmd.Context(), BrowsersTelemetrySetInput{Identifier: args[0], Categories: strings.Join(args[1:], ","), Output: out}) -} - -func runBrowsersTelemetryStatus(cmd *cobra.Command, args []string) error { - client := getKernelClient(cmd) - svc := client.Browsers - out, _ := cmd.Flags().GetString("output") - b := BrowsersCmd{browsers: &svc} - return b.TelemetryStatus(cmd.Context(), BrowsersTelemetryStatusInput{Identifier: args[0], Output: out}) -} - func runBrowsersTelemetryStream(cmd *cobra.Command, args []string) error { client := getKernelClient(cmd) svc := client.Browsers diff --git a/cmd/browsers_telemetry_test.go b/cmd/browsers_telemetry_test.go index 014ae1d..0d0ddc7 100644 --- a/cmd/browsers_telemetry_test.go +++ b/cmd/browsers_telemetry_test.go @@ -17,206 +17,6 @@ func (f *FakeBrowserTelemetryService) StreamStreaming(ctx context.Context, id st return makeStream([]kernel.BrowserTelemetryStreamResponse{}) } -func TestBrowsersTelemetryStart_SendsEnablePayload(t *testing.T) { - setupStdoutCapture(t) - var capturedID string - var captured kernel.BrowserUpdateParams - fake := &FakeBrowsersService{UpdateFunc: func(ctx context.Context, id string, body kernel.BrowserUpdateParams, opts ...option.RequestOption) (*kernel.BrowserUpdateResponse, error) { - capturedID = id - captured = body - return &kernel.BrowserUpdateResponse{SessionID: id}, nil - }} - b := BrowsersCmd{browsers: fake} - - err := b.TelemetryStart(context.Background(), BrowsersTelemetryStartInput{Identifier: "session123"}) - - assert.NoError(t, err) - assert.Equal(t, "session123", capturedID) - assert.True(t, captured.Telemetry.Enabled.Valid()) - assert.True(t, captured.Telemetry.Enabled.Value) - assert.Contains(t, outBuf.String(), "Started telemetry for browser session123") -} - -func TestBrowsersTelemetryStop_SendsDisablePayload(t *testing.T) { - setupStdoutCapture(t) - var capturedID string - var captured kernel.BrowserUpdateParams - fake := &FakeBrowsersService{UpdateFunc: func(ctx context.Context, id string, body kernel.BrowserUpdateParams, opts ...option.RequestOption) (*kernel.BrowserUpdateResponse, error) { - capturedID = id - captured = body - return &kernel.BrowserUpdateResponse{SessionID: id}, nil - }} - b := BrowsersCmd{browsers: fake} - - err := b.TelemetryStop(context.Background(), BrowsersTelemetryStopInput{Identifier: "session123"}) - - assert.NoError(t, err) - assert.Equal(t, "session123", capturedID) - assert.True(t, captured.Telemetry.Enabled.Valid()) - assert.False(t, captured.Telemetry.Enabled.Value) - assert.Contains(t, outBuf.String(), "Stopped telemetry for browser session123") -} - -func TestBrowsersTelemetrySet_PartialCategories(t *testing.T) { - setupStdoutCapture(t) - var captured kernel.BrowserUpdateParams - fake := &FakeBrowsersService{UpdateFunc: func(ctx context.Context, id string, body kernel.BrowserUpdateParams, opts ...option.RequestOption) (*kernel.BrowserUpdateResponse, error) { - captured = body - return &kernel.BrowserUpdateResponse{SessionID: id}, nil - }} - b := BrowsersCmd{browsers: fake} - - err := b.TelemetrySet(context.Background(), BrowsersTelemetrySetInput{Identifier: "session123", Categories: "network=on,page=off"}) - - assert.NoError(t, err) - assert.False(t, captured.Telemetry.Enabled.Valid()) - assert.True(t, captured.Telemetry.Browser.Network.Enabled.Valid()) - assert.True(t, captured.Telemetry.Browser.Network.Enabled.Value) - assert.True(t, captured.Telemetry.Browser.Page.Enabled.Valid()) - assert.False(t, captured.Telemetry.Browser.Page.Enabled.Value) - // Unspecified categories omitted — server retains their state - assert.False(t, captured.Telemetry.Browser.Console.Enabled.Valid()) - assert.False(t, captured.Telemetry.Browser.Interaction.Enabled.Valid()) -} - -func TestBrowsersTelemetrySet_InvalidCategory(t *testing.T) { - b := BrowsersCmd{browsers: &FakeBrowsersService{}} - - err := b.TelemetrySet(context.Background(), BrowsersTelemetrySetInput{Identifier: "session123", Categories: "foo=on"}) - - assert.Error(t, err) - assert.Contains(t, err.Error(), "unknown category") -} - -func TestBrowsersTelemetrySet_InvalidValue(t *testing.T) { - b := BrowsersCmd{browsers: &FakeBrowsersService{}} - - err := b.TelemetrySet(context.Background(), BrowsersTelemetrySetInput{Identifier: "session123", Categories: "network=yes"}) - - assert.Error(t, err) - assert.Contains(t, err.Error(), "must be 'on' or 'off'") -} - -func TestBrowsersTelemetryStart_UnsupportedOutputErrors(t *testing.T) { - b := BrowsersCmd{browsers: &FakeBrowsersService{}} - - err := b.TelemetryStart(context.Background(), BrowsersTelemetryStartInput{Identifier: "session123", Output: "yaml"}) - - assert.Error(t, err) - assert.Contains(t, err.Error(), "unsupported --output value") -} - -func TestBrowsersTelemetryStop_UnsupportedOutputErrors(t *testing.T) { - b := BrowsersCmd{browsers: &FakeBrowsersService{}} - - err := b.TelemetryStop(context.Background(), BrowsersTelemetryStopInput{Identifier: "session123", Output: "yaml"}) - - assert.Error(t, err) - assert.Contains(t, err.Error(), "unsupported --output value") -} - -func TestBrowsersTelemetrySet_UnsupportedOutputErrors(t *testing.T) { - b := BrowsersCmd{browsers: &FakeBrowsersService{}} - - err := b.TelemetrySet(context.Background(), BrowsersTelemetrySetInput{Identifier: "session123", Categories: "network=on", Output: "yaml"}) - - assert.Error(t, err) - assert.Contains(t, err.Error(), "unsupported --output value") -} - -func TestBrowsersTelemetryStatus_UnsupportedOutputErrors(t *testing.T) { - b := BrowsersCmd{browsers: &FakeBrowsersService{}} - - err := b.TelemetryStatus(context.Background(), BrowsersTelemetryStatusInput{Identifier: "session123", Output: "yaml"}) - - assert.Error(t, err) - assert.Contains(t, err.Error(), "unsupported --output value") -} - -func TestBrowsersTelemetrySet_WhitespaceTolerance(t *testing.T) { - setupStdoutCapture(t) - var captured kernel.BrowserUpdateParams - fake := &FakeBrowsersService{UpdateFunc: func(ctx context.Context, id string, body kernel.BrowserUpdateParams, opts ...option.RequestOption) (*kernel.BrowserUpdateResponse, error) { - captured = body - return &kernel.BrowserUpdateResponse{SessionID: id}, nil - }} - b := BrowsersCmd{browsers: fake} - - err := b.TelemetrySet(context.Background(), BrowsersTelemetrySetInput{Identifier: "session123", Categories: " network = on , page = off "}) - - assert.NoError(t, err) - assert.True(t, captured.Telemetry.Browser.Network.Enabled.Valid()) - assert.True(t, captured.Telemetry.Browser.Network.Enabled.Value) - assert.True(t, captured.Telemetry.Browser.Page.Enabled.Valid()) - assert.False(t, captured.Telemetry.Browser.Page.Enabled.Value) -} - -func TestBrowsersTelemetryStatus_PrintsCategories(t *testing.T) { - setupStdoutCapture(t) - fake := &FakeBrowsersService{GetFunc: func(ctx context.Context, id string, query kernel.BrowserGetParams, opts ...option.RequestOption) (*kernel.BrowserGetResponse, error) { - var resp kernel.BrowserGetResponse - if err := json.Unmarshal([]byte(`{"session_id":"session123","telemetry":{"browser":{"console":{"enabled":true},"interaction":{"enabled":false},"network":{"enabled":true},"page":{"enabled":false}}}}`), &resp); err != nil { - t.Fatalf("unmarshal: %v", err) - } - return &resp, nil - }} - b := BrowsersCmd{browsers: fake} - - err := b.TelemetryStatus(context.Background(), BrowsersTelemetryStatusInput{Identifier: "session123"}) - - assert.NoError(t, err) - out := outBuf.String() - assert.Contains(t, out, "enabled: on") - assert.Contains(t, out, "console: on") - assert.Contains(t, out, "interaction: off") - assert.Contains(t, out, "network: on") - assert.Contains(t, out, "page: off") -} - -func TestBrowsersTelemetryStatus_VMDefaultsAllOn(t *testing.T) { - setupStdoutCapture(t) - fake := &FakeBrowsersService{GetFunc: func(ctx context.Context, id string, query kernel.BrowserGetParams, opts ...option.RequestOption) (*kernel.BrowserGetResponse, error) { - // API returns browser:{} when using VM defaults — enabled field absent means true per SDK - var resp kernel.BrowserGetResponse - if err := json.Unmarshal([]byte(`{"session_id":"session123","telemetry":{"browser":{}}}`), &resp); err != nil { - t.Fatalf("unmarshal: %v", err) - } - return &resp, nil - }} - b := BrowsersCmd{browsers: fake} - - err := b.TelemetryStatus(context.Background(), BrowsersTelemetryStatusInput{Identifier: "session123"}) - - assert.NoError(t, err) - out := outBuf.String() - assert.Contains(t, out, "enabled: on") - assert.Contains(t, out, "console: on") - assert.Contains(t, out, "interaction: on") - assert.Contains(t, out, "network: on") - assert.Contains(t, out, "page: on") -} - -func TestBrowsersTelemetryStatus_StoppedShowsDisabled(t *testing.T) { - setupStdoutCapture(t) - fake := &FakeBrowsersService{GetFunc: func(ctx context.Context, id string, query kernel.BrowserGetParams, opts ...option.RequestOption) (*kernel.BrowserGetResponse, error) { - // API returns {} (no browser key) after telemetry stop - var resp kernel.BrowserGetResponse - if err := json.Unmarshal([]byte(`{"session_id":"session123","telemetry":{}}`), &resp); err != nil { - t.Fatalf("unmarshal: %v", err) - } - return &resp, nil - }} - b := BrowsersCmd{browsers: fake} - - err := b.TelemetryStatus(context.Background(), BrowsersTelemetryStatusInput{Identifier: "session123"}) - - assert.NoError(t, err) - out := outBuf.String() - assert.Contains(t, out, "enabled: off") - assert.NotContains(t, out, "console:") - assert.NotContains(t, out, "network:") -} - func TestTelemetryStream_UnknownCategoryErrors(t *testing.T) { b := BrowsersCmd{browsers: &FakeBrowsersService{}} @@ -297,3 +97,40 @@ func TestShouldEmit(t *testing.T) { }) } } + +func TestParseTelemetryCategories_PartialCategories(t *testing.T) { + p, err := parseTelemetryCategories("network=on,page=off") + + assert.NoError(t, err) + assert.True(t, p.Network.Enabled.Valid()) + assert.True(t, p.Network.Enabled.Value) + assert.True(t, p.Page.Enabled.Valid()) + assert.False(t, p.Page.Enabled.Value) + // Unspecified categories omitted — server retains their state + assert.False(t, p.Console.Enabled.Valid()) + assert.False(t, p.Interaction.Enabled.Valid()) +} + +func TestParseTelemetryCategories_InvalidCategory(t *testing.T) { + _, err := parseTelemetryCategories("foo=on") + + assert.Error(t, err) + assert.Contains(t, err.Error(), "unknown category") +} + +func TestParseTelemetryCategories_InvalidValue(t *testing.T) { + _, err := parseTelemetryCategories("network=yes") + + assert.Error(t, err) + assert.Contains(t, err.Error(), "must be 'on' or 'off'") +} + +func TestParseTelemetryCategories_WhitespaceTolerance(t *testing.T) { + p, err := parseTelemetryCategories(" network = on , page = off ") + + assert.NoError(t, err) + assert.True(t, p.Network.Enabled.Valid()) + assert.True(t, p.Network.Enabled.Value) + assert.True(t, p.Page.Enabled.Valid()) + assert.False(t, p.Page.Enabled.Value) +} From c179c81bed94813cd2ee69e4feb1df1690610c25 Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Thu, 28 May 2026 09:09:51 -0300 Subject: [PATCH 18/28] review: require explicit --telemetry=all instead of bare --telemetry --- README.md | 6 +++--- cmd/browsers.go | 6 ++---- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index e144908..000f46d 100644 --- a/README.md +++ b/README.md @@ -211,7 +211,7 @@ Commands with JSON output support: - `--start-url ` - Initial page to open on launch - `--pool-id ` - Acquire a browser from the specified pool (mutually exclusive with --pool-name; ignores other session flags) - `--pool-name ` - Acquire a browser from the pool name (mutually exclusive with --pool-id; ignores other session flags) - - `--telemetry` - Enable telemetry for all categories + - `--telemetry=all` - Enable telemetry for all categories - `--telemetry=` - Per-category config, e.g. `--telemetry=network=on,page=off` - `--output json`, `-o json` - Output raw JSON object - _Note: When a pool is specified, omit other session configuration flags—pool settings determine profile, proxy, viewport, etc._ @@ -224,7 +224,7 @@ Commands with JSON output support: - `--proxy-id ` - Set proxy; `--clear-proxy` to remove - `--profile-id ` / `--profile-name ` - Load a profile; `--save-changes` to persist on exit - `--viewport ` - Resize viewport; `--force` to resize during active live view or recording - - `--telemetry` - Enable telemetry for all categories; `--telemetry=off` to disable; `--telemetry=network=on,page=off` for per-category + - `--telemetry=all` to enable all categories; `--telemetry=off` to disable; `--telemetry=network=on,page=off` for per-category - `--output json`, `-o json` - Output raw JSON object - `kernel browsers curl ` - Make HTTP requests through a browser session's Chrome network stack - `-X, --request ` - HTTP method (default: GET; defaults to POST when `--data` is set) @@ -292,7 +292,7 @@ Commands with JSON output support: Telemetry config is a sub-field of the browser session. Use `browsers update` to enable, disable, or configure it, and `browsers get` to inspect the current state. -- Enable all categories: `kernel browsers update --telemetry` +- Enable all categories: `kernel browsers update --telemetry=all` - Disable: `kernel browsers update --telemetry=off` - Per-category: `kernel browsers update --telemetry=network=on,page=off` (valid: `console`, `interaction`, `network`, `page`; `system` always emits and cannot be toggled) diff --git a/cmd/browsers.go b/cmd/browsers.go index e00888b..70716be 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -2269,8 +2269,7 @@ func init() { browsersUpdateCmd.Flags().Bool("save-changes", false, "If set, save changes back to the profile when the session ends") browsersUpdateCmd.Flags().String("viewport", "", "Browser viewport size (e.g., 1920x1080@25). Supported: 2560x1440@10, 1920x1080@25, 1920x1200@25, 1440x900@25, 1024x768@60, 1200x800@60, 1280x800@60") browsersUpdateCmd.Flags().Bool("force", false, "Force viewport resize even when a live view or recording/replay is active") - browsersUpdateCmd.Flags().String("telemetry", "", "Update telemetry: --telemetry for all categories, --telemetry=off to disable, --telemetry=network=on,page=off for per-category") - browsersUpdateCmd.Flag("telemetry").NoOptDefVal = "all" + browsersUpdateCmd.Flags().String("telemetry", "", "Update telemetry: --telemetry=all to enable, --telemetry=off to disable, --telemetry=network=on,page=off for per-category") browsersCmd.AddCommand(browsersListCmd) browsersCmd.AddCommand(browsersCreateCmd) @@ -2536,8 +2535,7 @@ func init() { browsersCreateCmd.Flags().Bool("viewport-interactive", false, "Interactively select viewport size from list") browsersCreateCmd.Flags().String("pool-id", "", "Browser pool ID to acquire from (mutually exclusive with --pool-name)") browsersCreateCmd.Flags().String("pool-name", "", "Browser pool name to acquire from (mutually exclusive with --pool-id)") - browsersCreateCmd.Flags().String("telemetry", "", "Enable telemetry: --telemetry for all, --telemetry=network=on,page=off for per-category") - browsersCreateCmd.Flag("telemetry").NoOptDefVal = "all" + browsersCreateCmd.Flags().String("telemetry", "", "Enable telemetry: --telemetry=all to enable all, --telemetry=network=on,page=off for per-category") // curl curlCmd := &cobra.Command{ From dac65962567d80f54054b5c18de5885ac12f95af Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Thu, 28 May 2026 09:12:17 -0300 Subject: [PATCH 19/28] review: drop knownTelemetryTypes warning; collapse category lists knownTelemetryTypes was 22 hardcoded strings that would silently drift as new server-side event types shipped. Drop the warning; unrecognized --types values simply produce no matches, which is obvious. knownTelemetryCategories was settableCategories + "system" with no single source of truth. Remove it; validate --categories against settableCategories with an explicit carve-out for "system". --- cmd/browsers_telemetry.go | 23 +++-------------------- cmd/browsers_telemetry_test.go | 15 --------------- 2 files changed, 3 insertions(+), 35 deletions(-) diff --git a/cmd/browsers_telemetry.go b/cmd/browsers_telemetry.go index 35c3395..ee1b31b 100644 --- a/cmd/browsers_telemetry.go +++ b/cmd/browsers_telemetry.go @@ -74,21 +74,9 @@ func parseTelemetryCategories(s string) (kernel.BrowserTelemetryCategoriesConfig } // settableCategories are the categories accepted by --telemetry=. +// "system" is always-on and cannot be toggled, but is valid as a --categories stream filter. var settableCategories = []string{"console", "interaction", "network", "page"} -// knownTelemetryCategories are the real API event categories observable on stream. -var knownTelemetryCategories = []string{"console", "network", "page", "interaction", "system"} - -var knownTelemetryTypes = []string{ - "console_log", "console_error", - "network_request", "network_response", "network_loading_failed", "network_idle", - "page_navigation", "page_dom_content_loaded", "page_load", "page_tab_opened", - "page_layout_shift", "page_lcp", "page_layout_settled", "page_navigation_settled", - "interaction_click", "interaction_key", "interaction_scroll_settled", - "monitor_screenshot", "monitor_disconnected", "monitor_reconnected", - "monitor_reconnect_failed", "monitor_init_failed", -} - // eventCategoryFromRaw reads the category field directly from the raw event JSON. // Returns "" if the field is absent — callers that need a category must handle the empty case. func eventCategoryFromRaw(ev kernel.BrowserTelemetryEventUnion) string { @@ -119,13 +107,8 @@ func (b BrowsersCmd) TelemetryStream(ctx context.Context, in BrowsersTelemetrySt return err } for _, c := range in.Categories { - if !slices.Contains(knownTelemetryCategories, c) { - return fmt.Errorf("unknown category %q: must be one of %s", c, strings.Join(knownTelemetryCategories, ", ")) - } - } - for _, t := range in.Types { - if !slices.Contains(knownTelemetryTypes, t) { - pterm.Warning.Printf("unrecognized event type %q — no events will match\n", t) + if c != "system" && !slices.Contains(settableCategories, c) { + return fmt.Errorf("unknown category %q: must be one of %s", c, strings.Join(append(settableCategories, "system"), ", ")) } } if b.telemetry == nil { diff --git a/cmd/browsers_telemetry_test.go b/cmd/browsers_telemetry_test.go index 0d0ddc7..8e2142f 100644 --- a/cmd/browsers_telemetry_test.go +++ b/cmd/browsers_telemetry_test.go @@ -29,21 +29,6 @@ func TestTelemetryStream_UnknownCategoryErrors(t *testing.T) { assert.Contains(t, err.Error(), "unknown category") } -func TestTelemetryStream_UnknownTypeWarns(t *testing.T) { - setupStdoutCapture(t) - fake := &FakeBrowsersService{GetFunc: func(ctx context.Context, id string, query kernel.BrowserGetParams, opts ...option.RequestOption) (*kernel.BrowserGetResponse, error) { - return &kernel.BrowserGetResponse{SessionID: id}, nil - }} - b := BrowsersCmd{browsers: fake, telemetry: &FakeBrowserTelemetryService{}} - - err := b.TelemetryStream(context.Background(), BrowsersTelemetryStreamInput{ - Identifier: "session123", - Types: []string{"invalid_type"}, - }) - - assert.NoError(t, err) - assert.Contains(t, outBuf.String(), "unrecognized event type") -} func makeEvent(t *testing.T, raw string) kernel.BrowserTelemetryEventUnion { t.Helper() From 54a7620a4d22f60bccd6d5a10213cb1075cd3fb9 Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Thu, 28 May 2026 09:18:50 -0300 Subject: [PATCH 20/28] review: derive event category from Type field; drop json.Unmarshal per event --- cmd/browsers_telemetry.go | 25 ++++++++++++------------- cmd/browsers_telemetry_test.go | 18 +++++++++--------- 2 files changed, 21 insertions(+), 22 deletions(-) diff --git a/cmd/browsers_telemetry.go b/cmd/browsers_telemetry.go index ee1b31b..e0d3505 100644 --- a/cmd/browsers_telemetry.go +++ b/cmd/browsers_telemetry.go @@ -2,7 +2,6 @@ package cmd import ( "context" - "encoding/json" "errors" "fmt" "slices" @@ -77,23 +76,23 @@ func parseTelemetryCategories(s string) (kernel.BrowserTelemetryCategoriesConfig // "system" is always-on and cannot be toggled, but is valid as a --categories stream filter. var settableCategories = []string{"console", "interaction", "network", "page"} -// eventCategoryFromRaw reads the category field directly from the raw event JSON. -// Returns "" if the field is absent — callers that need a category must handle the empty case. -func eventCategoryFromRaw(ev kernel.BrowserTelemetryEventUnion) string { - var obj struct { - Category string `json:"category"` +// eventCategory derives the category from the event type prefix. +// "monitor_*" maps to "system"; all others use the prefix before the first "_". +// TODO(sdk): kernel-go-sdk should surface Category directly on BrowserTelemetryEventUnion. +func eventCategory(ev kernel.BrowserTelemetryEventUnion) string { + prefix, _, ok := strings.Cut(ev.Type, "_") + if !ok { + return ev.Type } - if raw := ev.RawJSON(); raw != "" { - if err := json.Unmarshal([]byte(raw), &obj); err == nil { - return obj.Category - } + if prefix == "monitor" { + return "system" } - return "" + return prefix } // shouldEmit applies client-side category/type filters to a telemetry event. func shouldEmit(ev kernel.BrowserTelemetryEventUnion, categories, types []string) bool { - if len(categories) > 0 && !slices.Contains(categories, eventCategoryFromRaw(ev)) { + if len(categories) > 0 && !slices.Contains(categories, eventCategory(ev)) { return false } if len(types) > 0 && !slices.Contains(types, ev.Type) { @@ -135,7 +134,7 @@ func (b BrowsersCmd) TelemetryStream(ctx context.Context, in BrowsersTelemetrySt continue } ts := time.UnixMicro(ev.Event.Ts).Local().Format("15:04:05") - pterm.Printf("%s [%s] %s\n", ts, eventCategoryFromRaw(ev.Event), ev.Event.Type) + pterm.Printf("%s [%s] %s\n", ts, eventCategory(ev.Event), ev.Event.Type) } if err := stream.Err(); err != nil { return util.CleanedUpSdkError{Err: err} diff --git a/cmd/browsers_telemetry_test.go b/cmd/browsers_telemetry_test.go index 8e2142f..e64d2f8 100644 --- a/cmd/browsers_telemetry_test.go +++ b/cmd/browsers_telemetry_test.go @@ -39,22 +39,22 @@ func makeEvent(t *testing.T, raw string) kernel.BrowserTelemetryEventUnion { return ev } -func TestEventCategoryFromRaw(t *testing.T) { +func TestEventCategory(t *testing.T) { cases := []struct { raw string want string }{ - // real category field present — used directly - {`{"type":"monitor_screenshot","category":"system","ts":0}`, "system"}, - {`{"type":"network_response","category":"network","ts":0}`, "network"}, - // no category field — returns "" - {`{"type":"console_log","ts":0}`, ""}, - {`{"type":"page_navigation","ts":0}`, ""}, - {`{"type":"nounderscore","ts":0}`, ""}, + {`{"type":"monitor_screenshot","ts":0}`, "system"}, + {`{"type":"monitor_disconnected","ts":0}`, "system"}, + {`{"type":"network_response","ts":0}`, "network"}, + {`{"type":"console_log","ts":0}`, "console"}, + {`{"type":"page_navigation","ts":0}`, "page"}, + {`{"type":"interaction_click","ts":0}`, "interaction"}, + {`{"type":"nounderscore","ts":0}`, "nounderscore"}, } for _, tc := range cases { ev := makeEvent(t, tc.raw) - assert.Equal(t, tc.want, eventCategoryFromRaw(ev), "raw=%s", tc.raw) + assert.Equal(t, tc.want, eventCategory(ev), "type=%s", ev.Type) } } From 4a0ba014f2680abcb8a429581c53b2d3c5617d8e Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Thu, 28 May 2026 09:28:24 -0300 Subject: [PATCH 21/28] nit: inline validateJSONOutput helper; drop errors import --- cmd/browsers_telemetry.go | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/cmd/browsers_telemetry.go b/cmd/browsers_telemetry.go index e0d3505..bb6c5d0 100644 --- a/cmd/browsers_telemetry.go +++ b/cmd/browsers_telemetry.go @@ -2,7 +2,6 @@ package cmd import ( "context" - "errors" "fmt" "slices" "strconv" @@ -30,13 +29,6 @@ type BrowsersTelemetryStreamInput struct { Output string } -func validateJSONOutput(out string) error { - if out != "" && out != "json" { - return errors.New("unsupported --output value: use 'json'") - } - return nil -} - // parseTelemetryCategories parses a comma-separated "name=on|off" string into // a BrowserTelemetryCategoriesConfigParam. Unmentioned categories are omitted. func parseTelemetryCategories(s string) (kernel.BrowserTelemetryCategoriesConfigParam, error) { @@ -102,8 +94,8 @@ func shouldEmit(ev kernel.BrowserTelemetryEventUnion, categories, types []string } func (b BrowsersCmd) TelemetryStream(ctx context.Context, in BrowsersTelemetryStreamInput) error { - if err := validateJSONOutput(in.Output); err != nil { - return err + if in.Output != "" && in.Output != "json" { + return fmt.Errorf("unsupported --output value: use 'json'") } for _, c := range in.Categories { if c != "system" && !slices.Contains(settableCategories, c) { From 373f618b4198fc43f6e0a562cdeb8acb79386a59 Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Thu, 28 May 2026 09:42:30 -0300 Subject: [PATCH 22/28] review: fix create/update --telemetry parity; fix append aliasing; gofmt --- cmd/browsers.go | 6 ++++-- cmd/browsers_telemetry.go | 7 +++++-- cmd/browsers_telemetry_test.go | 1 - cmd/browsers_test.go | 21 ++++++++++++++++++--- 4 files changed, 27 insertions(+), 8 deletions(-) diff --git a/cmd/browsers.go b/cmd/browsers.go index 70716be..a72d01b 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -427,12 +427,14 @@ func (b BrowsersCmd) Create(ctx context.Context, in BrowsersCreateInput) error { if in.Telemetry == "all" { params.Telemetry = kernel.BrowserTelemetryRequestConfigParam{Enabled: kernel.Opt(true)} + } else if in.Telemetry == "off" { + params.Telemetry = kernel.BrowserTelemetryRequestConfigParam{Enabled: kernel.Opt(false)} } else if in.Telemetry != "" { p, err := parseTelemetryCategories(in.Telemetry) if err != nil { return err } - params.Telemetry = kernel.BrowserTelemetryRequestConfigParam{Browser: p} + params.Telemetry = kernel.BrowserTelemetryRequestConfigParam{Enabled: kernel.Opt(true), Browser: p} } browser, err := b.browsers.New(ctx, params) @@ -2535,7 +2537,7 @@ func init() { browsersCreateCmd.Flags().Bool("viewport-interactive", false, "Interactively select viewport size from list") browsersCreateCmd.Flags().String("pool-id", "", "Browser pool ID to acquire from (mutually exclusive with --pool-name)") browsersCreateCmd.Flags().String("pool-name", "", "Browser pool name to acquire from (mutually exclusive with --pool-id)") - browsersCreateCmd.Flags().String("telemetry", "", "Enable telemetry: --telemetry=all to enable all, --telemetry=network=on,page=off for per-category") + browsersCreateCmd.Flags().String("telemetry", "", "Configure telemetry: --telemetry=all to enable, --telemetry=off to disable, --telemetry=network=on,page=off for per-category") // curl curlCmd := &cobra.Command{ diff --git a/cmd/browsers_telemetry.go b/cmd/browsers_telemetry.go index bb6c5d0..fbd0db5 100644 --- a/cmd/browsers_telemetry.go +++ b/cmd/browsers_telemetry.go @@ -68,6 +68,9 @@ func parseTelemetryCategories(s string) (kernel.BrowserTelemetryCategoriesConfig // "system" is always-on and cannot be toggled, but is valid as a --categories stream filter. var settableCategories = []string{"console", "interaction", "network", "page"} +// streamableCategories extends settableCategories with "system" for --categories stream filtering. +var streamableCategories = append(settableCategories[:len(settableCategories):len(settableCategories)], "system") + // eventCategory derives the category from the event type prefix. // "monitor_*" maps to "system"; all others use the prefix before the first "_". // TODO(sdk): kernel-go-sdk should surface Category directly on BrowserTelemetryEventUnion. @@ -98,8 +101,8 @@ func (b BrowsersCmd) TelemetryStream(ctx context.Context, in BrowsersTelemetrySt return fmt.Errorf("unsupported --output value: use 'json'") } for _, c := range in.Categories { - if c != "system" && !slices.Contains(settableCategories, c) { - return fmt.Errorf("unknown category %q: must be one of %s", c, strings.Join(append(settableCategories, "system"), ", ")) + if !slices.Contains(streamableCategories, c) { + return fmt.Errorf("unknown category %q: must be one of %s", c, strings.Join(streamableCategories, ", ")) } } if b.telemetry == nil { diff --git a/cmd/browsers_telemetry_test.go b/cmd/browsers_telemetry_test.go index e64d2f8..7caa6c9 100644 --- a/cmd/browsers_telemetry_test.go +++ b/cmd/browsers_telemetry_test.go @@ -29,7 +29,6 @@ func TestTelemetryStream_UnknownCategoryErrors(t *testing.T) { assert.Contains(t, err.Error(), "unknown category") } - func makeEvent(t *testing.T, raw string) kernel.BrowserTelemetryEventUnion { t.Helper() var ev kernel.BrowserTelemetryEventUnion diff --git a/cmd/browsers_test.go b/cmd/browsers_test.go index 94d35fc..d630de4 100644 --- a/cmd/browsers_test.go +++ b/cmd/browsers_test.go @@ -911,7 +911,6 @@ func (f *FakeProcessService) StdoutStreamStreaming(ctx context.Context, processI return makeStream([]kernel.BrowserProcessStdoutStreamResponse{{Stream: kernel.BrowserProcessStdoutStreamResponseStreamStdout, DataB64: "aGVsbG8=", Event: ""}, {Event: "exit", ExitCode: 0}}) } - type FakeLogService struct { StreamFunc func(ctx context.Context, id string, query kernel.BrowserLogStreamParams, opts ...option.RequestOption) *ssestream.Stream[shared.LogEvent] } @@ -1672,7 +1671,8 @@ func TestBrowsersCreate_WithTelemetryCategories(t *testing.T) { err := b.Create(context.Background(), BrowsersCreateInput{Telemetry: "network=on,page=off"}) assert.NoError(t, err) - assert.False(t, captured.Telemetry.Enabled.Valid()) + assert.True(t, captured.Telemetry.Enabled.Valid()) + assert.True(t, captured.Telemetry.Enabled.Value) assert.True(t, captured.Telemetry.Browser.Network.Enabled.Valid()) assert.True(t, captured.Telemetry.Browser.Network.Enabled.Value) assert.True(t, captured.Telemetry.Browser.Page.Enabled.Valid()) @@ -1681,6 +1681,22 @@ func TestBrowsersCreate_WithTelemetryCategories(t *testing.T) { assert.False(t, captured.Telemetry.Browser.Interaction.Enabled.Valid()) } +func TestBrowsersCreate_WithTelemetryOff(t *testing.T) { + setupStdoutCapture(t) + var captured kernel.BrowserNewParams + fake := &FakeBrowsersService{NewFunc: func(ctx context.Context, body kernel.BrowserNewParams, opts ...option.RequestOption) (*kernel.BrowserNewResponse, error) { + captured = body + return &kernel.BrowserNewResponse{SessionID: "session123", CdpWsURL: "ws://example"}, nil + }} + b := BrowsersCmd{browsers: fake} + + err := b.Create(context.Background(), BrowsersCreateInput{Telemetry: "off"}) + + assert.NoError(t, err) + assert.True(t, captured.Telemetry.Enabled.Valid()) + assert.False(t, captured.Telemetry.Enabled.Value) +} + func TestBrowsersCreate_WithoutTelemetry(t *testing.T) { setupStdoutCapture(t) var captured kernel.BrowserNewParams @@ -1783,4 +1799,3 @@ func TestBrowsersUpdate_ForceWithProxyButNoViewport_Errors(t *testing.T) { assert.Error(t, err) assert.Contains(t, err.Error(), "--force requires --viewport") } - From 1c711740cbacc63be18a620d887bb1695594ff24 Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Thu, 28 May 2026 10:03:52 -0300 Subject: [PATCH 23/28] review: DRY telemetry param logic; inline hasTelemetryChange; trim README --- README.md | 3 --- cmd/browsers.go | 24 +++++++----------------- cmd/browsers_telemetry.go | 16 ++++++++++++++++ 3 files changed, 23 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 000f46d..6657340 100644 --- a/README.md +++ b/README.md @@ -221,9 +221,6 @@ Commands with JSON output support: - `kernel browsers get ` - Get detailed browser session info - `--output json`, `-o json` - Output raw JSON object - `kernel browsers update ` - Update a running browser session - - `--proxy-id ` - Set proxy; `--clear-proxy` to remove - - `--profile-id ` / `--profile-name ` - Load a profile; `--save-changes` to persist on exit - - `--viewport ` - Resize viewport; `--force` to resize during active live view or recording - `--telemetry=all` to enable all categories; `--telemetry=off` to disable; `--telemetry=network=on,page=off` for per-category - `--output json`, `-o json` - Output raw JSON object - `kernel browsers curl ` - Make HTTP requests through a browser session's Chrome network stack diff --git a/cmd/browsers.go b/cmd/browsers.go index a72d01b..98690f6 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -425,16 +425,12 @@ func (b BrowsersCmd) Create(ctx context.Context, in BrowsersCreateInput) error { } } - if in.Telemetry == "all" { - params.Telemetry = kernel.BrowserTelemetryRequestConfigParam{Enabled: kernel.Opt(true)} - } else if in.Telemetry == "off" { - params.Telemetry = kernel.BrowserTelemetryRequestConfigParam{Enabled: kernel.Opt(false)} - } else if in.Telemetry != "" { - p, err := parseTelemetryCategories(in.Telemetry) + if in.Telemetry != "" { + t, err := applyTelemetryParam(in.Telemetry) if err != nil { return err } - params.Telemetry = kernel.BrowserTelemetryRequestConfigParam{Enabled: kernel.Opt(true), Browser: p} + params.Telemetry = t } browser, err := b.browsers.New(ctx, params) @@ -602,10 +598,8 @@ func (b BrowsersCmd) Update(ctx context.Context, in BrowsersUpdateInput) error { return fmt.Errorf("--force requires --viewport") } - hasTelemetryChange := in.Telemetry != "" - // Validate that at least one update option is provided - if !hasProxyChange && !hasProfileChange && !hasViewportChange && !hasTelemetryChange { + if !hasProxyChange && !hasProfileChange && !hasViewportChange && in.Telemetry == "" { return fmt.Errorf("must specify at least one of: --proxy-id, --clear-proxy, --profile-id, --profile-name, --viewport, or --telemetry") } @@ -632,16 +626,12 @@ func (b BrowsersCmd) Update(ctx context.Context, in BrowsersUpdateInput) error { } // Handle telemetry changes - if in.Telemetry == "all" { - params.Telemetry = kernel.BrowserTelemetryRequestConfigParam{Enabled: kernel.Opt(true)} - } else if in.Telemetry == "off" { - params.Telemetry = kernel.BrowserTelemetryRequestConfigParam{Enabled: kernel.Opt(false)} - } else if in.Telemetry != "" { - p, err := parseTelemetryCategories(in.Telemetry) + if in.Telemetry != "" { + t, err := applyTelemetryParam(in.Telemetry) if err != nil { return err } - params.Telemetry = kernel.BrowserTelemetryRequestConfigParam{Enabled: kernel.Opt(true), Browser: p} + params.Telemetry = t } // Handle viewport changes diff --git a/cmd/browsers_telemetry.go b/cmd/browsers_telemetry.go index fbd0db5..e2523e8 100644 --- a/cmd/browsers_telemetry.go +++ b/cmd/browsers_telemetry.go @@ -64,6 +64,22 @@ func parseTelemetryCategories(s string) (kernel.BrowserTelemetryCategoriesConfig return p, nil } +// applyTelemetryParam converts a --telemetry flag value to the API param. +func applyTelemetryParam(s string) (kernel.BrowserTelemetryRequestConfigParam, error) { + switch s { + case "all": + return kernel.BrowserTelemetryRequestConfigParam{Enabled: kernel.Opt(true)}, nil + case "off": + return kernel.BrowserTelemetryRequestConfigParam{Enabled: kernel.Opt(false)}, nil + default: + p, err := parseTelemetryCategories(s) + if err != nil { + return kernel.BrowserTelemetryRequestConfigParam{}, err + } + return kernel.BrowserTelemetryRequestConfigParam{Enabled: kernel.Opt(true), Browser: p}, nil + } +} + // settableCategories are the categories accepted by --telemetry=. // "system" is always-on and cannot be toggled, but is valid as a --categories stream filter. var settableCategories = []string{"console", "interaction", "network", "page"} From a213e331a3025cd4ccb7e6acdf3a535ff5034a08 Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Thu, 28 May 2026 10:14:50 -0300 Subject: [PATCH 24/28] review: address should-fix and nit feedback on telemetry stream --- README.md | 15 +++--- cmd/browsers_telemetry.go | 31 ++++++------ cmd/browsers_telemetry_test.go | 89 ++++++++++++++++++++++++++++++++-- 3 files changed, 108 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 6657340..2d339e8 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,8 @@ Commands with JSON output support: - **Apps**: `list`, `history` - **Deploy**: `deploy` (JSONL streaming), `history` - **Invoke**: `invoke` (JSONL streaming), `history` -- **Browser Sub-commands**: `replays list/start`, `process exec/spawn`, `fs file-info/list-files`, `telemetry stream` +- **Browser Sub-commands**: `replays list/start`, `process exec/spawn`, `fs file-info/list-files` +- **Browser NDJSON streaming**: `telemetry stream` ### Authentication @@ -211,8 +212,7 @@ Commands with JSON output support: - `--start-url ` - Initial page to open on launch - `--pool-id ` - Acquire a browser from the specified pool (mutually exclusive with --pool-name; ignores other session flags) - `--pool-name ` - Acquire a browser from the pool name (mutually exclusive with --pool-id; ignores other session flags) - - `--telemetry=all` - Enable telemetry for all categories - - `--telemetry=` - Per-category config, e.g. `--telemetry=network=on,page=off` + - `--telemetry=all` - Enable telemetry for all categories; `--telemetry=off` to disable; `--telemetry=network=on,page=off` for per-category - `--output json`, `-o json` - Output raw JSON object - _Note: When a pool is specified, omit other session configuration flags—pool settings determine profile, proxy, viewport, etc._ - `kernel browsers delete ` - Delete a browser @@ -221,7 +221,9 @@ Commands with JSON output support: - `kernel browsers get ` - Get detailed browser session info - `--output json`, `-o json` - Output raw JSON object - `kernel browsers update ` - Update a running browser session - - `--telemetry=all` to enable all categories; `--telemetry=off` to disable; `--telemetry=network=on,page=off` for per-category + - `--telemetry=all` - Enable telemetry for all categories + - `--telemetry=off` - Disable telemetry + - `--telemetry=` - Per-category config, e.g. `--telemetry=network=on,page=off` - `--output json`, `-o json` - Output raw JSON object - `kernel browsers curl ` - Make HTTP requests through a browser session's Chrome network stack - `-X, --request ` - HTTP method (default: GET; defaults to POST when `--data` is set) @@ -293,11 +295,12 @@ Telemetry config is a sub-field of the browser session. Use `browsers update` to - Disable: `kernel browsers update --telemetry=off` - Per-category: `kernel browsers update --telemetry=network=on,page=off` (valid: `console`, `interaction`, `network`, `page`; `system` always emits and cannot be toggled) -- `kernel browsers telemetry stream ` - Stream live telemetry events +- `kernel browsers telemetry stream ` - Stream live telemetry events (NDJSON with `-o json`) - `--categories ` - Filter by API event category (console,network,page,interaction,system); `system` covers all `monitor_*` events - `--types ` - Filter by event type (e.g. network_response,console_error) - - `--seq ` - Resume stream from sequence number (Last-Event-ID) + - `--seq ` - Resume stream from sequence number (Last-Event-ID); `--seq=0` resumes from the beginning - `-o, --output json` - Output newline-delimited JSON envelopes + - Default output: `15:04:05 [network] network_response` ### Browser Process Control diff --git a/cmd/browsers_telemetry.go b/cmd/browsers_telemetry.go index e2523e8..2f5f8b7 100644 --- a/cmd/browsers_telemetry.go +++ b/cmd/browsers_telemetry.go @@ -84,9 +84,6 @@ func applyTelemetryParam(s string) (kernel.BrowserTelemetryRequestConfigParam, e // "system" is always-on and cannot be toggled, but is valid as a --categories stream filter. var settableCategories = []string{"console", "interaction", "network", "page"} -// streamableCategories extends settableCategories with "system" for --categories stream filtering. -var streamableCategories = append(settableCategories[:len(settableCategories):len(settableCategories)], "system") - // eventCategory derives the category from the event type prefix. // "monitor_*" maps to "system"; all others use the prefix before the first "_". // TODO(sdk): kernel-go-sdk should surface Category directly on BrowserTelemetryEventUnion. @@ -113,39 +110,39 @@ func shouldEmit(ev kernel.BrowserTelemetryEventUnion, categories, types []string } func (b BrowsersCmd) TelemetryStream(ctx context.Context, in BrowsersTelemetryStreamInput) error { + if b.telemetry == nil { + return fmt.Errorf("telemetry service not available") + } if in.Output != "" && in.Output != "json" { return fmt.Errorf("unsupported --output value: use 'json'") } - for _, c := range in.Categories { - if !slices.Contains(streamableCategories, c) { - return fmt.Errorf("unknown category %q: must be one of %s", c, strings.Join(streamableCategories, ", ")) - } - } - if b.telemetry == nil { - pterm.Error.Println("telemetry service not available") - return nil - } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { return util.CleanedUpSdkError{Err: err} } params := kernel.BrowserTelemetryStreamParams{} - if in.Seq > 0 { + if in.Seq >= 0 { params.LastEventID = kernel.Opt(strconv.FormatInt(in.Seq, 10)) } stream := b.telemetry.StreamStreaming(ctx, br.SessionID, params) defer stream.Close() for stream.Next() { ev := stream.Current() - if !shouldEmit(ev.Event, in.Categories, in.Types) { + cat := eventCategory(ev.Event) + if len(in.Categories) > 0 && !slices.Contains(in.Categories, cat) { + continue + } + if len(in.Types) > 0 && !slices.Contains(in.Types, ev.Event.Type) { continue } if in.Output == "json" { - _ = util.PrintCompactJSONLine(ev) + if err := util.PrintCompactJSONLine(ev); err != nil { + return err + } continue } ts := time.UnixMicro(ev.Event.Ts).Local().Format("15:04:05") - pterm.Printf("%s [%s] %s\n", ts, eventCategory(ev.Event), ev.Event.Type) + pterm.Printf("%s %-11s %s\n", ts, "["+cat+"]", ev.Event.Type) } if err := stream.Err(); err != nil { return util.CleanedUpSdkError{Err: err} @@ -159,7 +156,7 @@ func init() { telemetryStream := &cobra.Command{Use: "stream ", Short: "Stream live telemetry events", Args: cobra.ExactArgs(1), RunE: runBrowsersTelemetryStream} telemetryStream.Flags().StringSlice("categories", []string{}, "Filter by API event category (console,network,page,interaction,system); system covers all monitor_* events") telemetryStream.Flags().StringSlice("types", []string{}, "Filter by event type (e.g. network_response,console_error)") - telemetryStream.Flags().Int64("seq", 0, "Resume stream from sequence number (Last-Event-ID)") + telemetryStream.Flags().Int64("seq", -1, "Resume stream from sequence number (Last-Event-ID); 0 means from the beginning") telemetryStream.Flags().StringP("output", "o", "", "Output format: json for newline-delimited JSON envelopes") telemetryRoot.AddCommand(telemetryStream) browsersCmd.AddCommand(telemetryRoot) diff --git a/cmd/browsers_telemetry_test.go b/cmd/browsers_telemetry_test.go index 7caa6c9..fb4e3d6 100644 --- a/cmd/browsers_telemetry_test.go +++ b/cmd/browsers_telemetry_test.go @@ -11,22 +11,103 @@ import ( "github.com/stretchr/testify/assert" ) -type FakeBrowserTelemetryService struct{} +type FakeBrowserTelemetryService struct { + StreamFunc func() *ssestream.Stream[kernel.BrowserTelemetryStreamResponse] +} func (f *FakeBrowserTelemetryService) StreamStreaming(ctx context.Context, id string, query kernel.BrowserTelemetryStreamParams, opts ...option.RequestOption) *ssestream.Stream[kernel.BrowserTelemetryStreamResponse] { + if f.StreamFunc != nil { + return f.StreamFunc() + } return makeStream([]kernel.BrowserTelemetryStreamResponse{}) } -func TestTelemetryStream_UnknownCategoryErrors(t *testing.T) { +func TestTelemetryStream_NilTelemetryErrors(t *testing.T) { b := BrowsersCmd{browsers: &FakeBrowsersService{}} err := b.TelemetryStream(context.Background(), BrowsersTelemetryStreamInput{ Identifier: "session123", - Categories: []string{"invalid"}, }) assert.Error(t, err) - assert.Contains(t, err.Error(), "unknown category") + assert.Contains(t, err.Error(), "telemetry service not available") +} + +func TestTelemetryStream_UnknownCategoryPassesThrough(t *testing.T) { + setupStdoutCapture(t) + fake := &FakeBrowsersService{GetFunc: func(ctx context.Context, id string, query kernel.BrowserGetParams, opts ...option.RequestOption) (*kernel.BrowserGetResponse, error) { + return &kernel.BrowserGetResponse{SessionID: id}, nil + }} + b := BrowsersCmd{browsers: fake, telemetry: &FakeBrowserTelemetryService{}} + + err := b.TelemetryStream(context.Background(), BrowsersTelemetryStreamInput{ + Identifier: "session123", + Categories: []string{"future_category"}, + Seq: -1, + }) + + assert.NoError(t, err) +} + +func TestTelemetryStream_SystemCategoryAccepted(t *testing.T) { + setupStdoutCapture(t) + fake := &FakeBrowsersService{GetFunc: func(ctx context.Context, id string, query kernel.BrowserGetParams, opts ...option.RequestOption) (*kernel.BrowserGetResponse, error) { + return &kernel.BrowserGetResponse{SessionID: id}, nil + }} + b := BrowsersCmd{browsers: fake, telemetry: &FakeBrowserTelemetryService{}} + + err := b.TelemetryStream(context.Background(), BrowsersTelemetryStreamInput{ + Identifier: "session123", + Categories: []string{"system"}, + Seq: -1, + }) + + assert.NoError(t, err) +} + +func TestTelemetryStream_EventsFlow(t *testing.T) { + setupStdoutCapture(t) + event := kernel.BrowserTelemetryStreamResponse{} + if err := json.Unmarshal([]byte(`{"event":{"type":"network_response","ts":1000000}}`), &event); err != nil { + t.Fatalf("unmarshal: %v", err) + } + fakeBrowsers := &FakeBrowsersService{GetFunc: func(ctx context.Context, id string, query kernel.BrowserGetParams, opts ...option.RequestOption) (*kernel.BrowserGetResponse, error) { + return &kernel.BrowserGetResponse{SessionID: id}, nil + }} + fakeTelemetry := &FakeBrowserTelemetryService{StreamFunc: func() *ssestream.Stream[kernel.BrowserTelemetryStreamResponse] { + return makeStream([]kernel.BrowserTelemetryStreamResponse{event}) + }} + b := BrowsersCmd{browsers: fakeBrowsers, telemetry: fakeTelemetry} + + err := b.TelemetryStream(context.Background(), BrowsersTelemetryStreamInput{ + Identifier: "session123", + Seq: -1, + }) + + assert.NoError(t, err) + assert.Contains(t, outBuf.String(), "network_response") +} + +func TestTelemetryStream_EventsFlow_JSON(t *testing.T) { + event := kernel.BrowserTelemetryStreamResponse{} + if err := json.Unmarshal([]byte(`{"event":{"type":"network_response","ts":1000000}}`), &event); err != nil { + t.Fatalf("unmarshal: %v", err) + } + fakeBrowsers := &FakeBrowsersService{GetFunc: func(ctx context.Context, id string, query kernel.BrowserGetParams, opts ...option.RequestOption) (*kernel.BrowserGetResponse, error) { + return &kernel.BrowserGetResponse{SessionID: id}, nil + }} + fakeTelemetry := &FakeBrowserTelemetryService{StreamFunc: func() *ssestream.Stream[kernel.BrowserTelemetryStreamResponse] { + return makeStream([]kernel.BrowserTelemetryStreamResponse{event}) + }} + b := BrowsersCmd{browsers: fakeBrowsers, telemetry: fakeTelemetry} + + err := b.TelemetryStream(context.Background(), BrowsersTelemetryStreamInput{ + Identifier: "session123", + Output: "json", + Seq: -1, + }) + + assert.NoError(t, err) } func makeEvent(t *testing.T, raw string) kernel.BrowserTelemetryEventUnion { From 92c6805cd3cc0d19b98b139a56921f810393231c Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Thu, 28 May 2026 10:19:09 -0300 Subject: [PATCH 25/28] review: fix tab alignment, rename buildTelemetryParam, validate --seq, add tests --- README.md | 4 ++- cmd/browsers.go | 4 +-- cmd/browsers_telemetry.go | 9 ++++-- cmd/browsers_telemetry_test.go | 55 ++++++++++++++++++++++++++++++++-- 4 files changed, 64 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 2d339e8..c34b0b0 100644 --- a/README.md +++ b/README.md @@ -212,7 +212,9 @@ Commands with JSON output support: - `--start-url ` - Initial page to open on launch - `--pool-id ` - Acquire a browser from the specified pool (mutually exclusive with --pool-name; ignores other session flags) - `--pool-name ` - Acquire a browser from the pool name (mutually exclusive with --pool-id; ignores other session flags) - - `--telemetry=all` - Enable telemetry for all categories; `--telemetry=off` to disable; `--telemetry=network=on,page=off` for per-category + - `--telemetry=all` - Enable telemetry for all categories + - `--telemetry=off` - Disable telemetry + - `--telemetry=` - Per-category config, e.g. `--telemetry=network=on,page=off` - `--output json`, `-o json` - Output raw JSON object - _Note: When a pool is specified, omit other session configuration flags—pool settings determine profile, proxy, viewport, etc._ - `kernel browsers delete ` - Delete a browser diff --git a/cmd/browsers.go b/cmd/browsers.go index 98690f6..15de224 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -426,7 +426,7 @@ func (b BrowsersCmd) Create(ctx context.Context, in BrowsersCreateInput) error { } if in.Telemetry != "" { - t, err := applyTelemetryParam(in.Telemetry) + t, err := buildTelemetryParam(in.Telemetry) if err != nil { return err } @@ -627,7 +627,7 @@ func (b BrowsersCmd) Update(ctx context.Context, in BrowsersUpdateInput) error { // Handle telemetry changes if in.Telemetry != "" { - t, err := applyTelemetryParam(in.Telemetry) + t, err := buildTelemetryParam(in.Telemetry) if err != nil { return err } diff --git a/cmd/browsers_telemetry.go b/cmd/browsers_telemetry.go index 2f5f8b7..9fa7cac 100644 --- a/cmd/browsers_telemetry.go +++ b/cmd/browsers_telemetry.go @@ -64,8 +64,8 @@ func parseTelemetryCategories(s string) (kernel.BrowserTelemetryCategoriesConfig return p, nil } -// applyTelemetryParam converts a --telemetry flag value to the API param. -func applyTelemetryParam(s string) (kernel.BrowserTelemetryRequestConfigParam, error) { +// buildTelemetryParam converts a --telemetry flag value to the API param. +func buildTelemetryParam(s string) (kernel.BrowserTelemetryRequestConfigParam, error) { switch s { case "all": return kernel.BrowserTelemetryRequestConfigParam{Enabled: kernel.Opt(true)}, nil @@ -120,6 +120,9 @@ func (b BrowsersCmd) TelemetryStream(ctx context.Context, in BrowsersTelemetrySt if err != nil { return util.CleanedUpSdkError{Err: err} } + if in.Seq < -1 { + return fmt.Errorf("--seq must be >= 0 (use --seq=0 to resume from the beginning, or omit to stream from now)") + } params := kernel.BrowserTelemetryStreamParams{} if in.Seq >= 0 { params.LastEventID = kernel.Opt(strconv.FormatInt(in.Seq, 10)) @@ -142,7 +145,7 @@ func (b BrowsersCmd) TelemetryStream(ctx context.Context, in BrowsersTelemetrySt continue } ts := time.UnixMicro(ev.Event.Ts).Local().Format("15:04:05") - pterm.Printf("%s %-11s %s\n", ts, "["+cat+"]", ev.Event.Type) + pterm.Printf("%s\t[%s]\t%s\n", ts, cat, ev.Event.Type) } if err := stream.Err(); err != nil { return util.CleanedUpSdkError{Err: err} diff --git a/cmd/browsers_telemetry_test.go b/cmd/browsers_telemetry_test.go index fb4e3d6..68a67e8 100644 --- a/cmd/browsers_telemetry_test.go +++ b/cmd/browsers_telemetry_test.go @@ -1,8 +1,11 @@ package cmd import ( + "bytes" "context" "encoding/json" + "io" + "os" "testing" kernel "github.com/kernel/kernel-go-sdk" @@ -11,6 +14,24 @@ import ( "github.com/stretchr/testify/assert" ) +// captureStdout redirects os.Stdout for the duration of the test and returns +// the captured output. Needed for paths that use fmt.Println rather than pterm. +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + r, w, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe: %v", err) + } + old := os.Stdout + os.Stdout = w + fn() + w.Close() + os.Stdout = old + var buf bytes.Buffer + io.Copy(&buf, r) + return buf.String() +} + type FakeBrowserTelemetryService struct { StreamFunc func() *ssestream.Stream[kernel.BrowserTelemetryStreamResponse] } @@ -101,13 +122,43 @@ func TestTelemetryStream_EventsFlow_JSON(t *testing.T) { }} b := BrowsersCmd{browsers: fakeBrowsers, telemetry: fakeTelemetry} + var err error + out := captureStdout(t, func() { + err = b.TelemetryStream(context.Background(), BrowsersTelemetryStreamInput{ + Identifier: "session123", + Output: "json", + Seq: -1, + }) + }) + + assert.NoError(t, err) + assert.Contains(t, out, "network_response") +} + +type capturingTelemetryService struct { + captured kernel.BrowserTelemetryStreamParams +} + +func (c *capturingTelemetryService) StreamStreaming(ctx context.Context, id string, query kernel.BrowserTelemetryStreamParams, opts ...option.RequestOption) *ssestream.Stream[kernel.BrowserTelemetryStreamResponse] { + c.captured = query + return makeStream([]kernel.BrowserTelemetryStreamResponse{}) +} + +func TestTelemetryStream_SeqZeroSetsLastEventID(t *testing.T) { + fakeBrowsers := &FakeBrowsersService{GetFunc: func(ctx context.Context, id string, query kernel.BrowserGetParams, opts ...option.RequestOption) (*kernel.BrowserGetResponse, error) { + return &kernel.BrowserGetResponse{SessionID: id}, nil + }} + capSvc := &capturingTelemetryService{} + b := BrowsersCmd{browsers: fakeBrowsers, telemetry: capSvc} + err := b.TelemetryStream(context.Background(), BrowsersTelemetryStreamInput{ Identifier: "session123", - Output: "json", - Seq: -1, + Seq: 0, }) assert.NoError(t, err) + assert.True(t, capSvc.captured.LastEventID.Valid()) + assert.Equal(t, "0", capSvc.captured.LastEventID.Value) } func makeEvent(t *testing.T, raw string) kernel.BrowserTelemetryEventUnion { From 6df0ac3e56b8d15a9300fcab25717c214241efc4 Mon Sep 17 00:00:00 2001 From: Archan Datta Date: Thu, 28 May 2026 10:21:12 -0300 Subject: [PATCH 26/28] nit: validate --seq before browsers.Get to fail fast --- cmd/browsers_telemetry.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/browsers_telemetry.go b/cmd/browsers_telemetry.go index 9fa7cac..bdc6403 100644 --- a/cmd/browsers_telemetry.go +++ b/cmd/browsers_telemetry.go @@ -116,13 +116,13 @@ func (b BrowsersCmd) TelemetryStream(ctx context.Context, in BrowsersTelemetrySt if in.Output != "" && in.Output != "json" { return fmt.Errorf("unsupported --output value: use 'json'") } + if in.Seq < -1 { + return fmt.Errorf("--seq must be >= 0 (use --seq=0 to resume from the beginning, or omit to stream from now)") + } br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) if err != nil { return util.CleanedUpSdkError{Err: err} } - if in.Seq < -1 { - return fmt.Errorf("--seq must be >= 0 (use --seq=0 to resume from the beginning, or omit to stream from now)") - } params := kernel.BrowserTelemetryStreamParams{} if in.Seq >= 0 { params.LastEventID = kernel.Opt(strconv.FormatInt(in.Seq, 10)) From 26e6b5b238b453f103b61fe2a0a6e57e0e9d69be Mon Sep 17 00:00:00 2001 From: archandatta <35818003+archandatta@users.noreply.github.com> Date: Thu, 28 May 2026 13:58:19 +0000 Subject: [PATCH 27/28] docs: fix browser telemetry README inaccuracies - mention browsers create as a --telemetry surface (not just update) - document partial-update semantics for per-category config - note default --seq behavior (omit to stream from now) - correct the default output example to call out tab-separated format --- README.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index c34b0b0..5c189cf 100644 --- a/README.md +++ b/README.md @@ -291,18 +291,20 @@ Commands with JSON output support: ### Browser Telemetry -Telemetry config is a sub-field of the browser session. Use `browsers update` to enable, disable, or configure it, and `browsers get` to inspect the current state. +Telemetry config is a sub-field of the browser session. Use `browsers create` or `browsers update` to enable, disable, or configure it, and `browsers get` to inspect the current state. - Enable all categories: `kernel browsers update --telemetry=all` - Disable: `kernel browsers update --telemetry=off` - Per-category: `kernel browsers update --telemetry=network=on,page=off` (valid: `console`, `interaction`, `network`, `page`; `system` always emits and cannot be toggled) +Per-category updates are partial — only categories you name are changed; others retain their current state. `--telemetry=all` and `--telemetry=off` reset the entire config. + - `kernel browsers telemetry stream ` - Stream live telemetry events (NDJSON with `-o json`) - - `--categories ` - Filter by API event category (console,network,page,interaction,system); `system` covers all `monitor_*` events - - `--types ` - Filter by event type (e.g. network_response,console_error) - - `--seq ` - Resume stream from sequence number (Last-Event-ID); `--seq=0` resumes from the beginning + - `--categories ` - Filter by event category (`console`, `network`, `page`, `interaction`, `system`); `system` matches `monitor_*` event types + - `--types ` - Filter by event type (e.g. `network_response`, `console_error`) + - `--seq ` - Resume from sequence number (Last-Event-ID); `--seq=0` replays from the beginning. Omit to stream from now. - `-o, --output json` - Output newline-delimited JSON envelopes - - Default output: `15:04:05 [network] network_response` + - Default output: tab-separated `