diff --git a/cmd/api_keys.go b/cmd/api_keys.go index 77374cf..47cc854 100644 --- a/cmd/api_keys.go +++ b/cmd/api_keys.go @@ -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 @@ -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} } diff --git a/cmd/api_keys_test.go b/cmd/api_keys_test.go index 70ce814..d7284b9 100644 --- a/cmd/api_keys_test.go +++ b/cmd/api_keys_test.go @@ -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...) } diff --git a/cmd/browsers.go b/cmd/browsers.go index 8d545be..6dee378 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -2749,6 +2749,16 @@ 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 ", Short: "Read recorded telemetry events", 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().Bool("all", false, "Fetch every page instead of just the first") + telemetryEvents.Flags().StringP("output", "o", "", "Output format: json for newline-delimited JSON envelopes") + telemetryRoot.AddCommand(telemetryEvents) + browsersCmd.AddCommand(telemetryRoot) // no flags for view; it takes a single positional argument diff --git a/cmd/browsers_telemetry.go b/cmd/browsers_telemetry.go index 2621520..355dccd 100644 --- a/cmd/browsers_telemetry.go +++ b/cmd/browsers_telemetry.go @@ -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 { @@ -232,3 +235,102 @@ func runBrowsersTelemetryStream(cmd *cobra.Command, args []string) error { Output: out, }) } + +type BrowsersTelemetryEventsInput struct { + Identifier string + Limit int64 + Since string + Until string + Categories []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 + } + 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) + } + if len(in.Categories) > 0 { + params.Category = in.Categories + } + + br, err := b.browsers.Get(ctx, in.Identifier, kernel.BrowserGetParams{}) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + emit := func(ev kernel.BrowserTelemetryEventsResponse) error { + 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 + } + + if in.All { + pager := b.telemetry.EventsAutoPaging(ctx, br.SessionID, params) + 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, br.SessionID, params) + 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 + } + } + } + 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") + 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, + All: all, + Output: out, + }) +} diff --git a/cmd/browsers_telemetry_test.go b/cmd/browsers_telemetry_test.go index f73a082..3581bfd 100644 --- a/cmd/browsers_telemetry_test.go +++ b/cmd/browsers_telemetry_test.go @@ -4,12 +4,14 @@ import ( "bytes" "context" "encoding/json" + "fmt" "io" "os" "testing" 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/stretchr/testify/assert" ) @@ -33,7 +35,9 @@ func captureStdout(t *testing.T, fn func()) string { } type FakeBrowserTelemetryService struct { - StreamFunc func() *ssestream.Stream[kernel.BrowserTelemetryStreamResponse] + StreamFunc func() *ssestream.Stream[kernel.BrowserTelemetryStreamResponse] + EventsFunc func(id string, query kernel.BrowserTelemetryEventsParams) (*pagination.OffsetPagination[kernel.BrowserTelemetryEventsResponse], error) + EventsAutoPagingFunc func() *pagination.OffsetPaginationAutoPager[kernel.BrowserTelemetryEventsResponse] } func (f *FakeBrowserTelemetryService) StreamStreaming(ctx context.Context, id string, query kernel.BrowserTelemetryStreamParams, opts ...option.RequestOption) *ssestream.Stream[kernel.BrowserTelemetryStreamResponse] { @@ -43,6 +47,126 @@ func (f *FakeBrowserTelemetryService) StreamStreaming(ctx context.Context, id st return makeStream([]kernel.BrowserTelemetryStreamResponse{}) } +func (f *FakeBrowserTelemetryService) Events(ctx context.Context, id string, query kernel.BrowserTelemetryEventsParams, opts ...option.RequestOption) (*pagination.OffsetPagination[kernel.BrowserTelemetryEventsResponse], error) { + if f.EventsFunc != nil { + return f.EventsFunc(id, query) + } + 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() + } + return nil +} + +func telemetryEventsPage(t *testing.T, raws ...string) *pagination.OffsetPagination[kernel.BrowserTelemetryEventsResponse] { + t.Helper() + items := make([]kernel.BrowserTelemetryEventsResponse, 0, len(raws)) + for _, raw := range raws { + var ev kernel.BrowserTelemetryEventsResponse + if err := json.Unmarshal([]byte(raw), &ev); err != nil { + t.Fatalf("unmarshal: %v", err) + } + items = append(items, ev) + } + return &pagination.OffsetPagination[kernel.BrowserTelemetryEventsResponse]{Items: items} +} + +func TestTelemetryEvents_NilTelemetryErrors(t *testing.T) { + b := BrowsersCmd{browsers: &FakeBrowsersService{}} + + err := b.TelemetryEvents(context.Background(), BrowsersTelemetryEventsInput{Identifier: "session123"}) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "telemetry service not available") +} + +func TestTelemetryEvents_UnsupportedOutputErrors(t *testing.T) { + b := BrowsersCmd{browsers: &FakeBrowsersService{}, telemetry: &FakeBrowserTelemetryService{}} + + err := b.TelemetryEvents(context.Background(), BrowsersTelemetryEventsInput{Identifier: "session123", Output: "yaml"}) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "unsupported --output value") +} + +func TestTelemetryEvents_UnknownCategoryErrors(t *testing.T) { + b := BrowsersCmd{browsers: &FakeBrowsersService{}, telemetry: &FakeBrowserTelemetryService{}} + + err := b.TelemetryEvents(context.Background(), BrowsersTelemetryEventsInput{Identifier: "session123", Categories: []string{"netowrk"}}) + + assert.Error(t, err) + assert.Contains(t, err.Error(), "invalid --categories value") +} + +func TestTelemetryEvents_SinglePageTextAndParams(t *testing.T) { + setupStdoutCapture(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 + }} + var gotID string + var gotQuery kernel.BrowserTelemetryEventsParams + fakeTelemetry := &FakeBrowserTelemetryService{EventsFunc: func(id string, query kernel.BrowserTelemetryEventsParams) (*pagination.OffsetPagination[kernel.BrowserTelemetryEventsResponse], error) { + gotID, gotQuery = id, query + return telemetryEventsPage(t, + `{"event":{"type":"network_response","category":"network","ts":1000000},"seq":7}`, + `{"event":{"type":"network_request","category":"network","ts":2000000},"seq":8}`, + ), nil + }} + b := BrowsersCmd{browsers: fakeBrowsers, telemetry: fakeTelemetry} + + err := b.TelemetryEvents(context.Background(), BrowsersTelemetryEventsInput{ + Identifier: "session123", + Limit: 5, + Since: "5m", + Until: "2020-01-01T00:00:00Z", + Categories: []string{"network"}, + }) + + assert.NoError(t, err) + assert.Equal(t, "session123", gotID) + assert.Equal(t, int64(5), gotQuery.Limit.Value) + assert.Equal(t, "5m", gotQuery.Since.Value) + assert.Equal(t, "2020-01-01T00:00:00Z", gotQuery.Until.Value) + assert.Equal(t, []string{"network"}, gotQuery.Category) + out := outBuf.String() + assert.Contains(t, out, "network_response") + assert.Contains(t, out, "network_request") + assert.Contains(t, out, "7") + assert.Contains(t, out, "8") +} + +func TestTelemetryEvents_SinglePageJSON(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 + }} + fakeTelemetry := &FakeBrowserTelemetryService{EventsFunc: func(id string, query kernel.BrowserTelemetryEventsParams) (*pagination.OffsetPagination[kernel.BrowserTelemetryEventsResponse], error) { + return telemetryEventsPage(t, `{"event":{"type":"network_response","ts":1000000},"seq":1}`), nil + }} + b := BrowsersCmd{browsers: fakeBrowsers, telemetry: fakeTelemetry} + + var err error + out := captureStdout(t, func() { + err = b.TelemetryEvents(context.Background(), BrowsersTelemetryEventsInput{Identifier: "session123", Output: "json"}) + }) + + assert.NoError(t, err) + assert.Contains(t, out, "network_response") +} + +func TestTelemetryEvents_GetErrorSurfaces(t *testing.T) { + fakeBrowsers := &FakeBrowsersService{GetFunc: func(ctx context.Context, id string, query kernel.BrowserGetParams, opts ...option.RequestOption) (*kernel.BrowserGetResponse, error) { + return nil, fmt.Errorf("boom") + }} + b := BrowsersCmd{browsers: fakeBrowsers, telemetry: &FakeBrowserTelemetryService{}} + + err := b.TelemetryEvents(context.Background(), BrowsersTelemetryEventsInput{Identifier: "session123"}) + + assert.Error(t, err) +} + func TestTelemetryStream_NilTelemetryErrors(t *testing.T) { b := BrowsersCmd{browsers: &FakeBrowsersService{}} diff --git a/go.mod b/go.mod index a014ca3..6ab268a 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.66.0 + github.com/kernel/kernel-go-sdk v0.70.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 0059b0f..6e472be 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.66.0 h1:pn+fSHHo4fJ4kYm8uOkF5J2rj6k1FC6NqlLzoxy2jy4= -github.com/kernel/kernel-go-sdk v0.66.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.70.0 h1:tgg8suA8R9Y4ZLiby0jNc0KJ1KqHpqg+a4l0sJkZJVM= +github.com/kernel/kernel-go-sdk v0.70.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=