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
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -321,9 +321,12 @@ 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 historical telemetry events (paged)
- `--limit <n>` - Maximum number of events per page (default 20)
- `--limit <n>` - Maximum number of events per page (1-100, default 20)
- `--offset <cursor>` - Pagination cursor: pass the `X-Next-Offset` from a previous response
- `--since <ts|dur>` / `--until <ts|dur>` - Time window (RFC-3339 timestamp or duration like `5m`). `--since` is ignored when `--offset` is set; `--until` still bounds the page
- `--categories <list>` - Filter by event category (`console`, `network`, `page`, `interaction`, `control`, `connection`, `system`, `screenshot`, `captcha`, `monitor`); filtered server-side
- `--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` - Walk every page in the window instead of just the first (ignores `--offset`; no `next_offset` is returned)
- `-o, --output json` - Output `{ "events": [...], "next_offset": "..." }` (omit `next_offset` when there is no next page)

### Browser Process Control
Expand Down
5 changes: 4 additions & 1 deletion cmd/browsers.go
Original file line number Diff line number Diff line change
Expand Up @@ -2753,10 +2753,13 @@ followed automatically by Chromium.`,
telemetryRoot.AddCommand(telemetryStream)

telemetryEvents := &cobra.Command{Use: "events <id>", Short: "Read historical telemetry events (paged)", Args: cobra.ExactArgs(1), RunE: runBrowsersTelemetryEvents}
telemetryEvents.Flags().Int64("limit", 0, "Maximum number of events per page (default 20)")
telemetryEvents.Flags().Int64("limit", 0, "Maximum number of events per page (1-100, default 20)")
telemetryEvents.Flags().Int64("offset", 0, "Pagination cursor: pass the X-Next-Offset from a previous response")
telemetryEvents.Flags().String("since", "", "Window start: RFC-3339 timestamp or a duration like 5m (default 5m). Ignored when --offset is set")
telemetryEvents.Flags().String("until", "", "Window end (exclusive): RFC-3339 timestamp or a 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, "Walk every page in the window instead of just the first (ignores --offset)")
addJSONOutputFlag(telemetryEvents)
telemetryRoot.AddCommand(telemetryEvents)
browsersCmd.AddCommand(telemetryRoot)
Expand Down
87 changes: 68 additions & 19 deletions cmd/browsers_telemetry.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import (
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) (res *pagination.OffsetPagination[kernel.BrowserTelemetryEventsResponse], err error)
EventsAutoPaging(ctx context.Context, id string, query kernel.BrowserTelemetryEventsParams, opts ...option.RequestOption) *pagination.OffsetPaginationAutoPager[kernel.BrowserTelemetryEventsResponse]
}

type BrowsersTelemetryStreamInput struct {
Expand All @@ -44,6 +45,9 @@ type BrowsersTelemetryEventsInput struct {
Offset int64
Since string
Until string
Categories []string
Types []string
All bool
Output string
}

Expand Down Expand Up @@ -262,45 +266,84 @@ func (b BrowsersCmd) TelemetryEvents(ctx context.Context, in BrowsersTelemetryEv
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, ", "))
}
}

br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{})
if err != nil {
return util.CleanedUpSdkError{Err: err}
// Resolve a name to a session ID. The events archive outlives the session, so
// a 404 (ended or unknown session) is not fatal: fall back to the identifier
// as-is, since its archive may still be readable. Surface any other error.
sessionID := in.Identifier
if br, gerr := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}); gerr == nil {
sessionID = br.SessionID
Comment thread
cursor[bot] marked this conversation as resolved.
} else if !util.IsNotFound(gerr) {
return util.CleanedUpSdkError{Err: gerr}
}

// A --types filter is client-side (the archive endpoint filters only by
// category), so it must see every page to be complete. Walk the whole window
// whenever --all or a --types filter is set; otherwise read a single page and
// surface the X-Next-Offset cursor for manual --offset paging.
fullScan := in.All || len(in.Types) > 0

params := kernel.BrowserTelemetryEventsParams{}
if in.Limit > 0 {
params.Limit = kernel.Opt(in.Limit)
}
if in.Offset > 0 {
if in.Offset > 0 && !fullScan {
params.Offset = kernel.Opt(in.Offset)
} else if in.Since != "" {
// Offset is an opaque cursor that encodes the window start, so --since is
// ignored once paging by offset; only send it for the first page.
// ignored once paging by offset; only send it for the first page. A full
// scan ignores --offset entirely and walks the window from --since.
params.Since = kernel.Opt(in.Since)
}
// --until still bounds the page even when paging by offset.
if in.Until != "" {
params.Until = kernel.Opt(in.Until)
}

var raw *http.Response
page, err := b.telemetry.Events(ctx, br.SessionID, params, option.WithResponseInto(&raw))
if err != nil {
return util.CleanedUpSdkError{Err: err}
// 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)+1)
for _, c := range in.Categories {
opts = append(opts, option.WithQueryAdd("category", c))
}

var items []kernel.BrowserTelemetryEventsResponse
if page != nil {
items = page.Items
}

// Pagination: the API sets X-Has-More=true while more pages remain;
// X-Next-Offset is the cursor to pass as --offset for the next page. Surface
// it (in JSON and as the table hint) only when there is actually a next page.
nextOffset := ""
if raw != nil && strings.EqualFold(raw.Header.Get("X-Has-More"), "true") {
nextOffset = raw.Header.Get("X-Next-Offset")

if fullScan {
pager := b.telemetry.EventsAutoPaging(ctx, sessionID, params, opts...)
Comment thread
cursor[bot] marked this conversation as resolved.
for pager.Next() {
it := pager.Current()
if shouldEmit(it.Event.Category, it.Event.Type, nil, in.Types) {
items = append(items, it)
}
}
if err := pager.Err(); err != nil {
return util.CleanedUpSdkError{Err: err}
}
} else {
var raw *http.Response
page, err := b.telemetry.Events(ctx, sessionID, params, append(opts, option.WithResponseInto(&raw))...)
if err != nil {
return util.CleanedUpSdkError{Err: err}
}
if page != nil {
items = page.Items
}
// The API sets X-Has-More=true while more pages remain; X-Next-Offset is
// the cursor to pass as --offset for the next page. Surface it (in JSON and
// as the table hint) only when there is actually a next page.
if raw != nil && strings.EqualFold(raw.Header.Get("X-Has-More"), "true") {
nextOffset = raw.Header.Get("X-Next-Offset")
}
}

if in.Output == "json" {
Expand Down Expand Up @@ -354,13 +397,19 @@ func runBrowsersTelemetryEvents(cmd *cobra.Command, args []string) error {
offset, _ := cmd.Flags().GetInt64("offset")
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,
Offset: offset,
Since: since,
Until: until,
Categories: categories,
Types: types,
All: all,
Output: out,
})
}
154 changes: 152 additions & 2 deletions cmd/browsers_telemetry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"context"
"encoding/json"
"io"
"net/http"
"os"
"testing"

Expand Down Expand Up @@ -34,8 +35,9 @@ func captureStdout(t *testing.T, fn func()) string {
}

type FakeBrowserTelemetryService struct {
StreamFunc func() *ssestream.Stream[kernel.BrowserTelemetryStreamResponse]
EventsFunc func(ctx context.Context, id string, query kernel.BrowserTelemetryEventsParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.BrowserTelemetryEventsResponse], error)
StreamFunc func() *ssestream.Stream[kernel.BrowserTelemetryStreamResponse]
EventsFunc func(ctx context.Context, id string, query kernel.BrowserTelemetryEventsParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.BrowserTelemetryEventsResponse], error)
EventsAutoPagingFunc func(id string, query kernel.BrowserTelemetryEventsParams, opts ...option.RequestOption) *pagination.OffsetPaginationAutoPager[kernel.BrowserTelemetryEventsResponse]
}

func (f *FakeBrowserTelemetryService) StreamStreaming(ctx context.Context, id string, query kernel.BrowserTelemetryStreamParams, opts ...option.RequestOption) *ssestream.Stream[kernel.BrowserTelemetryStreamResponse] {
Expand All @@ -52,6 +54,13 @@ func (f *FakeBrowserTelemetryService) Events(ctx context.Context, id string, que
return &pagination.OffsetPagination[kernel.BrowserTelemetryEventsResponse]{}, nil
}

func (f *FakeBrowserTelemetryService) EventsAutoPaging(ctx context.Context, id string, query kernel.BrowserTelemetryEventsParams, opts ...option.RequestOption) *pagination.OffsetPaginationAutoPager[kernel.BrowserTelemetryEventsResponse] {
if f.EventsAutoPagingFunc != nil {
return f.EventsAutoPagingFunc(id, query, opts...)
}
return pagination.NewOffsetPaginationAutoPager(&pagination.OffsetPagination[kernel.BrowserTelemetryEventsResponse]{}, nil)
}

func TestTelemetryStream_NilTelemetryErrors(t *testing.T) {
b := BrowsersCmd{browsers: &FakeBrowsersService{}}

Expand Down Expand Up @@ -425,3 +434,144 @@ func TestTelemetryEvents_OffsetIgnoresSinceKeepsUntil(t *testing.T) {
assert.NoError(t, err)
_ = buf
}

func TestTelemetryEvents_UnknownCategoryErrors(t *testing.T) {
b := BrowsersCmd{browsers: &FakeBrowsersService{}, telemetry: &FakeBrowserTelemetryService{}}

err := b.TelemetryEvents(context.Background(), BrowsersTelemetryEventsInput{Identifier: "br-1", Categories: []string{"netowrk"}})

assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid --categories value")
}

func TestTelemetryEvents_InvalidLimitErrors(t *testing.T) {
b := BrowsersCmd{browsers: &FakeBrowsersService{}, telemetry: &FakeBrowserTelemetryService{}}

for _, lim := range []int64{-1, 101} {
err := b.TelemetryEvents(context.Background(), BrowsersTelemetryEventsInput{Identifier: "br-1", Limit: lim})
assert.Error(t, err)
assert.Contains(t, err.Error(), "invalid --limit value")
}
}

// Categories are filtered server-side, but the SDK comma-joins a []string field
// into one value the endpoint won't match, so they go out as repeated query
// params instead of the typed Category field.
func TestTelemetryEvents_CategoriesSentAsRepeatedQueryParams(t *testing.T) {
buf := capturePtermOutput(t)
fakeBrowsers := &FakeBrowsersService{GetFunc: func(ctx context.Context, id string, query kernel.BrowserGetParams, opts ...option.RequestOption) (*kernel.BrowserGetResponse, error) {
return &kernel.BrowserGetResponse{SessionID: "sess-1"}, nil
}}
var gotQuery kernel.BrowserTelemetryEventsParams
var gotOpts []option.RequestOption
fakeTelemetry := &FakeBrowserTelemetryService{
EventsFunc: func(ctx context.Context, id string, query kernel.BrowserTelemetryEventsParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.BrowserTelemetryEventsResponse], error) {
gotQuery, gotOpts = query, opts
return &pagination.OffsetPagination[kernel.BrowserTelemetryEventsResponse]{}, nil
},
}
b := BrowsersCmd{browsers: fakeBrowsers, telemetry: fakeTelemetry}

err := b.TelemetryEvents(context.Background(), BrowsersTelemetryEventsInput{Identifier: "br-1", Categories: []string{"console", "network"}})

assert.NoError(t, err)
assert.Empty(t, gotQuery.Category, "categories must not use the comma-joined typed field")
// Two category query params plus the response-capture option for the cursor.
assert.Len(t, gotOpts, 3)
_ = buf
}

// The events archive outlives the session, so a 404 from Get (e.g. an ended
// session) must not stop the read: the command falls back to the raw identifier.
func TestTelemetryEvents_FallsBackToIdentifierOn404(t *testing.T) {
buf := capturePtermOutput(t)
fakeBrowsers := &FakeBrowsersService{GetFunc: func(ctx context.Context, id string, query kernel.BrowserGetParams, opts ...option.RequestOption) (*kernel.BrowserGetResponse, error) {
return nil, &kernel.Error{StatusCode: http.StatusNotFound}
}}
var gotID string
fakeTelemetry := &FakeBrowserTelemetryService{
EventsFunc: func(ctx context.Context, id string, query kernel.BrowserTelemetryEventsParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.BrowserTelemetryEventsResponse], error) {
gotID = id
return &pagination.OffsetPagination[kernel.BrowserTelemetryEventsResponse]{}, nil
},
}
b := BrowsersCmd{browsers: fakeBrowsers, telemetry: fakeTelemetry}

err := b.TelemetryEvents(context.Background(), BrowsersTelemetryEventsInput{Identifier: "ended-session-id"})

assert.NoError(t, err)
assert.Equal(t, "ended-session-id", gotID)
_ = buf
}

// A non-404 Get failure (auth, 5xx, transient) is a real error, not an ended
// session — it must surface, not get swallowed by the fallback.
func TestTelemetryEvents_SurfacesNonNotFoundGetError(t *testing.T) {
fakeBrowsers := &FakeBrowsersService{GetFunc: func(ctx context.Context, id string, query kernel.BrowserGetParams, opts ...option.RequestOption) (*kernel.BrowserGetResponse, error) {
return nil, &kernel.Error{StatusCode: http.StatusInternalServerError}
}}
fakeTelemetry := &FakeBrowserTelemetryService{
EventsFunc: func(ctx context.Context, id string, query kernel.BrowserTelemetryEventsParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.BrowserTelemetryEventsResponse], error) {
t.Fatalf("Events must not be called when Get fails with a non-404 error")
return nil, nil
},
}
b := BrowsersCmd{browsers: fakeBrowsers, telemetry: fakeTelemetry}

err := b.TelemetryEvents(context.Background(), BrowsersTelemetryEventsInput{Identifier: "br-1"})

assert.Error(t, err)
}

// A --types filter is client-side, so it must scan every page in the window to be
// complete. Setting --types (without --all) must therefore route through the
// auto-pager, not the single-page fetch that could drop matches on later pages.
func TestTelemetryEvents_TypesFilterWalksAllPages(t *testing.T) {
buf := capturePtermOutput(t)
fakeBrowsers := &FakeBrowsersService{GetFunc: func(ctx context.Context, id string, query kernel.BrowserGetParams, opts ...option.RequestOption) (*kernel.BrowserGetResponse, error) {
return &kernel.BrowserGetResponse{SessionID: "sess-1"}, nil
}}
autoPaged := false
fakeTelemetry := &FakeBrowserTelemetryService{
EventsFunc: func(ctx context.Context, id string, query kernel.BrowserTelemetryEventsParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.BrowserTelemetryEventsResponse], error) {
t.Fatalf("single-page Events must not be called when --types is set")
return nil, nil
},
EventsAutoPagingFunc: func(id string, query kernel.BrowserTelemetryEventsParams, opts ...option.RequestOption) *pagination.OffsetPaginationAutoPager[kernel.BrowserTelemetryEventsResponse] {
autoPaged = true
return pagination.NewOffsetPaginationAutoPager(&pagination.OffsetPagination[kernel.BrowserTelemetryEventsResponse]{}, nil)
},
}
b := BrowsersCmd{browsers: fakeBrowsers, telemetry: fakeTelemetry}

err := b.TelemetryEvents(context.Background(), BrowsersTelemetryEventsInput{Identifier: "br-1", Types: []string{"network_response"}})

assert.NoError(t, err)
assert.True(t, autoPaged, "--types must walk every page so the client-side filter is complete")
_ = buf
}

// A full-window scan (--all/--types) must ignore the manual --offset cursor and
// walk from --since; forwarding the offset would start mid-window and drop
// earlier pages, contradicting the documented behavior.
func TestTelemetryEvents_FullScanIgnoresOffsetUsesSince(t *testing.T) {
buf := capturePtermOutput(t)
fakeBrowsers := &FakeBrowsersService{GetFunc: func(ctx context.Context, id string, query kernel.BrowserGetParams, opts ...option.RequestOption) (*kernel.BrowserGetResponse, error) {
return &kernel.BrowserGetResponse{SessionID: "sess-1"}, nil
}}
var gotQuery kernel.BrowserTelemetryEventsParams
fakeTelemetry := &FakeBrowserTelemetryService{
EventsAutoPagingFunc: func(id string, query kernel.BrowserTelemetryEventsParams, opts ...option.RequestOption) *pagination.OffsetPaginationAutoPager[kernel.BrowserTelemetryEventsResponse] {
gotQuery = query
return pagination.NewOffsetPaginationAutoPager(&pagination.OffsetPagination[kernel.BrowserTelemetryEventsResponse]{}, nil)
},
}
b := BrowsersCmd{browsers: fakeBrowsers, telemetry: fakeTelemetry}

err := b.TelemetryEvents(context.Background(), BrowsersTelemetryEventsInput{Identifier: "br-1", All: true, Offset: 100, Since: "5m"})

assert.NoError(t, err)
assert.False(t, gotQuery.Offset.Valid(), "--all must not forward --offset")
assert.Equal(t, "5m", gotQuery.Since.Value, "--all walks the window from --since")
_ = buf
}
Loading