Skip to content

Commit be15bdc

Browse files
archandattaclaude
andcommitted
feat: add filtering and full-archive paging to browsers telemetry events
Layer additive options onto the existing telemetry events command: - --categories: server-side filter, sent as repeated category query params (the SDK comma-joins the typed field, which the endpoint will not match) - --types: client-side filter; walks every page in the window so matches on later pages are not dropped - --all: walk every page in the window instead of just the first - fall back to the raw identifier when Get 404s, so the archive of an ended session stays readable - validate --limit is within 1-100 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent b5b726e commit be15bdc

4 files changed

Lines changed: 177 additions & 21 deletions

File tree

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -321,9 +321,12 @@ Per-category updates are partial — only categories you name are changed; other
321321
- `-o, --output json` - Output newline-delimited JSON envelopes
322322
- Default output: tab-separated `<time>\t[<category>]\t<type>`, e.g. `15:04:05 [network] network_response`
323323
- `kernel browsers telemetry events <id>` - Read historical telemetry events (paged)
324-
- `--limit <n>` - Maximum number of events per page (default 20)
324+
- `--limit <n>` - Maximum number of events per page (1-100, default 20)
325325
- `--offset <cursor>` - Pagination cursor: pass the `X-Next-Offset` from a previous response
326326
- `--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
327+
- `--categories <list>` - Filter by event category (`console`, `network`, `page`, `interaction`, `control`, `connection`, `system`, `screenshot`, `captcha`, `monitor`); filtered server-side
328+
- `--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
329+
- `--all` - Walk every page in the window instead of just the first (ignores `--offset`; no `next_offset` is returned)
327330
- `-o, --output json` - Output `{ "events": [...], "next_offset": "..." }` (omit `next_offset` when there is no next page)
328331

329332
### Browser Process Control

cmd/browsers.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2753,10 +2753,13 @@ followed automatically by Chromium.`,
27532753
telemetryRoot.AddCommand(telemetryStream)
27542754

27552755
telemetryEvents := &cobra.Command{Use: "events <id>", Short: "Read historical telemetry events (paged)", Args: cobra.ExactArgs(1), RunE: runBrowsersTelemetryEvents}
2756-
telemetryEvents.Flags().Int64("limit", 0, "Maximum number of events per page (default 20)")
2756+
telemetryEvents.Flags().Int64("limit", 0, "Maximum number of events per page (1-100, default 20)")
27572757
telemetryEvents.Flags().Int64("offset", 0, "Pagination cursor: pass the X-Next-Offset from a previous response")
27582758
telemetryEvents.Flags().String("since", "", "Window start: RFC-3339 timestamp or a duration like 5m (default 5m). Ignored when --offset is set")
27592759
telemetryEvents.Flags().String("until", "", "Window end (exclusive): RFC-3339 timestamp or a duration like 5m")
2760+
telemetryEvents.Flags().StringSlice("categories", []string{}, "Filter by event category (console,network,page,interaction,control,connection,system,screenshot,captcha,monitor)")
2761+
telemetryEvents.Flags().StringSlice("types", []string{}, "Filter by event type (e.g. network_response,console_error); walks every page in the window")
2762+
telemetryEvents.Flags().Bool("all", false, "Walk every page in the window instead of just the first (ignores --offset)")
27602763
addJSONOutputFlag(telemetryEvents)
27612764
telemetryRoot.AddCommand(telemetryEvents)
27622765
browsersCmd.AddCommand(telemetryRoot)

cmd/browsers_telemetry.go

Lines changed: 61 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import (
2727
type BrowserTelemetryService interface {
2828
StreamStreaming(ctx context.Context, id string, query kernel.BrowserTelemetryStreamParams, opts ...option.RequestOption) (stream *ssestream.Stream[kernel.BrowserTelemetryStreamResponse])
2929
Events(ctx context.Context, id string, query kernel.BrowserTelemetryEventsParams, opts ...option.RequestOption) (res *pagination.OffsetPagination[kernel.BrowserTelemetryEventsResponse], err error)
30+
EventsAutoPaging(ctx context.Context, id string, query kernel.BrowserTelemetryEventsParams, opts ...option.RequestOption) *pagination.OffsetPaginationAutoPager[kernel.BrowserTelemetryEventsResponse]
3031
}
3132

3233
type BrowsersTelemetryStreamInput struct {
@@ -44,6 +45,9 @@ type BrowsersTelemetryEventsInput struct {
4445
Offset int64
4546
Since string
4647
Until string
48+
Categories []string
49+
Types []string
50+
All bool
4751
Output string
4852
}
4953

@@ -262,10 +266,21 @@ func (b BrowsersCmd) TelemetryEvents(ctx context.Context, in BrowsersTelemetryEv
262266
if err := validateJSONOutput(in.Output); err != nil {
263267
return err
264268
}
269+
if in.Limit != 0 && (in.Limit < 1 || in.Limit > 100) {
270+
return fmt.Errorf("invalid --limit value %d: must be between 1 and 100", in.Limit)
271+
}
272+
for _, c := range in.Categories {
273+
if !slices.Contains(streamFilterCategories, c) {
274+
return fmt.Errorf("invalid --categories value %q: must be one of %s", c, strings.Join(streamFilterCategories, ", "))
275+
}
276+
}
265277

266-
br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{})
267-
if err != nil {
268-
return util.CleanedUpSdkError{Err: err}
278+
// Resolve a name to a session ID when possible, but fall back to the identifier
279+
// as-is: the events archive outlives the session, so Get can 404 for an ended
280+
// session whose telemetry is still readable.
281+
sessionID := in.Identifier
282+
if br, gerr := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}); gerr == nil {
283+
sessionID = br.SessionID
269284
}
270285

271286
params := kernel.BrowserTelemetryEventsParams{}
@@ -283,24 +298,47 @@ func (b BrowsersCmd) TelemetryEvents(ctx context.Context, in BrowsersTelemetryEv
283298
if in.Until != "" {
284299
params.Until = kernel.Opt(in.Until)
285300
}
286-
287-
var raw *http.Response
288-
page, err := b.telemetry.Events(ctx, br.SessionID, params, option.WithResponseInto(&raw))
289-
if err != nil {
290-
return util.CleanedUpSdkError{Err: err}
301+
// Send each category as a repeated query param. The SDK serializes a []string
302+
// field as a single comma-joined value, but the endpoint expects the parameter
303+
// repeated, so a comma-joined value matches no category.
304+
opts := make([]option.RequestOption, 0, len(in.Categories)+1)
305+
for _, c := range in.Categories {
306+
opts = append(opts, option.WithQueryAdd("category", c))
291307
}
292308

293309
var items []kernel.BrowserTelemetryEventsResponse
294-
if page != nil {
295-
items = page.Items
296-
}
297-
298-
// Pagination: the API sets X-Has-More=true while more pages remain;
299-
// X-Next-Offset is the cursor to pass as --offset for the next page. Surface
300-
// it (in JSON and as the table hint) only when there is actually a next page.
301310
nextOffset := ""
302-
if raw != nil && strings.EqualFold(raw.Header.Get("X-Has-More"), "true") {
303-
nextOffset = raw.Header.Get("X-Next-Offset")
311+
312+
// A --types filter is client-side (the archive endpoint filters only by
313+
// category), so it must see every page to be complete. Walk the whole window
314+
// whenever --all or a --types filter is set; otherwise read a single page and
315+
// surface the X-Next-Offset cursor for manual --offset paging.
316+
if in.All || len(in.Types) > 0 {
317+
pager := b.telemetry.EventsAutoPaging(ctx, sessionID, params, opts...)
318+
for pager.Next() {
319+
it := pager.Current()
320+
if shouldEmit(it.Event.Category, it.Event.Type, nil, in.Types) {
321+
items = append(items, it)
322+
}
323+
}
324+
if err := pager.Err(); err != nil {
325+
return util.CleanedUpSdkError{Err: err}
326+
}
327+
} else {
328+
var raw *http.Response
329+
page, err := b.telemetry.Events(ctx, sessionID, params, append(opts, option.WithResponseInto(&raw))...)
330+
if err != nil {
331+
return util.CleanedUpSdkError{Err: err}
332+
}
333+
if page != nil {
334+
items = page.Items
335+
}
336+
// The API sets X-Has-More=true while more pages remain; X-Next-Offset is
337+
// the cursor to pass as --offset for the next page. Surface it (in JSON and
338+
// as the table hint) only when there is actually a next page.
339+
if raw != nil && strings.EqualFold(raw.Header.Get("X-Has-More"), "true") {
340+
nextOffset = raw.Header.Get("X-Next-Offset")
341+
}
304342
}
305343

306344
if in.Output == "json" {
@@ -354,13 +392,19 @@ func runBrowsersTelemetryEvents(cmd *cobra.Command, args []string) error {
354392
offset, _ := cmd.Flags().GetInt64("offset")
355393
since, _ := cmd.Flags().GetString("since")
356394
until, _ := cmd.Flags().GetString("until")
395+
categories, _ := cmd.Flags().GetStringSlice("categories")
396+
types, _ := cmd.Flags().GetStringSlice("types")
397+
all, _ := cmd.Flags().GetBool("all")
357398
b := BrowsersCmd{browsers: &svc, telemetry: &svc.Telemetry}
358399
return b.TelemetryEvents(cmd.Context(), BrowsersTelemetryEventsInput{
359400
Identifier: args[0],
360401
Limit: limit,
361402
Offset: offset,
362403
Since: since,
363404
Until: until,
405+
Categories: categories,
406+
Types: types,
407+
All: all,
364408
Output: out,
365409
})
366410
}

cmd/browsers_telemetry_test.go

Lines changed: 108 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"bytes"
55
"context"
66
"encoding/json"
7+
"fmt"
78
"io"
89
"os"
910
"testing"
@@ -34,8 +35,9 @@ func captureStdout(t *testing.T, fn func()) string {
3435
}
3536

3637
type FakeBrowserTelemetryService struct {
37-
StreamFunc func() *ssestream.Stream[kernel.BrowserTelemetryStreamResponse]
38-
EventsFunc func(ctx context.Context, id string, query kernel.BrowserTelemetryEventsParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.BrowserTelemetryEventsResponse], error)
38+
StreamFunc func() *ssestream.Stream[kernel.BrowserTelemetryStreamResponse]
39+
EventsFunc func(ctx context.Context, id string, query kernel.BrowserTelemetryEventsParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.BrowserTelemetryEventsResponse], error)
40+
EventsAutoPagingFunc func() *pagination.OffsetPaginationAutoPager[kernel.BrowserTelemetryEventsResponse]
3941
}
4042

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

57+
func (f *FakeBrowserTelemetryService) EventsAutoPaging(ctx context.Context, id string, query kernel.BrowserTelemetryEventsParams, opts ...option.RequestOption) *pagination.OffsetPaginationAutoPager[kernel.BrowserTelemetryEventsResponse] {
58+
if f.EventsAutoPagingFunc != nil {
59+
return f.EventsAutoPagingFunc()
60+
}
61+
return pagination.NewOffsetPaginationAutoPager(&pagination.OffsetPagination[kernel.BrowserTelemetryEventsResponse]{}, nil)
62+
}
63+
5564
func TestTelemetryStream_NilTelemetryErrors(t *testing.T) {
5665
b := BrowsersCmd{browsers: &FakeBrowsersService{}}
5766

@@ -425,3 +434,100 @@ func TestTelemetryEvents_OffsetIgnoresSinceKeepsUntil(t *testing.T) {
425434
assert.NoError(t, err)
426435
_ = buf
427436
}
437+
438+
func TestTelemetryEvents_UnknownCategoryErrors(t *testing.T) {
439+
b := BrowsersCmd{browsers: &FakeBrowsersService{}, telemetry: &FakeBrowserTelemetryService{}}
440+
441+
err := b.TelemetryEvents(context.Background(), BrowsersTelemetryEventsInput{Identifier: "br-1", Categories: []string{"netowrk"}})
442+
443+
assert.Error(t, err)
444+
assert.Contains(t, err.Error(), "invalid --categories value")
445+
}
446+
447+
func TestTelemetryEvents_InvalidLimitErrors(t *testing.T) {
448+
b := BrowsersCmd{browsers: &FakeBrowsersService{}, telemetry: &FakeBrowserTelemetryService{}}
449+
450+
for _, lim := range []int64{-1, 101} {
451+
err := b.TelemetryEvents(context.Background(), BrowsersTelemetryEventsInput{Identifier: "br-1", Limit: lim})
452+
assert.Error(t, err)
453+
assert.Contains(t, err.Error(), "invalid --limit value")
454+
}
455+
}
456+
457+
// Categories are filtered server-side, but the SDK comma-joins a []string field
458+
// into one value the endpoint won't match, so they go out as repeated query
459+
// params instead of the typed Category field.
460+
func TestTelemetryEvents_CategoriesSentAsRepeatedQueryParams(t *testing.T) {
461+
buf := capturePtermOutput(t)
462+
fakeBrowsers := &FakeBrowsersService{GetFunc: func(ctx context.Context, id string, query kernel.BrowserGetParams, opts ...option.RequestOption) (*kernel.BrowserGetResponse, error) {
463+
return &kernel.BrowserGetResponse{SessionID: "sess-1"}, nil
464+
}}
465+
var gotQuery kernel.BrowserTelemetryEventsParams
466+
var gotOpts []option.RequestOption
467+
fakeTelemetry := &FakeBrowserTelemetryService{
468+
EventsFunc: func(ctx context.Context, id string, query kernel.BrowserTelemetryEventsParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.BrowserTelemetryEventsResponse], error) {
469+
gotQuery, gotOpts = query, opts
470+
return &pagination.OffsetPagination[kernel.BrowserTelemetryEventsResponse]{}, nil
471+
},
472+
}
473+
b := BrowsersCmd{browsers: fakeBrowsers, telemetry: fakeTelemetry}
474+
475+
err := b.TelemetryEvents(context.Background(), BrowsersTelemetryEventsInput{Identifier: "br-1", Categories: []string{"console", "network"}})
476+
477+
assert.NoError(t, err)
478+
assert.Empty(t, gotQuery.Category, "categories must not use the comma-joined typed field")
479+
// Two category query params plus the response-capture option for the cursor.
480+
assert.Len(t, gotOpts, 3)
481+
_ = buf
482+
}
483+
484+
// The events archive outlives the session, so a 404 from Get (e.g. an ended
485+
// session) must not stop the read: the command falls back to the raw identifier.
486+
func TestTelemetryEvents_FallsBackToIdentifierWhenGetFails(t *testing.T) {
487+
buf := capturePtermOutput(t)
488+
fakeBrowsers := &FakeBrowsersService{GetFunc: func(ctx context.Context, id string, query kernel.BrowserGetParams, opts ...option.RequestOption) (*kernel.BrowserGetResponse, error) {
489+
return nil, fmt.Errorf("not found")
490+
}}
491+
var gotID string
492+
fakeTelemetry := &FakeBrowserTelemetryService{
493+
EventsFunc: func(ctx context.Context, id string, query kernel.BrowserTelemetryEventsParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.BrowserTelemetryEventsResponse], error) {
494+
gotID = id
495+
return &pagination.OffsetPagination[kernel.BrowserTelemetryEventsResponse]{}, nil
496+
},
497+
}
498+
b := BrowsersCmd{browsers: fakeBrowsers, telemetry: fakeTelemetry}
499+
500+
err := b.TelemetryEvents(context.Background(), BrowsersTelemetryEventsInput{Identifier: "ended-session-id"})
501+
502+
assert.NoError(t, err)
503+
assert.Equal(t, "ended-session-id", gotID)
504+
_ = buf
505+
}
506+
507+
// A --types filter is client-side, so it must scan every page in the window to be
508+
// complete. Setting --types (without --all) must therefore route through the
509+
// auto-pager, not the single-page fetch that could drop matches on later pages.
510+
func TestTelemetryEvents_TypesFilterWalksAllPages(t *testing.T) {
511+
buf := capturePtermOutput(t)
512+
fakeBrowsers := &FakeBrowsersService{GetFunc: func(ctx context.Context, id string, query kernel.BrowserGetParams, opts ...option.RequestOption) (*kernel.BrowserGetResponse, error) {
513+
return &kernel.BrowserGetResponse{SessionID: "sess-1"}, nil
514+
}}
515+
autoPaged := false
516+
fakeTelemetry := &FakeBrowserTelemetryService{
517+
EventsFunc: func(ctx context.Context, id string, query kernel.BrowserTelemetryEventsParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.BrowserTelemetryEventsResponse], error) {
518+
t.Fatalf("single-page Events must not be called when --types is set")
519+
return nil, nil
520+
},
521+
EventsAutoPagingFunc: func() *pagination.OffsetPaginationAutoPager[kernel.BrowserTelemetryEventsResponse] {
522+
autoPaged = true
523+
return pagination.NewOffsetPaginationAutoPager(&pagination.OffsetPagination[kernel.BrowserTelemetryEventsResponse]{}, nil)
524+
},
525+
}
526+
b := BrowsersCmd{browsers: fakeBrowsers, telemetry: fakeTelemetry}
527+
528+
err := b.TelemetryEvents(context.Background(), BrowsersTelemetryEventsInput{Identifier: "br-1", Types: []string{"network_response"}})
529+
530+
assert.NoError(t, err)
531+
assert.True(t, autoPaged, "--types must walk every page so the client-side filter is complete")
532+
_ = buf
533+
}

0 commit comments

Comments
 (0)