Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +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`
- **Browser telemetry**: `telemetry stream` (live NDJSON), `telemetry events` (recorded archive)

### Authentication

Expand Down Expand Up @@ -320,6 +320,16 @@ Per-category updates are partial — only categories you name are changed; other
- `-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`

- `kernel browsers telemetry events <id>` - Read recorded telemetry events from the archive (the durable counterpart to live `stream`)
- By default returns the first page of events in the window; use `--all` to walk every page and dump the full archive
- `--limit <n>` - Max events per page (1-100, default 20)
- `--since <t>` / `--until <t>` - Window bounds as an RFC-3339 timestamp or a duration like `5m` (default window: last 5m)
- `--categories <list>` - Filter by event category (`console`, `network`, `page`, `interaction`, `control`, `connection`, `system`, `screenshot`, `captcha`, `monitor`)
- `--types <list>` - Filter by event type (e.g. `network_response`, `console_error`); filtered client-side, so this walks every page in the window for complete results
- `--all` - Fetch every page in the window instead of just the first
- `-o, --output json` - Output newline-delimited JSON envelopes
- Default output: tab-separated `<time>\t<seq>\t[<category>]\t<type>`

### Browser Process Control

- `kernel browsers process exec <id> [--] [command...]` - Execute a command synchronously
Expand Down
4 changes: 2 additions & 2 deletions cmd/api_keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import (

type APIKeysService interface {
New(ctx context.Context, body kernel.APIKeyNewParams, opts ...option.RequestOption) (*kernel.CreatedAPIKey, error)
Get(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.APIKey, error)
Get(ctx context.Context, id string, query kernel.APIKeyGetParams, opts ...option.RequestOption) (*kernel.APIKey, error)
Update(ctx context.Context, id string, body kernel.APIKeyUpdateParams, opts ...option.RequestOption) (*kernel.APIKey, error)
List(ctx context.Context, query kernel.APIKeyListParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.APIKey], error)
Delete(ctx context.Context, id string, opts ...option.RequestOption) error
Expand Down Expand Up @@ -145,7 +145,7 @@ func (c APIKeysCmd) Get(ctx context.Context, in APIKeysGetInput) error {
return err
}

key, err := c.apiKeys.Get(ctx, in.ID)
key, err := c.apiKeys.Get(ctx, in.ID, kernel.APIKeyGetParams{})
if err != nil {
return util.CleanedUpSdkError{Err: err}
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/api_keys_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ func (f *FakeAPIKeysService) New(ctx context.Context, body kernel.APIKeyNewParam
return createdAPIKeyFromJSON(`{"id":"key_123","name":"default","key":"sk_test","masked_key":"sk_...test","created_at":"2026-05-27T12:00:00Z","created_by":{"id":"user_123","email":"dev@example.com","name":"Dev"},"expires_at":null,"project_id":null,"project_name":null}`), nil
}

func (f *FakeAPIKeysService) Get(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.APIKey, error) {
func (f *FakeAPIKeysService) Get(ctx context.Context, id string, query kernel.APIKeyGetParams, opts ...option.RequestOption) (*kernel.APIKey, error) {
if f.GetFunc != nil {
return f.GetFunc(ctx, id, opts...)
}
Expand Down
20 changes: 20 additions & 0 deletions cmd/browsers.go
Original file line number Diff line number Diff line change
Expand Up @@ -2749,6 +2749,26 @@ followed automatically by Chromium.`,
telemetryStream.Flags().Int64("seq", -1, "Resume after sequence number N (Last-Event-ID); replays events with seq > N. Default -1 streams from now")
telemetryStream.Flags().StringP("output", "o", "", "Output format: json for newline-delimited JSON envelopes")
telemetryRoot.AddCommand(telemetryStream)

telemetryEvents := &cobra.Command{
Use: "events <id>",
Short: "Read recorded telemetry events",
Long: "Read recorded telemetry events for a browser session, in ascending sequence order.\n\n" +
"By default this returns the first page of events in the window (most recent window is the\n" +
"last 5m; adjust with --since/--until). Use --all to walk every page and dump the full archive.\n\n" +
"--types filters client-side, so it walks every page in the window (like --all) to return complete results.",
Args: cobra.ExactArgs(1),
RunE: runBrowsersTelemetryEvents,
}
telemetryEvents.Flags().Int64("limit", 0, "Max events per page (1-100, default 20)")
telemetryEvents.Flags().String("since", "", "Window start: RFC-3339 timestamp or duration like 5m (default 5m)")
telemetryEvents.Flags().String("until", "", "Window end (exclusive): RFC-3339 timestamp or duration like 5m")
telemetryEvents.Flags().StringSlice("categories", []string{}, "Filter by event category (console,network,page,interaction,control,connection,system,screenshot,captcha,monitor)")
telemetryEvents.Flags().StringSlice("types", []string{}, "Filter by event type (e.g. network_response,console_error); walks every page in the window")
telemetryEvents.Flags().Bool("all", false, "Fetch every page (the full archive in the window) instead of just the first")
telemetryEvents.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.

Inline JSON output flag

Low Severity

The new telemetry events command registers -o/--output with a hand-rolled StringP and custom help instead of addJSONOutputFlag, which centralizes the flag and Output format: json for raw API response wording across CLI commands.

Fix in Cursor Fix in Web

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

Reviewed by Cursor Bugbot for commit d18dca1. Configure here.

telemetryRoot.AddCommand(telemetryEvents)

browsersCmd.AddCommand(telemetryRoot)

// no flags for view; it takes a single positional argument
Expand Down
125 changes: 124 additions & 1 deletion cmd/browsers_telemetry.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,17 @@ import (
"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/pagination"
"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.
// BrowserTelemetryService defines the subset we use for browser telemetry.
type BrowserTelemetryService interface {
StreamStreaming(ctx context.Context, id string, query kernel.BrowserTelemetryStreamParams, opts ...option.RequestOption) (stream *ssestream.Stream[kernel.BrowserTelemetryStreamResponse])
Events(ctx context.Context, id string, query kernel.BrowserTelemetryEventsParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.BrowserTelemetryEventsResponse], error)
EventsAutoPaging(ctx context.Context, id string, query kernel.BrowserTelemetryEventsParams, opts ...option.RequestOption) *pagination.OffsetPaginationAutoPager[kernel.BrowserTelemetryEventsResponse]
}

type BrowsersTelemetryStreamInput struct {
Expand Down Expand Up @@ -232,3 +235,123 @@ func runBrowsersTelemetryStream(cmd *cobra.Command, args []string) error {
Output: out,
})
}

type BrowsersTelemetryEventsInput struct {
Identifier string
Limit int64
Since string
Until string
Categories []string
Types []string
All bool
Output string
}

func (b BrowsersCmd) TelemetryEvents(ctx context.Context, in BrowsersTelemetryEventsInput) error {
if b.telemetry == nil {
return fmt.Errorf("telemetry service not available")
}
if err := validateJSONOutput(in.Output); err != nil {
return err
}
if in.Limit != 0 && (in.Limit < 1 || in.Limit > 100) {
return fmt.Errorf("invalid --limit value %d: must be between 1 and 100", in.Limit)
}
for _, c := range in.Categories {
if !slices.Contains(streamFilterCategories, c) {
return fmt.Errorf("invalid --categories value %q: must be one of %s", c, strings.Join(streamFilterCategories, ", "))
}
}

params := kernel.BrowserTelemetryEventsParams{}
if in.Limit > 0 {
params.Limit = kernel.Opt(in.Limit)
}
if in.Since != "" {
params.Since = kernel.Opt(in.Since)
}
if in.Until != "" {
params.Until = kernel.Opt(in.Until)
}
// Send each category as a repeated query param. The SDK serializes a []string
// field as a single comma-joined value, but the endpoint expects the parameter
// repeated, so a comma-joined value matches no category.
opts := make([]option.RequestOption, 0, len(in.Categories))
for _, c := range in.Categories {
opts = append(opts, option.WithQueryAdd("category", c))
}

// Resolve a name to a session ID when possible, but fall back to the identifier
// as-is: the events archive outlives the session, so Get can 404 for an ended
// session whose telemetry is still readable.
sessionID := in.Identifier
if br, gerr := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}); gerr == nil {
sessionID = br.SessionID
}

emit := func(ev kernel.BrowserTelemetryEventsResponse) error {
// Category filtering is server-side; types are filtered here, matching stream.
if !shouldEmit(ev.Event.Category, ev.Event.Type, nil, in.Types) {
return nil
}
if in.Output == "json" {
return util.PrintCompactJSONLine(ev)
}
ts := time.UnixMicro(ev.Event.Ts).Local().Format("2006-01-02 15:04:05")
pterm.Printf("%s\t%d\t[%s]\t%s\n", ts, ev.Seq, ev.Event.Category, ev.Event.Type)
return nil
}

// A --types filter is client-side (the archive endpoint filters only by
// category), so it must see every page to be complete: fetching a single page
// could drop matching events on later pages. Walk the whole window whenever a
// type filter is active, the same as --all.
if in.All || len(in.Types) > 0 {
pager := b.telemetry.EventsAutoPaging(ctx, sessionID, params, opts...)
for pager.Next() {
if err := emit(pager.Current()); err != nil {
return err
}
}
if err := pager.Err(); err != nil {
return util.CleanedUpSdkError{Err: err}
}
return nil
}

page, err := b.telemetry.Events(ctx, sessionID, params, opts...)
if err != nil {
return util.CleanedUpSdkError{Err: err}
}
if page != nil {
for i := range page.Items {
if err := emit(page.Items[i]); err != nil {
return err
}
}
}
Comment thread
cursor[bot] marked this conversation as resolved.
return nil
}

func runBrowsersTelemetryEvents(cmd *cobra.Command, args []string) error {
client := getKernelClient(cmd)
svc := client.Browsers
out, _ := cmd.Flags().GetString("output")
limit, _ := cmd.Flags().GetInt64("limit")
since, _ := cmd.Flags().GetString("since")
until, _ := cmd.Flags().GetString("until")
categories, _ := cmd.Flags().GetStringSlice("categories")
types, _ := cmd.Flags().GetStringSlice("types")
all, _ := cmd.Flags().GetBool("all")
b := BrowsersCmd{browsers: &svc, telemetry: &svc.Telemetry}
return b.TelemetryEvents(cmd.Context(), BrowsersTelemetryEventsInput{
Identifier: args[0],
Limit: limit,
Since: since,
Until: until,
Categories: categories,
Types: types,
All: all,
Output: out,
})
}
Loading
Loading