Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
e424621
update sdk
archandatta May 27, 2026
baa3558
feat: add PrintCompactJSONLine helper for NDJSON output
archandatta May 27, 2026
6f8006f
feat: add --telemetry flag on browsers create
archandatta May 27, 2026
4540e3f
feat: add browsers telemetry stop subcommand
archandatta May 27, 2026
e97378f
feat: add browsers telemetry stream subcommand with NDJSON output
archandatta May 27, 2026
a4d8cd9
feat: add browsers telemetry start subcommand
archandatta May 27, 2026
1ccc8c9
chore: document browser telemetry create flag, start, stop, and stream
archandatta May 27, 2026
9e4ef95
feat: add browsers telemetry set, status subcommands and simplify sta…
archandatta May 27, 2026
116851b
review: address browser telemetry code review feedback
archandatta May 27, 2026
bd84338
fix: status misreports off when telemetry uses VM defaults
archandatta May 27, 2026
832790f
fix: stream --categories uses API category field and corrects known c…
archandatta May 27, 2026
ba5ea92
fix: remove incorrect api category, validated against kernel-images
archandatta May 27, 2026
1cbcb11
fix: telemetry status shows enabled on/off and skips categories when …
archandatta May 27, 2026
3ac29ac
chore: split browser telemetry into cmd/browsers_telemetry.go
archandatta May 27, 2026
fc4affb
review: address browsers_telemetry code review feedback
archandatta May 27, 2026
87922b3
review: fix readme inaccuracies in telemetry documentation
archandatta May 27, 2026
4f4a8ae
review: collapse telemetry start/stop/set/status into browsers update…
archandatta May 28, 2026
c179c81
review: require explicit --telemetry=all instead of bare --telemetry
archandatta May 28, 2026
dac6596
review: drop knownTelemetryTypes warning; collapse category lists
archandatta May 28, 2026
54a7620
review: derive event category from Type field; drop json.Unmarshal pe…
archandatta May 28, 2026
4a0ba01
nit: inline validateJSONOutput helper; drop errors import
archandatta May 28, 2026
373f618
review: fix create/update --telemetry parity; fix append aliasing; gofmt
archandatta May 28, 2026
1c71174
review: DRY telemetry param logic; inline hasTelemetryChange; trim RE…
archandatta May 28, 2026
a213e33
review: address should-fix and nit feedback on telemetry stream
archandatta May 28, 2026
92c6805
review: fix tab alignment, rename buildTelemetryParam, validate --seq…
archandatta May 28, 2026
6df0ac3
nit: validate --seq before browsers.Get to fail fast
archandatta May 28, 2026
26e6b5b
docs: fix browser telemetry README inaccuracies
archandatta May 28, 2026
c30484a
review(sayan): fix telemetry per-category wire encoding; validate str…
archandatta May 29, 2026
93d3168
Merge remote-tracking branch 'origin/main' into archand/kernel-1116/b…
archandatta May 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ Commands with JSON output support:
- **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 NDJSON streaming**: `telemetry stream`

### Authentication

Expand Down Expand Up @@ -212,13 +213,21 @@ Commands with JSON output support:
- `--start-url <url>` - Initial page to open on launch
- `--pool-id <id>` - Acquire a browser from the specified pool (mutually exclusive with --pool-name; ignores other session flags)
- `--pool-name <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` - Disable telemetry
- `--telemetry=<list>` - 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 <id>` - Delete a browser
- `kernel browsers view <id>` - Get live view URL for a browser
- `--output json`, `-o json` - Output JSON with liveViewUrl
- `kernel browsers get <id>` - Get detailed browser session info
- `--output json`, `-o json` - Output raw JSON object
- `kernel browsers update <id>` - Update a running browser session
- `--telemetry=all` - Enable telemetry for all categories
- `--telemetry=off` - Disable telemetry
- `--telemetry=<list>` - Per-category config, e.g. `--telemetry=network=on,page=off`
- `--output json`, `-o json` - Output raw JSON object
- `kernel browsers curl <id> <url>` - Make HTTP requests through a browser session's Chrome network stack
- `-X, --request <method>` - HTTP method (default: GET; defaults to POST when `--data` is set)
- `-H, --header <header>` - HTTP header, repeatable (`"Key: Value"` format)
Expand Down Expand Up @@ -281,6 +290,23 @@ Commands with JSON output support:
- `kernel browsers replays download <id> <replay-id>` - Download a replay video
- `-f, --output-file <path>` - Output file path for the replay video

### Browser Telemetry

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 <id> --telemetry=all`
- Disable: `kernel browsers update <id> --telemetry=off`
- Per-category: `kernel browsers update <id> --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 <id>` - Stream live telemetry events (NDJSON with `-o json`)
- `--categories <list>` - Filter by event category (`console`, `network`, `page`, `interaction`, `system`); `system` matches `monitor_*` event types
- `--types <list>` - Filter by event type (e.g. `network_response`, `console_error`)
- `--seq <n>` - 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: tab-separated `<time>\t[<category>]\t<type>`, e.g. `15:04:05 [network] network_response`

### Browser Process Control

- `kernel browsers process exec <id> [--] [command...]` - Execute a command synchronously
Expand Down
30 changes: 28 additions & 2 deletions cmd/browsers.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ type BrowsersCreateInput struct {
StartURL string
Extensions []string
Viewport string
Telemetry string
Output string
}

Expand Down Expand Up @@ -202,6 +203,7 @@ type BrowsersUpdateInput struct {
ProfileSaveChanges BoolFlag
Viewport string
Force bool
Telemetry string
Output string
}

Expand All @@ -215,6 +217,7 @@ type BrowsersCmd struct {
logs BrowserLogService
computer BrowserComputerService
playwright BrowserPlaywrightService
telemetry BrowserTelemetryService
}

type BrowsersListInput struct {
Expand Down Expand Up @@ -410,6 +413,14 @@ func (b BrowsersCmd) Create(ctx context.Context, in BrowsersCreateInput) error {
}
}

if in.Telemetry != "" {
t, err := buildNewTelemetryParam(in.Telemetry)
if err != nil {
return err
}
params.Telemetry = t
}

browser, err := b.browsers.New(ctx, params)
if err != nil {
return util.CleanedUpSdkError{Err: err}
Expand Down Expand Up @@ -576,8 +587,8 @@ func (b BrowsersCmd) Update(ctx context.Context, in BrowsersUpdateInput) error {
}

// 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 && in.Telemetry == "" {
return fmt.Errorf("must specify at least one of: --proxy-id, --clear-proxy, --profile-id, --profile-name, --viewport, or --telemetry")
}

params := kernel.BrowserUpdateParams{}
Expand All @@ -602,6 +613,15 @@ func (b BrowsersCmd) Update(ctx context.Context, in BrowsersUpdateInput) error {
}
}

// Handle telemetry changes
if in.Telemetry != "" {
t, err := buildUpdateTelemetryParam(in.Telemetry)
if err != nil {
return err
}
params.Telemetry = t
}

// Handle viewport changes
if hasViewportChange {
width, height, refreshRate, err := parseViewport(in.Viewport)
Expand Down Expand Up @@ -2229,6 +2249,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=all to enable, --telemetry=off to disable, --telemetry=network=on,page=off for per-category")

browsersCmd.AddCommand(browsersListCmd)
browsersCmd.AddCommand(browsersCreateCmd)
Expand Down Expand Up @@ -2494,6 +2515,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", "", "Configure telemetry: --telemetry=all to enable, --telemetry=off to disable, --telemetry=network=on,page=off for per-category")

// curl
curlCmd := &cobra.Command{
Expand Down Expand Up @@ -2563,6 +2585,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().GetString("telemetry")
output, _ := cmd.Flags().GetString("output")

if poolID != "" && poolName != "" {
Expand Down Expand Up @@ -2672,6 +2695,7 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error {
StartURL: startURL,
Extensions: extensions,
Viewport: viewport,
Telemetry: telemetry,
Output: output,
}

Expand Down Expand Up @@ -2730,6 +2754,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}
Expand All @@ -2742,6 +2767,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,
})
}
Expand Down
207 changes: 207 additions & 0 deletions cmd/browsers_telemetry.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
package cmd

import (
"context"
"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 BrowsersTelemetryStreamInput struct {
Identifier string
Categories []string
Types []string
Seq int64
Output string
}

// 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, ",") {
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(name), strings.TrimSpace(val)
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 %s", name, strings.Join(settableCategories, ", "))
}
}
return p, nil
}

// buildNewTelemetryParam converts a --telemetry flag value to the create API param.
func buildNewTelemetryParam(s string) (kernel.BrowserNewParamsTelemetry, error) {
switch s {
case "all":
return kernel.BrowserNewParamsTelemetry{Enabled: kernel.Opt(true)}, nil
case "off":
return kernel.BrowserNewParamsTelemetry{Enabled: kernel.Opt(false)}, nil
default:
p, err := parseTelemetryCategories(s)
if err != nil {
return kernel.BrowserNewParamsTelemetry{}, err
}
return kernel.BrowserNewParamsTelemetry{Browser: p}, nil
}
}

// buildUpdateTelemetryParam converts a --telemetry flag value to the update API param.
func buildUpdateTelemetryParam(s string) (kernel.BrowserUpdateParamsTelemetry, error) {
switch s {
case "all":
return kernel.BrowserUpdateParamsTelemetry{Enabled: kernel.Opt(true)}, nil
case "off":
return kernel.BrowserUpdateParamsTelemetry{Enabled: kernel.Opt(false)}, nil
default:
p, err := parseTelemetryCategories(s)
if err != nil {
return kernel.BrowserUpdateParamsTelemetry{}, err
}
return kernel.BrowserUpdateParamsTelemetry{Browser: p}, nil
}
}

// settableCategories are the categories accepted by --telemetry=<categories>.
// "system" is always-on and cannot be toggled, but is valid as a --categories stream filter.
var settableCategories = []string{"console", "interaction", "network", "page"}

// streamFilterCategories are the categories accepted by `telemetry stream --categories`.
var streamFilterCategories = []string{"console", "interaction", "network", "page", "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.
func eventCategory(ev kernel.BrowserTelemetryEventUnion) string {
prefix, _, ok := strings.Cut(ev.Type, "_")
if !ok {
return ev.Type
}
if prefix == "monitor" {
return "system"
}
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, eventCategory(ev)) {
return false
}
if len(types) > 0 && !slices.Contains(types, ev.Type) {
return false
}
return true
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Function shouldEmit defined but never called in production

Low Severity

The shouldEmit helper function is defined and unit-tested but never called from production code. TelemetryStream duplicates the exact same filtering logic inline (checking in.Categories and in.Types with slices.Contains) instead of calling shouldEmit. This means the tested function and the actual streaming behavior can drift independently — a fix applied to one won't propagate to the other.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 26e6b5b. Configure here.


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'")
}
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)")
}
for _, c := range in.Categories {
if !slices.Contains(streamFilterCategories, c) {
return fmt.Errorf("unknown --categories value %q: must be one of %s", c, strings.Join(streamFilterCategories, ", "))
}
}
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()
cat := eventCategory(ev.Event)
if len(in.Categories) > 0 && !slices.Contains(in.Categories, cat) {
Comment thread
archandatta marked this conversation as resolved.
continue
}
if len(in.Types) > 0 && !slices.Contains(in.Types, ev.Event.Type) {
continue
}
if in.Output == "json" {
if err := util.PrintCompactJSONLine(ev); err != nil {
return err
}
continue
}
ts := time.UnixMicro(ev.Event.Ts).Local().Format("15:04:05")
pterm.Printf("%s\t[%s]\t%s\n", ts, cat, 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 <id>", 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", -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")
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Telemetry stream uses inline output flag instead of shared helper

Low Severity

The telemetry stream command defines its output flag inline via StringP("output", "o", "", "Output format: json for newline-delimited JSON envelopes") and validates it with a hand-rolled check (if in.Output != "" && in.Output != "json"). Every other command in cmd/ uses addJSONOutputFlag(cmd) for flag registration and validateJSONOutput(output) for validation. The error message also drifts from the standard format used by ValidateJSONOutput.

Additional Locations (1)
Fix in Cursor Fix in Web

Triggered by learned rule: Use shared JSON output helpers in CLI commands

Reviewed by Cursor Bugbot for commit 93d3168. Configure here.

telemetryRoot.AddCommand(telemetryStream)
browsersCmd.AddCommand(telemetryRoot)
}

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,
})
}
Loading
Loading