diff --git a/README.md b/README.md index 66cc048..a17e119 100644 --- a/README.md +++ b/README.md @@ -224,6 +224,7 @@ Commands with JSON output support: - `--telemetry=all` - Enable telemetry for all categories - `--telemetry=off` - Disable telemetry - `--telemetry=` - Per-category config, e.g. `--telemetry=network=on,page=off` + - `--telemetry-export-otlp ` - Export captured telemetry over OTLP to one of the org's configured destinations. Implies `--telemetry=all` when `--telemetry` is not set, since export requires capture. Use `--telemetry-export-otlp=off` to disable export. - `--chrome-policy ` - Custom Chrome enterprise policy as a JSON object. Kernel-managed policies (extensions, proxy, automation) are rejected server-side. - `--chrome-policy-file ` - Read the Chrome enterprise policy from a file (use `-` for stdin). Mutually exclusive with `--chrome-policy`. - `--output json`, `-o json` - Output raw JSON object @@ -321,6 +322,16 @@ Telemetry config is a sub-field of the browser session. Use `browsers create` or 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. +#### Exporting telemetry + +Captured telemetry can be exported over OTLP to one of the org's configured destinations with `--telemetry-export-otlp `. A value that looks like an ID is sent as one; anything else is resolved as a destination name, which must match exactly one destination in the org. + +- Capture and export: `kernel browsers create --telemetry-export-otlp my-collector` +- Capture without exporting: `kernel browsers create --telemetry=all` +- Stop exporting: `--telemetry-export-otlp=off` + +Export is bound at session creation, so it is available on `browsers create` and on the managed-auth commands that create a browser (`auth connections create`, `update`, and `login`). A browser session keeps the destination it was created with — `browsers update` cannot change it — and browser pools do not support export. + - `kernel browsers telemetry stream ` - Stream live telemetry events (NDJSON with `-o json`) - `--categories ` - Filter by event category (`console`, `network`, `page`, `interaction`, `control`, `connection`, `system`, `screenshot`, `captcha`, `monitor`) - `--types ` - Filter by event type (e.g. `network_response`, `console_error`) @@ -529,6 +540,11 @@ Per-category updates are partial — only categories you name are changed; other - `kernel proxies delete ` - Delete a proxy configuration - `-y, --yes` - Skip confirmation prompt +### Auth Context + +- `kernel auth context` - Show the identity and authorization context resolved for the current credentials: the authenticated principal, organization, credential scope, and the effective scope for the request. Credential secrets are never returned. Pass `--project ` to see the effective scope a project-scoped request would get. + - `--output json`, `-o json` - Output raw JSON object + ### Auth Connections Managed auth connections (`kernel auth connections`). The commands below are new or gained new flags; run `kernel auth connections --help` for the full command list. @@ -538,12 +554,15 @@ Managed auth connections (`kernel auth connections`). The commands below are new - `--page ` - Page number (1-based, default: 1) - `--per-page ` - Items per page (default: 20) - `--output json`, `-o json` - Output raw JSON array -- `kernel auth connections create` - New flag: +- `kernel auth connections create` - New flags: - `--telemetry=all` / `--telemetry=off` / `--telemetry=` - Default telemetry for this connection's browser sessions. Same semantics as `kernel browsers create` -- `kernel auth connections update ` - New flag: + - `--telemetry-export-otlp ` - Export this connection's captured telemetry over OTLP to one of the org's configured destinations. Implies `--telemetry=all` when `--telemetry` is not set. Use `=off` to disable export. +- `kernel auth connections update ` - New flags: - `--telemetry=all` / `--telemetry=off` / `--telemetry=` - Update telemetry for future browser sessions -- `kernel auth connections login ` - New flag: + - `--telemetry-export-otlp ` - Update where future sessions export captured telemetry. Naming a destination requires passing `--telemetry` in the same command, since the API validates capture and export together and enabling capture here would replace the connection's current category selection. Use `=off` to disable export. +- `kernel auth connections login ` - New flags: - `--telemetry=all` / `--telemetry=off` / `--telemetry=` - Telemetry override for this login only, merged onto the connection's config + - `--telemetry-export-otlp ` - Export override for this login only. Naming a destination requires passing `--telemetry` in the same command. Use `=off` to disable export. - `kernel auth connections submit ` - New flags: - `--field-value ` - Canonical field-id=value pair from the connection's `fields` list (repeatable); preferred over the legacy `--field` - `--choice-id ` - Canonical choice ID from the connection's `choices` list diff --git a/cmd/api_keys.go b/cmd/api_keys.go index c83f140..428b9cc 100644 --- a/cmd/api_keys.go +++ b/cmd/api_keys.go @@ -35,9 +35,15 @@ type APIKeysCreateInput struct { } type APIKeysListInput struct { - Limit int - Offset int - Output string + Limit int + Offset int + Name string + Query string + Status string + IncludeDeleted bool + SortBy string + SortDirection string + Output string } type APIKeysGetInput struct { @@ -116,6 +122,50 @@ func (c APIKeysCmd) List(ctx context.Context, in APIKeysListInput) error { if in.Offset > 0 { params.Offset = kernel.Int(int64(in.Offset)) } + if in.Name != "" { + params.Name = kernel.String(in.Name) + } + if in.Query != "" { + params.Query = kernel.String(in.Query) + } + // Prefer the newer --status filter; fall back to the deprecated + // --include-deleted so existing scripts keep working. + if in.Status != "" { + switch in.Status { + case "active": + params.Status = kernel.APIKeyListParamsStatusActive + case "deleted": + params.Status = kernel.APIKeyListParamsStatusDeleted + case "all": + params.Status = kernel.APIKeyListParamsStatusAll + default: + return fmt.Errorf("invalid --status value: %s (must be 'active', 'deleted', or 'all')", in.Status) + } + } else if in.IncludeDeleted { + params.IncludeDeleted = kernel.Opt(true) + } + if in.SortBy != "" { + switch in.SortBy { + case "created_at": + params.SortBy = kernel.APIKeyListParamsSortByCreatedAt + case "name": + params.SortBy = kernel.APIKeyListParamsSortByName + case "expires_at": + params.SortBy = kernel.APIKeyListParamsSortByExpiresAt + default: + return fmt.Errorf("invalid --sort-by value: %s (must be 'created_at', 'name', or 'expires_at')", in.SortBy) + } + } + if in.SortDirection != "" { + switch in.SortDirection { + case "asc": + params.SortDirection = kernel.APIKeyListParamsSortDirectionAsc + case "desc": + params.SortDirection = kernel.APIKeyListParamsSortDirectionDesc + default: + return fmt.Errorf("invalid --sort-direction value: %s (must be 'asc' or 'desc')", in.SortDirection) + } + } page, err := c.apiKeys.List(ctx, params) if err != nil { @@ -136,9 +186,15 @@ func (c APIKeysCmd) List(ctx context.Context, in APIKeysListInput) error { return nil } - table := pterm.TableData{{"ID", "Name", "Scope", "Project", "Masked Key", "Expires At", "Created At"}} + // Only surface Deleted At when the filter can actually return deleted keys. + showDeletedAt := in.IncludeDeleted || in.Status == "deleted" || in.Status == "all" + header := []string{"ID", "Name", "Scope", "Project", "Masked Key", "Expires At", "Created At"} + if showDeletedAt { + header = append(header, "Deleted At") + } + table := pterm.TableData{header} for _, key := range keys { - table = append(table, []string{ + row := []string{ key.ID, key.Name, formatAPIKeyScope(key), @@ -146,7 +202,11 @@ func (c APIKeysCmd) List(ctx context.Context, in APIKeysListInput) error { key.MaskedKey, formatAPIKeyExpiresAt(key), util.FormatLocal(key.CreatedAt), - }) + } + if showDeletedAt { + row = append(row, util.FormatLocal(key.DeletedAt)) + } + table = append(table, row) } PrintTableNoPad(table, true) return nil @@ -362,11 +422,23 @@ func runAPIKeysList(cmd *cobra.Command, args []string) error { c := getAPIKeysHandler(cmd) limit, _ := cmd.Flags().GetInt("limit") offset, _ := cmd.Flags().GetInt("offset") + name, _ := cmd.Flags().GetString("name") + query, _ := cmd.Flags().GetString("query") + status, _ := cmd.Flags().GetString("status") + includeDeleted, _ := cmd.Flags().GetBool("include-deleted") + sortBy, _ := cmd.Flags().GetString("sort-by") + sortDirection, _ := cmd.Flags().GetString("sort-direction") output, _ := cmd.Flags().GetString("output") return c.List(cmd.Context(), APIKeysListInput{ - Limit: limit, - Offset: offset, - Output: output, + Limit: limit, + Offset: offset, + Name: name, + Query: query, + Status: status, + IncludeDeleted: includeDeleted, + SortBy: sortBy, + SortDirection: sortDirection, + Output: output, }) } @@ -468,6 +540,12 @@ func init() { addJSONOutputFlag(apiKeysListCmd) apiKeysListCmd.Flags().Int("limit", 0, "Maximum number of results to return") apiKeysListCmd.Flags().Int("offset", 0, "Number of results to skip") + apiKeysListCmd.Flags().String("name", "", "Exact-match filter on API key name (names are not unique, so several keys may match)") + apiKeysListCmd.Flags().String("query", "", "Search API keys by name, creator, or project (identifiers and masked keys match by exact value or prefix)") + apiKeysListCmd.Flags().String("status", "", "Filter by status: 'active' (default), 'deleted', or 'all'") + apiKeysListCmd.Flags().Bool("include-deleted", false, "Deprecated: Use --status all instead. Include soft-deleted API keys in the results") + apiKeysListCmd.Flags().String("sort-by", "", "Sort by: created_at, name, or expires_at") + apiKeysListCmd.Flags().String("sort-direction", "", "Sort direction: asc or desc") addJSONOutputFlag(apiKeysGetCmd) apiKeysGetCmd.Flags().Bool("include-deleted", false, "Include soft-deleted API keys in the lookup") diff --git a/cmd/audit_logs_download.go b/cmd/audit_logs_download.go index a49954e..56c5ec8 100644 --- a/cmd/audit_logs_download.go +++ b/cmd/audit_logs_download.go @@ -30,6 +30,7 @@ type AuditLogsDownloadInput struct { Service string AuthStrategy string UserIDs []string + Format string To string Force bool } @@ -50,7 +51,7 @@ func (c AuditLogsCmd) Download(ctx context.Context, in AuditLogsDownloadInput) e outPath := in.To if outPath == "" { - outPath = defaultAuditLogsDownloadPath(params.Start, params.End) + outPath = defaultAuditLogsDownloadPath(params.Start, params.End, params.Format) } partialPath := outPath + ".partial" out, err := openAuditLogsDownloadOutput(partialPath, outPath, in.Force) @@ -202,7 +203,14 @@ func buildAuditLogsDownloadParams(in AuditLogsDownloadInput) (kernel.AuditLogExp params.Start = start params.End = end - params.Format = kernel.AuditLogExportChunkParamsFormatJSONLGz + switch in.Format { + case "", "jsonl.gz": + params.Format = kernel.AuditLogExportChunkParamsFormatJSONLGz + case "jsonl": + params.Format = kernel.AuditLogExportChunkParamsFormatJSONL + default: + return params, fmt.Errorf("invalid --format value: %s (must be 'jsonl.gz' or 'jsonl')", in.Format) + } if in.Search != "" { params.Search = kernel.String(in.Search) } @@ -222,9 +230,9 @@ func buildAuditLogsDownloadParams(in AuditLogsDownloadInput) (kernel.AuditLogExp return params, nil } -func defaultAuditLogsDownloadPath(start, end time.Time) string { +func defaultAuditLogsDownloadPath(start, end time.Time, format kernel.AuditLogExportChunkParamsFormat) string { const stamp = "20060102" - return fmt.Sprintf("audit-logs-%s-%s.jsonl.gz", start.UTC().Format(stamp), end.UTC().Format(stamp)) + return fmt.Sprintf("audit-logs-%s-%s.%s", start.UTC().Format(stamp), end.UTC().Format(stamp), format) } // checkAuditLogsDownloadTarget rejects paths that are not replaceable @@ -289,20 +297,23 @@ func runAuditLogsDownload(cmd *cobra.Command, args []string) error { service, _ := cmd.Flags().GetString("service") authStrategy, _ := cmd.Flags().GetString("auth-strategy") userIDs, _ := cmd.Flags().GetStringArray("user-id") + format, _ := cmd.Flags().GetString("format") to, _ := cmd.Flags().GetString("to") force, _ := cmd.Flags().GetBool("force") return c.Download(cmd.Context(), AuditLogsDownloadInput{ Start: start, End: end, Search: search, Method: method, ExcludeMethod: excludeMethod, IncludeGet: includeGet, Service: service, - AuthStrategy: authStrategy, UserIDs: userIDs, To: to, Force: force, + AuthStrategy: authStrategy, UserIDs: userIDs, Format: format, + To: to, Force: force, }) } var auditLogsDownloadCmd = &cobra.Command{ Use: "download", - Short: "Download audit logs as gzip-compressed JSONL", - Long: "Download audit logs as gzip-compressed JSONL in verified chunks. The time range is [start, end).\n\n" + + Short: "Download audit logs as JSONL", + Long: "Download audit logs as JSONL in verified chunks. The time range is [start, end).\n\n" + + "Output is gzip-compressed by default; pass --format jsonl for uncompressed JSONL.\n\n" + "The API allows at most 30 days per download.\n\n" + "GET requests are excluded by default; pass --include-get to include them.\n\n" + "The output file is published only after every chunk is downloaded.", @@ -321,7 +332,8 @@ func init() { auditLogsDownloadCmd.Flags().String("service", "", "Filter by service") auditLogsDownloadCmd.Flags().String("auth-strategy", "", "Filter by authentication strategy") auditLogsDownloadCmd.Flags().StringArray("user-id", nil, "Filter by user ID (repeatable)") - auditLogsDownloadCmd.Flags().String("to", "", "Output .jsonl.gz file path") + auditLogsDownloadCmd.Flags().String("format", "jsonl.gz", "Export format: jsonl.gz or jsonl") + auditLogsDownloadCmd.Flags().String("to", "", "Output file path (defaults to a name derived from the window and --format)") auditLogsDownloadCmd.Flags().Bool("force", false, "Overwrite the output file") _ = auditLogsDownloadCmd.MarkFlagRequired("start") _ = auditLogsDownloadCmd.MarkFlagRequired("end") diff --git a/cmd/audit_logs_download_test.go b/cmd/audit_logs_download_test.go index 9df3d67..9a9fcda 100644 --- a/cmd/audit_logs_download_test.go +++ b/cmd/audit_logs_download_test.go @@ -246,9 +246,36 @@ func TestAuditLogsDownloadDoesNotRetryClientErrors(t *testing.T) { func TestDefaultAuditLogsDownloadPath(t *testing.T) { start := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC) end := time.Date(2026, 6, 28, 0, 0, 0, 0, time.UTC) - path := defaultAuditLogsDownloadPath(start, end) - assert.Equal(t, "audit-logs-20260601-20260628.jsonl.gz", path) + gz := defaultAuditLogsDownloadPath(start, end, kernel.AuditLogExportChunkParamsFormatJSONLGz) + assert.Equal(t, "audit-logs-20260601-20260628.jsonl.gz", gz) + + plain := defaultAuditLogsDownloadPath(start, end, kernel.AuditLogExportChunkParamsFormatJSONL) + assert.Equal(t, "audit-logs-20260601-20260628.jsonl", plain) +} + +func TestAuditLogsDownloadFormatParam(t *testing.T) { + base := AuditLogsDownloadInput{Start: "2026-06-01", End: "2026-06-02"} + + for _, tc := range []struct { + format string + want kernel.AuditLogExportChunkParamsFormat + }{ + {format: "", want: kernel.AuditLogExportChunkParamsFormatJSONLGz}, + {format: "jsonl.gz", want: kernel.AuditLogExportChunkParamsFormatJSONLGz}, + {format: "jsonl", want: kernel.AuditLogExportChunkParamsFormatJSONL}, + } { + in := base + in.Format = tc.format + params, err := buildAuditLogsDownloadParams(in) + require.NoError(t, err) + assert.Equal(t, tc.want, params.Format) + } + + in := base + in.Format = "csv" + _, err := buildAuditLogsDownloadParams(in) + require.ErrorContains(t, err, "invalid --format value") } func TestAuditLogsDownloadRejectsBadChunkBeforeWriting(t *testing.T) { diff --git a/cmd/auth_connections.go b/cmd/auth_connections.go index 1f8b5f1..6209a8b 100644 --- a/cmd/auth_connections.go +++ b/cmd/auth_connections.go @@ -55,6 +55,7 @@ type AuthConnectionCreateInput struct { NoAutoReauth bool RecordSession BoolFlag Telemetry string + TelemetryExport string Output string } @@ -87,12 +88,14 @@ type AuthConnectionUpdateInput struct { AutoReauth BoolFlag RecordSession BoolFlag Telemetry string + TelemetryExport string Output string } type AuthConnectionListInput struct { Domain string ProfileName string + Query string Limit int Offset int Output string @@ -104,12 +107,13 @@ type AuthConnectionDeleteInput struct { } type AuthConnectionLoginInput struct { - ID string - ProxyID string - ProxyName string - RecordSession BoolFlag - Telemetry string - Output string + ID string + ProxyID string + ProxyName string + RecordSession BoolFlag + Telemetry string + TelemetryExport string + Output string } type AuthConnectionSubmitInput struct { @@ -220,8 +224,8 @@ func (c AuthConnectionCmd) Create(ctx context.Context, in AuthConnectionCreateIn params.ManagedAuthCreateRequest.RecordSession = kernel.Opt(in.RecordSession.Value) } - if in.Telemetry != "" { - t, err := buildAuthConnectionCreateTelemetryParam(in.Telemetry) + if in.Telemetry != "" || in.TelemetryExport != "" { + t, err := buildAuthConnectionCreateTelemetryParam(in.Telemetry, in.TelemetryExport) if err != nil { return err } @@ -345,8 +349,8 @@ func (c AuthConnectionCmd) Update(ctx context.Context, in AuthConnectionUpdateIn hasChanges = true } - if in.Telemetry != "" { - t, err := buildAuthConnectionUpdateTelemetryParam(in.Telemetry) + if in.Telemetry != "" || in.TelemetryExport != "" { + t, err := buildAuthConnectionUpdateTelemetryParam(in.Telemetry, in.TelemetryExport) if err != nil { return err } @@ -429,6 +433,9 @@ func (c AuthConnectionCmd) Get(ctx context.Context, in AuthConnectionGetInput) e if f.Required { meta = append(meta, "required") } + if f.ReplaceExisting { + meta = append(meta, "replace-existing") + } entry := f.ID if f.Label != "" { entry = fmt.Sprintf("%s (%s)", f.ID, f.Label) @@ -557,6 +564,9 @@ func (c AuthConnectionCmd) List(ctx context.Context, in AuthConnectionListInput) if in.ProfileName != "" { params.ProfileName = kernel.Opt(in.ProfileName) } + if in.Query != "" { + params.Query = kernel.Opt(in.Query) + } if in.Limit > 0 { params.Limit = kernel.Opt(int64(in.Limit)) } @@ -655,8 +665,8 @@ func (c AuthConnectionCmd) Login(ctx context.Context, in AuthConnectionLoginInpu params.RecordSession = kernel.Opt(in.RecordSession.Value) } - if in.Telemetry != "" { - t, err := buildAuthConnectionLoginTelemetryParam(in.Telemetry) + if in.Telemetry != "" || in.TelemetryExport != "" { + t, err := buildAuthConnectionLoginTelemetryParam(in.Telemetry, in.TelemetryExport) if err != nil { return err } @@ -949,7 +959,11 @@ func (c AuthConnectionCmd) Follow(ctx context.Context, in AuthConnectionFollowIn if len(state.Fields) > 0 { fieldIDs := make([]string, 0, len(state.Fields)) for _, f := range state.Fields { - fieldIDs = append(fieldIDs, f.ID) + id := f.ID + if f.ReplaceExisting { + id += " (replace-existing)" + } + fieldIDs = append(fieldIDs, id) } pterm.Info.Printf(" Fields: %s\n", strings.Join(fieldIDs, ", ")) } @@ -1097,6 +1111,7 @@ func init() { authConnectionsCreateCmd.Flags().Bool("no-auto-reauth", false, "Mark expired sessions as NEEDS_AUTH instead of attempting automatic re-authentication (auto re-auth is enabled by default)") authConnectionsCreateCmd.Flags().Bool("record-session", false, "Record browser sessions for this connection by default (useful for debugging)") authConnectionsCreateCmd.Flags().String("telemetry", "", "Configure telemetry for this connection's browser sessions (opt-in): --telemetry=all (default set), --telemetry=off (disable), or --telemetry=console,network (capture exactly those categories)") + authConnectionsCreateCmd.Flags().String("telemetry-export-otlp", "", "Export this connection's captured telemetry over OTLP to one of the org's configured destinations, by ID or name; --telemetry-export-otlp=off disables export. Implies --telemetry=all when --telemetry is not set, since export requires capture") _ = authConnectionsCreateCmd.MarkFlagRequired("domain") _ = authConnectionsCreateCmd.MarkFlagRequired("profile-name") authConnectionsCreateCmd.MarkFlagsMutuallyExclusive("credential-name", "credential-provider") @@ -1123,6 +1138,7 @@ func init() { authConnectionsUpdateCmd.Flags().Bool("no-auto-reauth", false, "Mark expired sessions as NEEDS_AUTH instead of attempting automatic re-authentication") authConnectionsUpdateCmd.Flags().Bool("record-session", false, "Set whether browser sessions are recorded by default; use --record-session=false to disable") authConnectionsUpdateCmd.Flags().String("telemetry", "", "Update telemetry for future browser sessions: --telemetry=all (reset to default set), --telemetry=off (disable), or --telemetry=console,network (merge those categories into the current selection)") + authConnectionsUpdateCmd.Flags().String("telemetry-export-otlp", "", "Update where future sessions export captured telemetry over OTLP, by destination ID or name; --telemetry-export-otlp=off disables export. Naming a destination requires passing --telemetry in the same command, since export and capture are validated together") authConnectionsUpdateCmd.MarkFlagsMutuallyExclusive("credential-name", "credential-provider") authConnectionsUpdateCmd.MarkFlagsMutuallyExclusive("save-credentials", "no-save-credentials") authConnectionsUpdateCmd.MarkFlagsMutuallyExclusive("health-checks", "no-health-checks") @@ -1132,6 +1148,7 @@ func init() { addJSONOutputFlag(authConnectionsListCmd) authConnectionsListCmd.Flags().String("domain", "", "Filter by domain") authConnectionsListCmd.Flags().String("profile-name", "", "Filter by profile name") + authConnectionsListCmd.Flags().String("query", "", "Search auth connections by ID, domain, or profile name") authConnectionsListCmd.Flags().Int("limit", 0, "Maximum number of results to return") authConnectionsListCmd.Flags().Int("offset", 0, "Number of results to skip") @@ -1144,6 +1161,7 @@ func init() { authConnectionsLoginCmd.Flags().String("proxy-name", "", "Proxy name to use for this login") authConnectionsLoginCmd.Flags().Bool("record-session", false, "Override whether this login's browser session is recorded; use --record-session=false to disable") authConnectionsLoginCmd.Flags().String("telemetry", "", "Telemetry override for this login only, merged onto the connection's config: --telemetry=all, --telemetry=off, or --telemetry=console,network") + authConnectionsLoginCmd.Flags().String("telemetry-export-otlp", "", "Export override for this login only: an OTLP destination ID or name; --telemetry-export-otlp=off disables export for this login. Naming a destination requires passing --telemetry in the same command, since export and capture are validated together") // Submit flags addJSONOutputFlag(authConnectionsSubmitCmd) @@ -1196,6 +1214,7 @@ func runAuthConnectionsCreate(cmd *cobra.Command, args []string) error { noHealthChecks, _ := cmd.Flags().GetBool("no-health-checks") noAutoReauth, _ := cmd.Flags().GetBool("no-auto-reauth") telemetry, _ := cmd.Flags().GetString("telemetry") + telemetryExport, _ := cmd.Flags().GetString("telemetry-export-otlp") svc := client.Auth.Connections c := AuthConnectionCmd{svc: &svc} @@ -1216,6 +1235,7 @@ func runAuthConnectionsCreate(cmd *cobra.Command, args []string) error { NoAutoReauth: noAutoReauth, RecordSession: readBoolFlag(cmd.Flags(), "record-session"), Telemetry: telemetry, + TelemetryExport: telemetryExport, Output: output, }) } @@ -1247,6 +1267,7 @@ func runAuthConnectionsUpdate(cmd *cobra.Command, args []string) error { noSaveCredentials, _ := cmd.Flags().GetBool("no-save-credentials") healthCheckInterval, _ := cmd.Flags().GetInt("health-check-interval") telemetry, _ := cmd.Flags().GetString("telemetry") + telemetryExport, _ := cmd.Flags().GetString("telemetry-export-otlp") saveCredentialsFlag := BoolFlag{} @@ -1297,6 +1318,7 @@ func runAuthConnectionsUpdate(cmd *cobra.Command, args []string) error { AutoReauth: togglePair("auto-reauth", "no-auto-reauth"), RecordSession: readBoolFlag(cmd.Flags(), "record-session"), Telemetry: telemetry, + TelemetryExport: telemetryExport, Output: output, }) } @@ -1306,6 +1328,7 @@ func runAuthConnectionsList(cmd *cobra.Command, args []string) error { output, _ := cmd.Flags().GetString("output") domain, _ := cmd.Flags().GetString("domain") profileName, _ := cmd.Flags().GetString("profile-name") + query, _ := cmd.Flags().GetString("query") limit, _ := cmd.Flags().GetInt("limit") offset, _ := cmd.Flags().GetInt("offset") @@ -1314,6 +1337,7 @@ func runAuthConnectionsList(cmd *cobra.Command, args []string) error { return c.List(cmd.Context(), AuthConnectionListInput{ Domain: domain, ProfileName: profileName, + Query: query, Limit: limit, Offset: offset, Output: output, @@ -1338,16 +1362,18 @@ func runAuthConnectionsLogin(cmd *cobra.Command, args []string) error { proxyID, _ := cmd.Flags().GetString("proxy-id") proxyName, _ := cmd.Flags().GetString("proxy-name") telemetry, _ := cmd.Flags().GetString("telemetry") + telemetryExport, _ := cmd.Flags().GetString("telemetry-export-otlp") svc := client.Auth.Connections c := AuthConnectionCmd{svc: &svc} return c.Login(cmd.Context(), AuthConnectionLoginInput{ - ID: args[0], - ProxyID: proxyID, - ProxyName: proxyName, - RecordSession: readBoolFlag(cmd.Flags(), "record-session"), - Telemetry: telemetry, - Output: output, + ID: args[0], + ProxyID: proxyID, + ProxyName: proxyName, + RecordSession: readBoolFlag(cmd.Flags(), "record-session"), + Telemetry: telemetry, + TelemetryExport: telemetryExport, + Output: output, }) } diff --git a/cmd/auth_connections_test.go b/cmd/auth_connections_test.go index ada9cb1..a1ed08f 100644 --- a/cmd/auth_connections_test.go +++ b/cmd/auth_connections_test.go @@ -869,6 +869,60 @@ func TestAuthConnectionsGet_TelemetryEnabledWithoutCategories(t *testing.T) { assert.Contains(t, outBuf.String(), "enabled (default categories)") } +func TestAuthConnectionsGet_TelemetryShowsExportDestination(t *testing.T) { + setupStdoutCapture(t) + // A connection bound to an OTLP destination reports where its sessions export, + // so the destination is visible without reading the raw JSON. Unlike a browser + // session's resolved config (where destination is the ID string), a connection + // echoes the stored request verbatim, so destination is an {id, name} object. + fake := &FakeAuthConnectionService{ + GetFunc: func(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.ManagedAuth, error) { + var auth kernel.ManagedAuth + require.NoError(t, json.Unmarshal([]byte(`{"id":"conn-1","browser_telemetry":{"enabled":true,"browser":{"console":{"enabled":true}},"export":{"otlp":{"enabled":true,"destination":{"id":"dest-abc"}}}}}`), &auth)) + return &auth, nil + }, + } + c := AuthConnectionCmd{svc: fake} + + require.NoError(t, c.Get(context.Background(), AuthConnectionGetInput{ID: "conn-1"})) + out := outBuf.String() + assert.Contains(t, out, "console") + assert.Contains(t, out, "exporting to dest-abc") +} + +func TestAuthConnectionsGet_TelemetryShowsExportDestinationByName(t *testing.T) { + setupStdoutCapture(t) + // A connection created with --telemetry-export-otlp stores the name, so + // that is what comes back; fall back to it when no ID is present. + fake := &FakeAuthConnectionService{ + GetFunc: func(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.ManagedAuth, error) { + var auth kernel.ManagedAuth + require.NoError(t, json.Unmarshal([]byte(`{"id":"conn-1","browser_telemetry":{"enabled":true,"export":{"otlp":{"enabled":true,"destination":{"name":"my-collector"}}}}}`), &auth)) + return &auth, nil + }, + } + c := AuthConnectionCmd{svc: fake} + + require.NoError(t, c.Get(context.Background(), AuthConnectionGetInput{ID: "conn-1"})) + assert.Contains(t, outBuf.String(), "exporting to my-collector") +} + +func TestAuthConnectionsGet_TelemetryOmitsExportWhenDisabled(t *testing.T) { + setupStdoutCapture(t) + // An export block that is present but disabled must not read as "exporting". + fake := &FakeAuthConnectionService{ + GetFunc: func(ctx context.Context, id string, opts ...option.RequestOption) (*kernel.ManagedAuth, error) { + var auth kernel.ManagedAuth + require.NoError(t, json.Unmarshal([]byte(`{"id":"conn-1","browser_telemetry":{"enabled":true,"export":{"otlp":{"enabled":false}}}}`), &auth)) + return &auth, nil + }, + } + c := AuthConnectionCmd{svc: fake} + + require.NoError(t, c.Get(context.Background(), AuthConnectionGetInput{ID: "conn-1"})) + assert.NotContains(t, outBuf.String(), "exporting to") +} + func TestAuthConnectionsGet_TelemetryRowOmittedWhenOff(t *testing.T) { setupStdoutCapture(t) // Telemetry that is off is not reported at all, rather than shown as a diff --git a/cmd/auth_context.go b/cmd/auth_context.go new file mode 100644 index 0000000..9019c92 --- /dev/null +++ b/cmd/auth_context.go @@ -0,0 +1,111 @@ +package cmd + +import ( + "context" + "fmt" + + "github.com/kernel/cli/pkg/util" + "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/option" + "github.com/pterm/pterm" + "github.com/spf13/cobra" +) + +// AuthContextService defines the subset of the Kernel SDK auth context client that we use. +type AuthContextService interface { + Get(ctx context.Context, opts ...option.RequestOption) (res *kernel.AuthContext, err error) +} + +// AuthContextCmd handles auth context operations independent of cobra. +type AuthContextCmd struct { + svc AuthContextService +} + +type AuthContextGetInput struct { + Output string +} + +func (c AuthContextCmd) Get(ctx context.Context, in AuthContextGetInput) error { + if err := validateJSONOutput(in.Output); err != nil { + return err + } + + authCtx, err := c.svc.Get(ctx) + if err != nil { + return util.CleanedUpSdkError{Err: err} + } + + if in.Output == "json" { + if authCtx == nil { + fmt.Println("null") + return nil + } + return util.PrintPrettyJSON(authCtx) + } + + renderAuthContext(authCtx) + return nil +} + +func renderAuthContext(authCtx *kernel.AuthContext) { + if authCtx == nil { + pterm.Info.Println("No authentication context found") + return + } + + rows := pterm.TableData{ + {"Field", "Value"}, + {"Principal ID", authCtx.Principal.ID}, + {"Principal Type", authCtx.Principal.Type}, + {"Organization ID", authCtx.Organization.ID}, + {"Auth Method", authCtx.Authentication.Method}, + {"Auth Source", authCtx.Authentication.Source}, + // credential_id is null for session credentials. + {"Credential ID", formatAuthContextOptional(authCtx.Authentication.CredentialID)}, + // A null project_id means the scope is organization-wide. + {"Credential Scope", formatAuthContextScope(authCtx.Authorization.CredentialScope.ProjectID)}, + {"Effective Scope", formatAuthContextScope(authCtx.Authorization.EffectiveScope.ProjectID)}, + } + PrintTableNoPad(rows, true) +} + +func formatAuthContextOptional(value string) string { + if value == "" { + return "-" + } + return value +} + +func formatAuthContextScope(projectID string) string { + if projectID == "" { + return "organization-wide" + } + return projectID +} + +// --- Cobra wiring --- + +var authContextCmd = &cobra.Command{ + Use: "context", + Short: "Show the authentication context for the current credentials", + Long: `Show the identity and authorization context resolved for requests made with the current credentials. + +Displays the authenticated principal, organization, credential scope, and effective request scope. +Credential secrets are never returned.`, + Args: cobra.NoArgs, + RunE: runAuthContext, +} + +func runAuthContext(cmd *cobra.Command, args []string) error { + client := getKernelClient(cmd) + output, _ := cmd.Flags().GetString("output") + + svc := client.Auth.Context + c := AuthContextCmd{svc: &svc} + return c.Get(cmd.Context(), AuthContextGetInput{Output: output}) +} + +func init() { + addJSONOutputFlag(authContextCmd) + authCmd.AddCommand(authContextCmd) +} diff --git a/cmd/auth_context_test.go b/cmd/auth_context_test.go new file mode 100644 index 0000000..aa806d3 --- /dev/null +++ b/cmd/auth_context_test.go @@ -0,0 +1,100 @@ +package cmd + +import ( + "context" + "errors" + "testing" + + "github.com/kernel/kernel-go-sdk" + "github.com/kernel/kernel-go-sdk/option" + "github.com/stretchr/testify/assert" +) + +type FakeAuthContextService struct { + GetFunc func(ctx context.Context, opts ...option.RequestOption) (*kernel.AuthContext, error) +} + +func (f *FakeAuthContextService) Get(ctx context.Context, opts ...option.RequestOption) (*kernel.AuthContext, error) { + if f.GetFunc != nil { + return f.GetFunc(ctx, opts...) + } + return &kernel.AuthContext{}, nil +} + +// sampleAuthContext unmarshals from raw JSON so RawJSON() is populated, which +// the --output json path relies on. +func sampleAuthContext(t *testing.T, projectID string) *kernel.AuthContext { + t.Helper() + scope := "null" + if projectID != "" { + scope = `"` + projectID + `"` + } + raw := `{ + "principal": {"id": "key_123", "type": "api_key"}, + "organization": {"id": "org_456"}, + "authentication": {"credential_id": "key_123", "method": "api_key", "source": "api_key"}, + "authorization": { + "credential_scope": {"project_id": ` + scope + `}, + "effective_scope": {"project_id": ` + scope + `} + } + }` + authCtx := &kernel.AuthContext{} + if err := authCtx.UnmarshalJSON([]byte(raw)); err != nil { + t.Fatalf("UnmarshalJSON: %v", err) + } + return authCtx +} + +func TestAuthContextGet_RendersContext(t *testing.T) { + buf := capturePtermOutput(t) + fake := &FakeAuthContextService{ + GetFunc: func(ctx context.Context, opts ...option.RequestOption) (*kernel.AuthContext, error) { + return sampleAuthContext(t, "proj_789"), nil + }, + } + c := AuthContextCmd{svc: fake} + assert.NoError(t, c.Get(context.Background(), AuthContextGetInput{})) + + out := buf.String() + assert.Contains(t, out, "Principal ID") + assert.Contains(t, out, "key_123") + assert.Contains(t, out, "org_456") + assert.Contains(t, out, "proj_789") +} + +func TestAuthContextGet_NullFieldsRenderPlaceholders(t *testing.T) { + buf := capturePtermOutput(t) + fake := &FakeAuthContextService{ + GetFunc: func(ctx context.Context, opts ...option.RequestOption) (*kernel.AuthContext, error) { + // An org-wide credential has a null project_id in both scopes. + return sampleAuthContext(t, ""), nil + }, + } + c := AuthContextCmd{svc: fake} + assert.NoError(t, c.Get(context.Background(), AuthContextGetInput{})) + assert.Contains(t, buf.String(), "organization-wide") +} + +func TestAuthContextGet_JSONOutput(t *testing.T) { + fake := &FakeAuthContextService{ + GetFunc: func(ctx context.Context, opts ...option.RequestOption) (*kernel.AuthContext, error) { + return sampleAuthContext(t, "proj_789"), nil + }, + } + c := AuthContextCmd{svc: fake} + out := captureStdout(t, func() { + assert.NoError(t, c.Get(context.Background(), AuthContextGetInput{Output: "json"})) + }) + assert.Contains(t, out, "org_456") +} + +func TestAuthContextGet_SurfacesAPIError(t *testing.T) { + capturePtermOutput(t) + fake := &FakeAuthContextService{ + GetFunc: func(ctx context.Context, opts ...option.RequestOption) (*kernel.AuthContext, error) { + return nil, errors.New("boom") + }, + } + c := AuthContextCmd{svc: fake} + assert.Error(t, c.Get(context.Background(), AuthContextGetInput{})) +} diff --git a/cmd/browser_pools.go b/cmd/browser_pools.go index c80bcdc..1d6dd5d 100644 --- a/cmd/browser_pools.go +++ b/cmd/browser_pools.go @@ -31,6 +31,8 @@ type BrowserPoolsCmd struct { } type BrowserPoolsListInput struct { + Name string + Query string Limit int Offset int Output string @@ -42,6 +44,12 @@ func (c BrowserPoolsCmd) List(ctx context.Context, in BrowserPoolsListInput) err } params := kernel.BrowserPoolListParams{} + if in.Name != "" { + params.Name = kernel.String(in.Name) + } + if in.Query != "" { + params.Query = kernel.String(in.Query) + } if in.Limit > 0 { params.Limit = kernel.Int(int64(in.Limit)) } @@ -486,6 +494,7 @@ type BrowserPoolsAcquireInput struct { IDOrName string TimeoutSeconds int64 Name string + StartURL string Tags map[string]string Telemetry string Output string @@ -493,9 +502,10 @@ type BrowserPoolsAcquireInput struct { // buildAcquireParams builds the SDK params for acquiring a browser from a pool. // Shared by `browser-pools acquire` and the `browsers create --pool-id/--pool-name` -// path so the per-lease name/tags/telemetry forwarding cannot silently diverge -// between them. The telemetry override merges onto the pool's config for this lease. -func buildAcquireParams(name string, tags map[string]string, timeoutSeconds int64, telemetry string) (kernel.BrowserPoolAcquireParams, error) { +// path so the per-lease name/tags/start-url/telemetry forwarding cannot silently +// diverge between them. The telemetry override merges onto the pool's config for +// this lease. +func buildAcquireParams(name string, tags map[string]string, timeoutSeconds int64, telemetry, startURL string) (kernel.BrowserPoolAcquireParams, error) { params := kernel.BrowserPoolAcquireParams{} if timeoutSeconds > 0 { params.AcquireTimeoutSeconds = kernel.Int(timeoutSeconds) @@ -503,6 +513,9 @@ func buildAcquireParams(name string, tags map[string]string, timeoutSeconds int6 if name != "" { params.Name = kernel.Opt(name) } + if startURL != "" { + params.StartURL = kernel.Opt(startURL) + } if len(tags) > 0 { params.Tags = kernel.Tags(tags) } @@ -521,7 +534,7 @@ func (c BrowserPoolsCmd) Acquire(ctx context.Context, in BrowserPoolsAcquireInpu return err } - params, err := buildAcquireParams(in.Name, in.Tags, in.TimeoutSeconds, in.Telemetry) + params, err := buildAcquireParams(in.Name, in.Tags, in.TimeoutSeconds, in.Telemetry, in.StartURL) if err != nil { return err } @@ -665,6 +678,8 @@ var browserPoolsFlushCmd = &cobra.Command{ func init() { addJSONOutputFlag(browserPoolsListCmd) + browserPoolsListCmd.Flags().String("name", "", "Exact-match filter on browser pool name") + browserPoolsListCmd.Flags().String("query", "", "Search browser pools by name (IDs match by exact value)") browserPoolsListCmd.Flags().Int("limit", 0, "Maximum number of pools to return") browserPoolsListCmd.Flags().Int("offset", 0, "Number of pools to skip (for pagination)") @@ -721,6 +736,7 @@ func init() { browserPoolsAcquireCmd.Flags().Int64("timeout", 0, "Acquire timeout in seconds") browserPoolsAcquireCmd.Flags().String("name", "", "Optional name for the acquired session (applies to this lease; cleared on release)") + browserPoolsAcquireCmd.Flags().String("start-url", "", "URL to navigate the acquired browser to, overriding the pool's start URL for this acquire only (best-effort)") browserPoolsAcquireCmd.Flags().StringArray("tag", nil, "Set a tag KEY=VALUE on the acquired session (repeatable; applies to this lease)") browserPoolsAcquireCmd.Flags().String("telemetry", "", "Telemetry override for this lease only, merged onto the pool's config: --telemetry=all, --telemetry=off, or --telemetry=console,network") addJSONOutputFlag(browserPoolsAcquireCmd) @@ -742,10 +758,12 @@ func init() { func runBrowserPoolsList(cmd *cobra.Command, args []string) error { client := getKernelClient(cmd) out, _ := cmd.Flags().GetString("output") + name, _ := cmd.Flags().GetString("name") + query, _ := cmd.Flags().GetString("query") limit, _ := cmd.Flags().GetInt("limit") offset, _ := cmd.Flags().GetInt("offset") c := BrowserPoolsCmd{client: &client.BrowserPools} - return c.List(cmd.Context(), BrowserPoolsListInput{Limit: limit, Offset: offset, Output: out}) + return c.List(cmd.Context(), BrowserPoolsListInput{Name: name, Query: query, Limit: limit, Offset: offset, Output: out}) } func runBrowserPoolsCreate(cmd *cobra.Command, args []string) error { @@ -879,6 +897,7 @@ func runBrowserPoolsAcquire(cmd *cobra.Command, args []string) error { client := getKernelClient(cmd) timeout, _ := cmd.Flags().GetInt64("timeout") name, _ := cmd.Flags().GetString("name") + startURL, _ := cmd.Flags().GetString("start-url") tags, _ := tagsFromFlag(cmd, "tag") telemetry, _ := cmd.Flags().GetString("telemetry") output, _ := cmd.Flags().GetString("output") @@ -887,6 +906,7 @@ func runBrowserPoolsAcquire(cmd *cobra.Command, args []string) error { IDOrName: args[0], TimeoutSeconds: timeout, Name: name, + StartURL: startURL, Tags: tags, Telemetry: telemetry, Output: output, diff --git a/cmd/browser_pools_test.go b/cmd/browser_pools_test.go index 921220e..672a21e 100644 --- a/cmd/browser_pools_test.go +++ b/cmd/browser_pools_test.go @@ -124,28 +124,32 @@ func TestBrowserPoolsList_ForwardsLimitOffset(t *testing.T) { assert.Equal(t, int64(8), captured.Offset.Value) } -// TestBuildAcquireParams covers the shared name/tags/timeout/telemetry forwarding -// used by both `browser-pools acquire` and the `browsers create --pool-id` lease path. +// TestBuildAcquireParams covers the shared name/tags/timeout/telemetry/start-url +// forwarding used by both `browser-pools acquire` and the `browsers create +// --pool-id` lease path. func TestBuildAcquireParams(t *testing.T) { - p, err := buildAcquireParams("lease", map[string]string{"env": "prod"}, 30, "console,network") + p, err := buildAcquireParams("lease", map[string]string{"env": "prod"}, 30, "console,network", "https://example.com") assert.NoError(t, err) assert.True(t, p.Name.Valid()) assert.Equal(t, "lease", p.Name.Value) assert.Equal(t, "prod", p.Tags["env"]) assert.True(t, p.AcquireTimeoutSeconds.Valid()) assert.Equal(t, int64(30), p.AcquireTimeoutSeconds.Value) + assert.True(t, p.StartURL.Valid()) + assert.Equal(t, "https://example.com", p.StartURL.Value) assert.True(t, p.Telemetry.Browser.Console.Enabled.Value) assert.True(t, p.Telemetry.Browser.Network.Enabled.Value) // Unset inputs produce an empty params struct (nothing forwarded). - empty, err := buildAcquireParams("", nil, 0, "") + empty, err := buildAcquireParams("", nil, 0, "", "") assert.NoError(t, err) assert.False(t, empty.Name.Valid()) assert.Len(t, empty.Tags, 0) assert.False(t, empty.AcquireTimeoutSeconds.Valid()) + assert.False(t, empty.StartURL.Valid()) // An invalid category surfaces an error rather than a partial param. - _, err = buildAcquireParams("", nil, 0, "bogus") + _, err = buildAcquireParams("", nil, 0, "bogus", "") assert.Error(t, err) } diff --git a/cmd/browsers.go b/cmd/browsers.go index 1d27d91..95ce95d 100644 --- a/cmd/browsers.go +++ b/cmd/browsers.go @@ -290,6 +290,7 @@ type BrowsersCreateInput struct { Extensions []string Viewport string Telemetry string + TelemetryExport string ChromePolicy string ChromePolicyFile string Name string @@ -540,8 +541,8 @@ func (b BrowsersCmd) Create(ctx context.Context, in BrowsersCreateInput) error { } } - if in.Telemetry != "" { - t, err := buildNewTelemetryParam(in.Telemetry) + if in.Telemetry != "" || in.TelemetryExport != "" { + t, err := buildNewTelemetryParam(in.Telemetry, in.TelemetryExport) if err != nil { return err } @@ -575,20 +576,20 @@ func (b BrowsersCmd) Create(ctx context.Context, in BrowsersCreateInput) error { return util.PrintPrettyJSON(browser) } - printBrowserSessionResult(browser.SessionID, browser.CdpWsURL, browser.BrowserLiveViewURL, browser.Profile, browser.StartURL, browser.Name, browser.Tags) - if in.Telemetry != "" { + printBrowserSessionResult(browser.SessionID, browser.CdpWsURL, browser.BrowserLiveViewURL, browser.Profile, browser.ProfileSaveChanges, browser.StartURL, browser.Name, browser.Tags) + if in.Telemetry != "" || in.TelemetryExport != "" { printTelemetrySummary(browser.Telemetry) } return nil } -func printBrowserSessionResult(sessionID, cdpURL, liveViewURL string, profile kernel.Profile, startURL, name string, tags kernel.Tags) { - tableData := buildBrowserTableData(sessionID, cdpURL, liveViewURL, profile, startURL, name, tags) +func printBrowserSessionResult(sessionID, cdpURL, liveViewURL string, profile kernel.Profile, profileSaveChanges bool, startURL, name string, tags kernel.Tags) { + tableData := buildBrowserTableData(sessionID, cdpURL, liveViewURL, profile, profileSaveChanges, startURL, name, tags) PrintTableNoPad(tableData, true) } // buildBrowserTableData creates a base table with common browser session fields. -func buildBrowserTableData(sessionID, cdpURL, liveViewURL string, profile kernel.Profile, startURL, name string, tags kernel.Tags) pterm.TableData { +func buildBrowserTableData(sessionID, cdpURL, liveViewURL string, profile kernel.Profile, profileSaveChanges bool, startURL, name string, tags kernel.Tags) pterm.TableData { tableData := pterm.TableData{ {"Property", "Value"}, {"Session ID", sessionID}, @@ -606,6 +607,7 @@ func buildBrowserTableData(sessionID, cdpURL, liveViewURL string, profile kernel profVal = profile.ID } tableData = append(tableData, []string{"Profile", profVal}) + tableData = append(tableData, []string{"Profile Save Changes", fmt.Sprintf("%t", profileSaveChanges)}) } if startURL != "" { tableData = append(tableData, []string{"Start URL", startURL}) @@ -683,6 +685,7 @@ func (b BrowsersCmd) Get(ctx context.Context, in BrowsersGetInput) error { browser.CdpWsURL, browser.BrowserLiveViewURL, browser.Profile, + browser.ProfileSaveChanges, browser.StartURL, browser.Name, browser.Tags, @@ -864,6 +867,9 @@ func (b BrowsersCmd) Update(ctx context.Context, in BrowsersUpdateInput) error { if hasTagsChange { pterm.Info.Printf("Tags: %s\n", util.OrDash(formatTags(browser.Tags))) } + if hasProfileChange { + pterm.Info.Printf("Profile save changes: %t\n", browser.ProfileSaveChanges) + } if in.Telemetry != "" { printTelemetrySummary(browser.Telemetry) } @@ -2783,6 +2789,7 @@ func init() { 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 (opt-in): --telemetry=all (default set), --telemetry=off (disable), or --telemetry=console,network (capture exactly those categories)") + browsersCreateCmd.Flags().String("telemetry-export-otlp", "", "Export captured telemetry over OTLP to one of the org's configured destinations, by ID or name; --telemetry-export-otlp=off disables export. Implies --telemetry=all when --telemetry is not set, since export requires capture") browsersCreateCmd.Flags().String("name", "", "Optional unique name for the browser session (used to find it later; can be changed with 'browsers update --name')") browsersCreateCmd.Flags().StringArray("tag", nil, "Set a tag KEY=VALUE on the session (repeatable; up to 50 pairs)") browsersCreateCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompts") @@ -2828,6 +2835,7 @@ followed automatically by Chromium.`, telemetryEvents := &cobra.Command{Use: "events ", Short: "Read historical telemetry events (paged)", Args: cobra.ExactArgs(1), RunE: runBrowsersTelemetryEvents} 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("order", "", "Read direction: asc (default) reads oldest first, desc reads newest first (cannot be combined with --since)") 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)") @@ -2873,6 +2881,7 @@ func poolLeaseAllowedFlags() map[string]bool { "pool-name": true, "timeout": true, "name": true, + "start-url": true, "tag": true, "telemetry": true, "output": true, @@ -2904,6 +2913,7 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { poolID, _ := cmd.Flags().GetString("pool-id") poolName, _ := cmd.Flags().GetString("pool-name") telemetry, _ := cmd.Flags().GetString("telemetry") + telemetryExport, _ := cmd.Flags().GetString("telemetry-export-otlp") name, _ := cmd.Flags().GetString("name") tags, _ := tagsFromFlag(cmd, "tag") chromePolicy, _ := cmd.Flags().GetString("chrome-policy") @@ -2918,7 +2928,8 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { if poolID != "" || poolName != "" { // When using a pool, configuration comes from the pool itself, but - // name, tags, and telemetry apply per-lease to the acquired session. + // name, start URL, tags, and telemetry apply per-lease to the acquired + // session — they mirror the fields BrowserPoolAcquireParams accepts. allowedFlags := poolLeaseAllowedFlags() // Check if any browser configuration flags were set (which would conflict). @@ -2968,7 +2979,7 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { if cmd.Flags().Changed("timeout") && timeout > 0 { acquireTimeout = int64(timeout) } - acquireParams, err := buildAcquireParams(name, tags, acquireTimeout, telemetry) + acquireParams, err := buildAcquireParams(name, tags, acquireTimeout, telemetry, startURL) if err != nil { return err } @@ -2988,7 +2999,7 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { if output == "json" { return util.PrintPrettyJSON(resp) } - printBrowserSessionResult(resp.SessionID, resp.CdpWsURL, resp.BrowserLiveViewURL, resp.Profile, resp.StartURL, resp.Name, resp.Tags) + printBrowserSessionResult(resp.SessionID, resp.CdpWsURL, resp.BrowserLiveViewURL, resp.Profile, resp.ProfileSaveChanges, resp.StartURL, resp.Name, resp.Tags) return nil } @@ -3024,6 +3035,7 @@ func runBrowsersCreate(cmd *cobra.Command, args []string) error { Extensions: extensions, Viewport: viewport, Telemetry: telemetry, + TelemetryExport: telemetryExport, ChromePolicy: chromePolicy, ChromePolicyFile: chromePolicyFile, Name: name, diff --git a/cmd/browsers_telemetry.go b/cmd/browsers_telemetry.go index 3eccd74..a5aa98f 100644 --- a/cmd/browsers_telemetry.go +++ b/cmd/browsers_telemetry.go @@ -44,6 +44,7 @@ type BrowsersTelemetryEventsInput struct { Identifier string Limit int64 Offset int64 + Order string Since string Until string Categories []string @@ -107,10 +108,102 @@ func resolveTelemetryFlag(s string) (param.Opt[bool], kernel.BrowserTelemetryCat } } -// buildNewTelemetryParam converts a --telemetry flag value to the create API param. -func buildNewTelemetryParam(s string) (kernel.BrowserNewParamsTelemetry, error) { +// telemetryExportOff is the --telemetry-export-otlp value that turns export off +// rather than naming a destination. +const telemetryExportOff = "off" + +// resolveTelemetryExportFlag interprets a --telemetry-export-otlp flag value: +// "off" disables OTLP export, and any other value selects a destination by ID or +// name. Setting a destination implies enabled=true server-side, so enabled is only +// sent for "off" (the API rejects enabled=false combined with a destination). +// It returns the resolved (enabled, id, name) triple so each endpoint can assemble +// its own param type. +func resolveTelemetryExportFlag(s string) (enabled param.Opt[bool], id, name string, err error) { + val := strings.TrimSpace(s) + if val == telemetryExportOff { + return kernel.Opt(false), "", "", nil + } + if val == "" { + return param.Opt[bool]{}, "", "", fmt.Errorf("empty --telemetry-export-otlp value: pass an OTLP destination ID or name, or %q to disable export", telemetryExportOff) + } + // Destinations have no list endpoint to disambiguate against, so fall back to + // the same CUID shape test the CLI uses for other ID-or-name references. + if cuidRegex.MatchString(val) { + return param.Opt[bool]{}, val, "", nil + } + return param.Opt[bool]{}, "", val, nil +} + +// telemetryFlagEnablesCapture reports whether a --telemetry value turns capture on. +// A destination requires capture to be enabled, so the create paths use this to +// decide whether to imply it. +func telemetryFlagEnablesCapture(s string) bool { + return s != "" && s != "off" +} + +// validateTelemetryExportCombo checks an export destination against the --telemetry +// value in the same command. The API validates the request payload on its own — it +// does not consult the stored config — so a destination is rejected unless that +// same request also enables capture, whether via enabled=true or category settings. +// +// canImply is true on the create paths, where there is no stored selection to +// clobber and capture can safely be turned on for the user. On update and login it +// is false: enabling capture there would replace the connection's current category +// selection, so the user has to say what to capture. +// +// Error messages never lead with a flag token — the error style title-cases the +// first word and treats - and = as word boundaries inside it. +func validateTelemetryExportCombo(telemetry, id, name string, canImply bool) error { + if id == "" && name == "" { + return nil + } + if telemetry == "off" { + return fmt.Errorf("cannot combine --telemetry=off with an export destination: export requires telemetry capture to be enabled") + } + if telemetry == "" && !canImply { + return fmt.Errorf("setting an export destination also requires --telemetry in the same command: use --telemetry=all for the default set, or --telemetry=console,network to select categories") + } + return nil +} + +// buildNewTelemetryParam converts --telemetry and --telemetry-export-otlp flag +// values to the create API param. +func buildNewTelemetryParam(s, export string) (kernel.BrowserNewParamsTelemetry, error) { enabled, browser, err := resolveTelemetryFlag(s) - return kernel.BrowserNewParamsTelemetry{Enabled: enabled, Browser: browser}, err + p := kernel.BrowserNewParamsTelemetry{Enabled: enabled, Browser: browser} + if err != nil || export == "" { + return p, err + } + exEnabled, id, name, err := resolveTelemetryExportFlag(export) + if err != nil { + return p, err + } + if err := validateTelemetryExportCombo(s, id, name, true); err != nil { + return p, err + } + // A destination needs capture on. Nothing exists yet to clobber on create, so + // imply the default set rather than making the user repeat --telemetry=all. + if (id != "" || name != "") && !telemetryFlagEnablesCapture(s) { + p.Enabled = kernel.Opt(true) + } + p.Export = kernel.BrowserNewParamsTelemetryExport{ + Otlp: kernel.BrowserNewParamsTelemetryExportOtlp{ + Enabled: exEnabled, + Destination: kernel.BrowserNewParamsTelemetryExportOtlpDestination{ + ID: optIfSet(id), + Name: optIfSet(name), + }, + }, + } + return p, nil +} + +// optIfSet wraps a non-empty string as a set param, leaving it omitted otherwise. +func optIfSet(s string) param.Opt[string] { + if s == "" { + return param.Opt[string]{} + } + return kernel.Opt(s) } // buildUpdateTelemetryParam converts a --telemetry flag value to the update API param. @@ -119,40 +212,127 @@ func buildUpdateTelemetryParam(s string) (kernel.BrowserUpdateParamsTelemetry, e return kernel.BrowserUpdateParamsTelemetry{Enabled: enabled, Browser: browser}, err } -// buildAuthConnectionCreateTelemetryParam converts a --telemetry flag value to the -// browser telemetry default stored on a new auth connection. -func buildAuthConnectionCreateTelemetryParam(s string) (kernel.ManagedAuthCreateRequestBrowserTelemetryParam, error) { +// buildAuthConnectionCreateTelemetryParam converts --telemetry and +// --telemetry-export-otlp flag values to the browser telemetry default stored on a +// new auth connection. +func buildAuthConnectionCreateTelemetryParam(s, export string) (kernel.ManagedAuthCreateRequestBrowserTelemetryParam, error) { enabled, browser, err := resolveTelemetryFlag(s) - return kernel.ManagedAuthCreateRequestBrowserTelemetryParam{Enabled: enabled, Browser: browser}, err + p := kernel.ManagedAuthCreateRequestBrowserTelemetryParam{Enabled: enabled, Browser: browser} + if err != nil || export == "" { + return p, err + } + exEnabled, id, name, err := resolveTelemetryExportFlag(export) + if err != nil { + return p, err + } + if err := validateTelemetryExportCombo(s, id, name, true); err != nil { + return p, err + } + if (id != "" || name != "") && !telemetryFlagEnablesCapture(s) { + p.Enabled = kernel.Opt(true) + } + p.Export = kernel.ManagedAuthCreateRequestBrowserTelemetryExportParam{ + Otlp: kernel.ManagedAuthCreateRequestBrowserTelemetryExportOtlpParam{ + Enabled: exEnabled, + Destination: kernel.ManagedAuthCreateRequestBrowserTelemetryExportOtlpDestinationParam{ + ID: optIfSet(id), + Name: optIfSet(name), + }, + }, + } + return p, nil } -// buildAuthConnectionUpdateTelemetryParam converts a --telemetry flag value to the -// browser telemetry default for future sessions of an existing auth connection. -func buildAuthConnectionUpdateTelemetryParam(s string) (kernel.ManagedAuthUpdateRequestBrowserTelemetryParam, error) { +// buildAuthConnectionUpdateTelemetryParam converts --telemetry and +// --telemetry-export-otlp flag values to the browser telemetry default for future +// sessions of an existing auth connection. Unlike the create paths this never +// implies capture: the connection already has a stored config, and enabled=true +// would replace its category selection rather than merge onto it. +func buildAuthConnectionUpdateTelemetryParam(s, export string) (kernel.ManagedAuthUpdateRequestBrowserTelemetryParam, error) { enabled, browser, err := resolveTelemetryFlag(s) - return kernel.ManagedAuthUpdateRequestBrowserTelemetryParam{Enabled: enabled, Browser: browser}, err + p := kernel.ManagedAuthUpdateRequestBrowserTelemetryParam{Enabled: enabled, Browser: browser} + if err != nil || export == "" { + return p, err + } + exEnabled, id, name, err := resolveTelemetryExportFlag(export) + if err != nil { + return p, err + } + if err := validateTelemetryExportCombo(s, id, name, false); err != nil { + return p, err + } + p.Export = kernel.ManagedAuthUpdateRequestBrowserTelemetryExportParam{ + Otlp: kernel.ManagedAuthUpdateRequestBrowserTelemetryExportOtlpParam{ + Enabled: exEnabled, + Destination: kernel.ManagedAuthUpdateRequestBrowserTelemetryExportOtlpDestinationParam{ + ID: optIfSet(id), + Name: optIfSet(name), + }, + }, + } + return p, nil } -// buildAuthConnectionLoginTelemetryParam converts a --telemetry flag value to the -// per-login browser telemetry override. -func buildAuthConnectionLoginTelemetryParam(s string) (kernel.AuthConnectionLoginParamsBrowserTelemetry, error) { +// buildAuthConnectionLoginTelemetryParam converts --telemetry and +// --telemetry-export-otlp flag values to the per-login browser telemetry override. +// The override merges onto the connection's stored config, which may already +// enable capture, so this does not imply it either. +func buildAuthConnectionLoginTelemetryParam(s, export string) (kernel.AuthConnectionLoginParamsBrowserTelemetry, error) { enabled, browser, err := resolveTelemetryFlag(s) - return kernel.AuthConnectionLoginParamsBrowserTelemetry{Enabled: enabled, Browser: browser}, err + p := kernel.AuthConnectionLoginParamsBrowserTelemetry{Enabled: enabled, Browser: browser} + if err != nil || export == "" { + return p, err + } + exEnabled, id, name, err := resolveTelemetryExportFlag(export) + if err != nil { + return p, err + } + if err := validateTelemetryExportCombo(s, id, name, false); err != nil { + return p, err + } + p.Export = kernel.AuthConnectionLoginParamsBrowserTelemetryExport{ + Otlp: kernel.AuthConnectionLoginParamsBrowserTelemetryExportOtlp{ + Enabled: exEnabled, + Destination: kernel.AuthConnectionLoginParamsBrowserTelemetryExportOtlpDestination{ + ID: optIfSet(id), + Name: optIfSet(name), + }, + }, + } + return p, nil } // formatManagedAuthTelemetry renders an auth connection's default browser telemetry // config for the details table. func formatManagedAuthTelemetry(cfg kernel.ManagedAuthBrowserTelemetry) string { - if on := telemetryEnabledCategories(kernel.BrowserTelemetryConfig{Browser: cfg.Browser}); len(on) > 0 { - return strings.Join(on, ", ") + base := func() string { + if on := telemetryEnabledCategories(kernel.BrowserTelemetryConfig{Browser: cfg.Browser}); len(on) > 0 { + return strings.Join(on, ", ") + } + // The API preserves the create-browser config verbatim rather than resolving + // it, so `{"enabled": true}` with no per-category settings means the default + // set. Reporting that as "disabled" would invert the connection's state. + if cfg.Enabled { + return "enabled (default categories)" + } + return "disabled" + }() + if dest := managedAuthExportDestination(cfg.Export); dest != "" { + return base + " (exporting to " + dest + ")" + } + return base +} + +// managedAuthExportDestination returns the OTLP destination an auth connection's +// sessions export to, or "" when export is off or unset. +func managedAuthExportDestination(ex kernel.ManagedAuthBrowserTelemetryExport) string { + if !ex.Otlp.Enabled { + return "" } - // The API preserves the create-browser config verbatim rather than resolving - // it, so `{"enabled": true}` with no per-category settings means the default - // set. Reporting that as "disabled" would invert the connection's state. - if cfg.Enabled { - return "enabled (default categories)" + if id := ex.Otlp.Destination.ID; id != "" { + return id } - return "disabled" + return ex.Otlp.Destination.Name } // settableCategories are the categories accepted by --telemetry=. @@ -203,6 +383,15 @@ func printTelemetrySummary(cfg kernel.BrowserTelemetryConfig) { return } pterm.Info.Printf("Telemetry capturing: %s\n", strings.Join(on, ", ")) + if cfg.Export.Otlp.Enabled { + // The response reports the resolved destination by ID even when the request + // selected it by name. + if dest := cfg.Export.Otlp.Destination; dest != "" { + pterm.Info.Printf("Telemetry exporting over OTLP to: %s\n", dest) + } else { + pterm.Info.Println("Telemetry exporting over OTLP") + } + } } // shouldEmit applies client-side category/type filters to a telemetry event. @@ -307,6 +496,14 @@ func (b BrowsersCmd) TelemetryEvents(ctx context.Context, in BrowsersTelemetryEv return fmt.Errorf("invalid --categories value %q: must be one of %s", c, strings.Join(streamFilterCategories, ", ")) } } + if in.Order != "" && in.Order != "asc" && in.Order != "desc" { + return fmt.Errorf("invalid --order value %q: must be asc or desc", in.Order) + } + // The endpoint rejects desc combined with a window start, since desc pages + // backwards from --until (or the newest archived event) instead. + if in.Order == "desc" && in.Since != "" { + return fmt.Errorf("--order desc cannot be combined with --since; use --until to bound the window instead") + } // 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 @@ -328,6 +525,9 @@ func (b BrowsersCmd) TelemetryEvents(ctx context.Context, in BrowsersTelemetryEv if in.Limit > 0 { params.Limit = kernel.Opt(in.Limit) } + if in.Order != "" { + params.Order = kernel.Opt(in.Order) + } if in.Offset > 0 && !fullScan { params.Offset = kernel.Opt(in.Offset) } else if in.Since != "" { @@ -428,6 +628,7 @@ func runBrowsersTelemetryEvents(cmd *cobra.Command, args []string) error { out, _ := cmd.Flags().GetString("output") limit, _ := cmd.Flags().GetInt64("limit") offset, _ := cmd.Flags().GetInt64("offset") + order, _ := cmd.Flags().GetString("order") since, _ := cmd.Flags().GetString("since") until, _ := cmd.Flags().GetString("until") categories, _ := cmd.Flags().GetStringSlice("categories") @@ -438,6 +639,7 @@ func runBrowsersTelemetryEvents(cmd *cobra.Command, args []string) error { Identifier: args[0], Limit: limit, Offset: offset, + Order: order, Since: since, Until: until, Categories: categories, diff --git a/cmd/browsers_telemetry_test.go b/cmd/browsers_telemetry_test.go index 982d2d8..50ced5e 100644 --- a/cmd/browsers_telemetry_test.go +++ b/cmd/browsers_telemetry_test.go @@ -336,21 +336,21 @@ func TestParseTelemetryCategories_WhitespaceTolerance(t *testing.T) { // listed categories enabled (Enabled unset). func TestBuildTelemetryParam_WireEncoding(t *testing.T) { t.Run("all", func(t *testing.T) { - p, err := buildNewTelemetryParam("all") + p, err := buildNewTelemetryParam("all", "") assert.NoError(t, err) assert.True(t, p.Enabled.Valid()) assert.True(t, p.Enabled.Value) assert.False(t, p.Browser.Network.Enabled.Valid()) }) t.Run("off", func(t *testing.T) { - p, err := buildNewTelemetryParam("off") + p, err := buildNewTelemetryParam("off", "") assert.NoError(t, err) assert.True(t, p.Enabled.Valid()) assert.False(t, p.Enabled.Value) assert.False(t, p.Browser.Network.Enabled.Valid()) }) t.Run("opt-in list sets only Browser", func(t *testing.T) { - p, err := buildNewTelemetryParam("network,control") + p, err := buildNewTelemetryParam("network,control", "") assert.NoError(t, err) assert.False(t, p.Enabled.Valid(), "Enabled must be unset for an opt-in selection") assert.True(t, p.Browser.Network.Enabled.Valid()) @@ -360,6 +360,150 @@ func TestBuildTelemetryParam_WireEncoding(t *testing.T) { }) } +// TestBuildTelemetryParam_ExportWireEncoding locks in the OTLP export wire shapes. +// A destination is sent as id or name (never both) and implies capture on create; +// "off" sends enabled=false with no destination, since the API rejects +// enabled=false combined with one. +func TestBuildTelemetryParam_ExportWireEncoding(t *testing.T) { + t.Run("destination by CUID sets id", func(t *testing.T) { + p, err := buildNewTelemetryParam("", "abcdefghijklmnopqrstuvwx") + assert.NoError(t, err) + otlp := p.Export.Otlp + assert.True(t, otlp.Destination.ID.Valid()) + assert.Equal(t, "abcdefghijklmnopqrstuvwx", otlp.Destination.ID.Value) + assert.False(t, otlp.Destination.Name.Valid(), "name must be unset when id is sent") + assert.False(t, otlp.Enabled.Valid(), "a destination implies enabled server-side") + }) + t.Run("destination by name sets name", func(t *testing.T) { + p, err := buildNewTelemetryParam("", "my-collector") + assert.NoError(t, err) + otlp := p.Export.Otlp + assert.True(t, otlp.Destination.Name.Valid()) + assert.Equal(t, "my-collector", otlp.Destination.Name.Value) + assert.False(t, otlp.Destination.ID.Valid(), "id must be unset when name is sent") + }) + t.Run("destination implies capture on create", func(t *testing.T) { + p, err := buildNewTelemetryParam("", "my-collector") + assert.NoError(t, err) + assert.True(t, p.Enabled.Valid(), "export requires capture, so create implies it") + assert.True(t, p.Enabled.Value) + }) + t.Run("explicit --telemetry selection is preserved", func(t *testing.T) { + p, err := buildNewTelemetryParam("network,control", "my-collector") + assert.NoError(t, err) + assert.False(t, p.Enabled.Valid(), "an opt-in selection must not be overridden") + assert.True(t, p.Browser.Network.Enabled.Value) + assert.Equal(t, "my-collector", p.Export.Otlp.Destination.Name.Value) + }) + t.Run("off disables export without a destination", func(t *testing.T) { + p, err := buildNewTelemetryParam("all", "off") + assert.NoError(t, err) + otlp := p.Export.Otlp + assert.True(t, otlp.Enabled.Valid()) + assert.False(t, otlp.Enabled.Value) + assert.False(t, otlp.Destination.ID.Valid()) + assert.False(t, otlp.Destination.Name.Valid()) + }) + t.Run("off does not imply capture", func(t *testing.T) { + p, err := buildNewTelemetryParam("", "off") + assert.NoError(t, err) + assert.False(t, p.Enabled.Valid(), "disabling export must not turn capture on") + }) + // The API validates the request payload on its own rather than consulting the + // stored config, so a destination needs a capture-enabling --telemetry in the + // same request. Update and login refuse to supply one: doing so would replace + // the connection's current category selection. + t.Run("update requires an explicit --telemetry alongside a destination", func(t *testing.T) { + _, err := buildAuthConnectionUpdateTelemetryParam("", "my-collector") + assert.Error(t, err) + assert.Contains(t, err.Error(), "also requires --telemetry") + }) + t.Run("login requires an explicit --telemetry alongside a destination", func(t *testing.T) { + _, err := buildAuthConnectionLoginTelemetryParam("", "my-collector") + assert.Error(t, err) + assert.Contains(t, err.Error(), "also requires --telemetry") + }) + t.Run("update never implies capture when --telemetry is given", func(t *testing.T) { + p, err := buildAuthConnectionUpdateTelemetryParam("console", "my-collector") + assert.NoError(t, err) + assert.False(t, p.Enabled.Valid(), "an opt-in selection must not be widened to the default set") + assert.True(t, p.Browser.Console.Enabled.Value) + assert.Equal(t, "my-collector", p.Export.Otlp.Destination.Name.Value) + }) + t.Run("login never implies capture when --telemetry is given", func(t *testing.T) { + p, err := buildAuthConnectionLoginTelemetryParam("console", "my-collector") + assert.NoError(t, err) + assert.False(t, p.Enabled.Valid()) + assert.Equal(t, "my-collector", p.Export.Otlp.Destination.Name.Value) + }) + t.Run("update and login allow export=off without --telemetry", func(t *testing.T) { + u, err := buildAuthConnectionUpdateTelemetryParam("", "off") + assert.NoError(t, err) + assert.False(t, u.Export.Otlp.Enabled.Value) + l, err := buildAuthConnectionLoginTelemetryParam("", "off") + assert.NoError(t, err) + assert.False(t, l.Export.Otlp.Enabled.Value) + }) + t.Run("auth connection create implies capture", func(t *testing.T) { + p, err := buildAuthConnectionCreateTelemetryParam("", "my-collector") + assert.NoError(t, err) + assert.True(t, p.Enabled.Valid()) + assert.True(t, p.Enabled.Value) + }) + t.Run("invalid category still errors with export set", func(t *testing.T) { + _, err := buildNewTelemetryParam("bogus", "my-collector") + assert.Error(t, err) + }) + t.Run("telemetry=off with a destination is rejected", func(t *testing.T) { + for _, tc := range []struct { + name string + fn func() error + }{ + {"create", func() error { _, e := buildNewTelemetryParam("off", "my-collector"); return e }}, + {"auth create", func() error { _, e := buildAuthConnectionCreateTelemetryParam("off", "my-collector"); return e }}, + {"auth update", func() error { _, e := buildAuthConnectionUpdateTelemetryParam("off", "my-collector"); return e }}, + {"auth login", func() error { _, e := buildAuthConnectionLoginTelemetryParam("off", "my-collector"); return e }}, + } { + err := tc.fn() + assert.Error(t, err, tc.name) + assert.Contains(t, err.Error(), "requires telemetry capture", tc.name) + } + }) + t.Run("telemetry=off with export=off is allowed", func(t *testing.T) { + p, err := buildNewTelemetryParam("off", "off") + assert.NoError(t, err) + assert.False(t, p.Enabled.Value) + assert.False(t, p.Export.Otlp.Enabled.Value) + }) + t.Run("empty export value errors", func(t *testing.T) { + _, err := buildNewTelemetryParam("all", " ") + assert.Error(t, err) + }) +} + +// TestPrintTelemetrySummary_Export covers the browser-session shape, where the +// resolved config reports destination as the ID string (not the {id, name} object +// an auth connection echoes back). +func TestPrintTelemetrySummary_Export(t *testing.T) { + parse := func(raw string) kernel.BrowserTelemetryConfig { + var cfg kernel.BrowserTelemetryConfig + if err := json.Unmarshal([]byte(raw), &cfg); err != nil { + t.Fatalf("unmarshal: %v", err) + } + return cfg + } + t.Run("reports the destination", func(t *testing.T) { + setupStdoutCapture(t) + printTelemetrySummary(parse(`{"browser":{"control":{"enabled":true}},"export":{"otlp":{"enabled":true,"destination":"dest-abc"}}}`)) + assert.Contains(t, outBuf.String(), "OTLP to: dest-abc") + }) + t.Run("stays quiet when export is off", func(t *testing.T) { + setupStdoutCapture(t) + printTelemetrySummary(parse(`{"browser":{"control":{"enabled":true}},"export":{"otlp":{"enabled":false}}}`)) + assert.NotContains(t, outBuf.String(), "OTLP") + }) +} + func TestTelemetryEnabledCategories(t *testing.T) { var cfg kernel.BrowserTelemetryConfig raw := `{"browser":{"control":{"enabled":true},"system":{"enabled":true},"network":{"enabled":false}}}` diff --git a/cmd/credential_providers.go b/cmd/credential_providers.go index 2f47234..5b8041c 100644 --- a/cmd/credential_providers.go +++ b/cmd/credential_providers.go @@ -32,6 +32,7 @@ type CredentialProvidersCmd struct { } type CredentialProvidersListInput struct { + Query string Limit int Offset int Output string @@ -81,6 +82,9 @@ func (c CredentialProvidersCmd) List(ctx context.Context, in CredentialProviders } params := kernel.CredentialProviderListParams{} + if in.Query != "" { + params.Query = kernel.String(in.Query) + } if in.Limit > 0 { params.Limit = kernel.Int(int64(in.Limit)) } @@ -446,6 +450,7 @@ func init() { // List flags addJSONOutputFlag(credentialProvidersListCmd) + credentialProvidersListCmd.Flags().String("query", "", "Search credential providers by name (IDs match by exact value)") credentialProvidersListCmd.Flags().Int("limit", 0, "Maximum number of credential providers to return") credentialProvidersListCmd.Flags().Int("offset", 0, "Number of credential providers to skip (for pagination)") @@ -483,12 +488,14 @@ func init() { func runCredentialProvidersList(cmd *cobra.Command, args []string) error { client := getKernelClient(cmd) output, _ := cmd.Flags().GetString("output") + query, _ := cmd.Flags().GetString("query") limit, _ := cmd.Flags().GetInt("limit") offset, _ := cmd.Flags().GetInt("offset") svc := client.CredentialProviders c := CredentialProvidersCmd{providers: &svc} return c.List(cmd.Context(), CredentialProvidersListInput{ + Query: query, Limit: limit, Offset: offset, Output: output, diff --git a/cmd/credentials.go b/cmd/credentials.go index 56fcbdc..bf7b764 100644 --- a/cmd/credentials.go +++ b/cmd/credentials.go @@ -32,6 +32,7 @@ type CredentialsCmd struct { type CredentialsListInput struct { Domain string + Query string Limit int Offset int Output string @@ -80,6 +81,9 @@ func (c CredentialsCmd) List(ctx context.Context, in CredentialsListInput) error if in.Domain != "" { params.Domain = kernel.Opt(in.Domain) } + if in.Query != "" { + params.Query = kernel.Opt(in.Query) + } if in.Limit > 0 { params.Limit = kernel.Opt(int64(in.Limit)) } @@ -410,6 +414,7 @@ func init() { // List flags addJSONOutputFlag(credentialsListCmd) credentialsListCmd.Flags().String("domain", "", "Filter by domain") + credentialsListCmd.Flags().String("query", "", "Search credentials by name or domain (IDs match by exact value)") credentialsListCmd.Flags().Int("limit", 0, "Maximum number of results to return") credentialsListCmd.Flags().Int("offset", 0, "Number of results to skip") @@ -445,6 +450,7 @@ func runCredentialsList(cmd *cobra.Command, args []string) error { client := getKernelClient(cmd) output, _ := cmd.Flags().GetString("output") domain, _ := cmd.Flags().GetString("domain") + query, _ := cmd.Flags().GetString("query") limit, _ := cmd.Flags().GetInt("limit") offset, _ := cmd.Flags().GetInt("offset") @@ -452,6 +458,7 @@ func runCredentialsList(cmd *cobra.Command, args []string) error { c := CredentialsCmd{credentials: &svc} return c.List(cmd.Context(), CredentialsListInput{ Domain: domain, + Query: query, Limit: limit, Offset: offset, Output: output, diff --git a/cmd/deploy.go b/cmd/deploy.go index a5000d0..09a8498 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -93,6 +93,7 @@ func init() { deployHistoryCmd.Flags().Int("per-page", 20, "Items per page (alias of --limit)") deployHistoryCmd.Flags().Int("page", 1, "Page number (1-based)") deployHistoryCmd.Flags().String("app-version", "", "Filter by application version (requires app_name)") + deployHistoryCmd.Flags().String("query", "", "Search deployments by ID or app name") addJSONOutputFlag(deployHistoryCmd) deployCmd.AddCommand(deployHistoryCmd) @@ -492,6 +493,7 @@ func runDeployHistory(cmd *cobra.Command, args []string) error { perPage, _ := cmd.Flags().GetInt("per-page") page, _ := cmd.Flags().GetInt("page") appVersionFilter, _ := cmd.Flags().GetString("app-version") + queryFilter, _ := cmd.Flags().GetString("query") output, _ := cmd.Flags().GetString("output") if err := validateJSONOutput(output); err != nil { @@ -530,6 +532,9 @@ func runDeployHistory(cmd *cobra.Command, args []string) error { if appVersionFilter != "" { params.AppVersion = kernel.Opt(appVersionFilter) } + if queryFilter != "" { + params.Query = kernel.Opt(queryFilter) + } // Request one extra item to detect hasMore params.Limit = kernel.Opt(int64(perPage + 1)) params.Offset = kernel.Opt(int64((page - 1) * perPage)) @@ -589,6 +594,9 @@ func runDeployHistory(cmd *cobra.Command, args []string) error { if appVersionFilter != "" { nextCmd += fmt.Sprintf(" --app-version %s", quoteIfNeeded(appVersionFilter)) } + if queryFilter != "" { + nextCmd += fmt.Sprintf(" --query %s", quoteIfNeeded(queryFilter)) + } pterm.Printf("Next: %s\n", nextCmd) } // Concise notes when user-specified per-page/limit/page are outside API-allowed range diff --git a/cmd/extensions.go b/cmd/extensions.go index 69aa33f..09bacf4 100644 --- a/cmd/extensions.go +++ b/cmd/extensions.go @@ -54,6 +54,8 @@ type ExtensionsService interface { } type ExtensionsListInput struct { + Name string + Query string Limit int Offset int Output string @@ -101,6 +103,12 @@ func (e ExtensionsCmd) List(ctx context.Context, in ExtensionsListInput) error { pterm.Info.Println("Fetching extensions...") } params := kernel.ExtensionListParams{} + if in.Name != "" { + params.Name = kernel.String(in.Name) + } + if in.Query != "" { + params.Query = kernel.String(in.Query) + } if in.Limit > 0 { params.Limit = kernel.Int(int64(in.Limit)) } @@ -452,11 +460,13 @@ var extensionsListCmd = &cobra.Command{ RunE: func(cmd *cobra.Command, args []string) error { client := getKernelClient(cmd) output, _ := cmd.Flags().GetString("output") + name, _ := cmd.Flags().GetString("name") + query, _ := cmd.Flags().GetString("query") limit, _ := cmd.Flags().GetInt("limit") offset, _ := cmd.Flags().GetInt("offset") svc := client.Extensions e := ExtensionsCmd{extensions: &svc} - return e.List(cmd.Context(), ExtensionsListInput{Limit: limit, Offset: offset, Output: output}) + return e.List(cmd.Context(), ExtensionsListInput{Name: name, Query: query, Limit: limit, Offset: offset, Output: output}) }, } @@ -586,6 +596,8 @@ func init() { addJSONOutputFlag(extensionsListCmd) addJSONOutputFlag(extensionsGetCmd) + extensionsListCmd.Flags().String("name", "", "Exact-match filter on extension name") + extensionsListCmd.Flags().String("query", "", "Search extensions by name (IDs match by exact value)") extensionsListCmd.Flags().Int("limit", 0, "Maximum number of extensions to return") extensionsListCmd.Flags().Int("offset", 0, "Number of extensions to skip (for pagination)") extensionsDeleteCmd.Flags().BoolP("yes", "y", false, "Skip confirmation prompt") diff --git a/cmd/invoke.go b/cmd/invoke.go index cca3804..196aef2 100644 --- a/cmd/invoke.go +++ b/cmd/invoke.go @@ -80,6 +80,7 @@ func init() { invocationHistoryCmd.Flags().String("action", "", "Filter by action name") invocationHistoryCmd.Flags().StringP("app", "a", "", "Filter by app name") invocationHistoryCmd.Flags().String("deployment-id", "", "Filter by deployment ID") + invocationHistoryCmd.Flags().String("query", "", "Search invocations by ID, app name, or action name") invocationHistoryCmd.Flags().Int("offset", 0, "Number of results to skip") invocationHistoryCmd.Flags().String("since", "", "Show invocations that started since the given time") invocationHistoryCmd.Flags().String("status", "", "Filter by invocation status: queued, running, succeeded, failed") @@ -421,6 +422,7 @@ func runInvocationHistory(cmd *cobra.Command, args []string) error { actionFilter, _ := cmd.Flags().GetString("action") appFilter, _ := cmd.Flags().GetString("app") deploymentID, _ := cmd.Flags().GetString("deployment-id") + query, _ := cmd.Flags().GetString("query") offset, _ := cmd.Flags().GetInt("offset") since, _ := cmd.Flags().GetString("since") statusFilter, _ := cmd.Flags().GetString("status") @@ -446,6 +448,9 @@ func runInvocationHistory(cmd *cobra.Command, args []string) error { if deploymentID != "" { params.DeploymentID = kernel.Opt(deploymentID) } + if query != "" { + params.Query = kernel.Opt(query) + } if offset > 0 { params.Offset = kernel.Opt(int64(offset)) } diff --git a/cmd/profiles.go b/cmd/profiles.go index 1820ad1..1a9264f 100644 --- a/cmd/profiles.go +++ b/cmd/profiles.go @@ -41,6 +41,7 @@ type ProfilesListInput struct { Output string Page int PerPage int + Name string Query string } @@ -91,6 +92,9 @@ func (p ProfilesCmd) List(ctx context.Context, in ProfilesListInput) error { } params := kernel.ProfileListParams{} + if in.Name != "" { + params.Name = kernel.Opt(in.Name) + } if in.Query != "" { params.Query = kernel.Opt(in.Query) } @@ -144,7 +148,10 @@ func (p ProfilesCmd) List(ctx context.Context, in ProfilesListInput) error { pterm.Printf("\nPage: %d Per-page: %d Items this page: %d Has more: %s\n", page, perPage, itemsThisPage, lo.Ternary(hasMore, "yes", "no")) if hasMore { nextPage := page + 1 - nextCmd := fmt.Sprintf("kernel profile list --page %d --per-page %d", nextPage, perPage) + nextCmd := fmt.Sprintf("kernel profiles list --page %d --per-page %d", nextPage, perPage) + if in.Name != "" { + nextCmd += fmt.Sprintf(" --name \"%s\"", in.Name) + } if in.Query != "" { nextCmd += fmt.Sprintf(" --query \"%s\"", in.Query) } @@ -451,6 +458,7 @@ func init() { addJSONOutputFlag(profilesListCmd) profilesListCmd.Flags().Int("per-page", 20, "Items per page (default 20)") profilesListCmd.Flags().Int("page", 1, "Page number (1-based)") + profilesListCmd.Flags().String("name", "", "Exact-match filter on profile name") profilesListCmd.Flags().String("query", "", "Search profiles by name or ID") addJSONOutputFlag(profilesGetCmd) addJSONOutputFlag(profilesCreateCmd) @@ -469,6 +477,7 @@ func runProfilesList(cmd *cobra.Command, args []string) error { output, _ := cmd.Flags().GetString("output") perPage, _ := cmd.Flags().GetInt("per-page") page, _ := cmd.Flags().GetInt("page") + name, _ := cmd.Flags().GetString("name") query, _ := cmd.Flags().GetString("query") svc := client.Profiles @@ -477,6 +486,7 @@ func runProfilesList(cmd *cobra.Command, args []string) error { Output: output, Page: page, PerPage: perPage, + Name: name, Query: query, }) } diff --git a/cmd/profiles_test.go b/cmd/profiles_test.go index 3546749..d8af479 100644 --- a/cmd/profiles_test.go +++ b/cmd/profiles_test.go @@ -108,7 +108,7 @@ func TestProfilesList_HasMore(t *testing.T) { _ = p.List(context.Background(), ProfilesListInput{Page: 1, PerPage: perPage}) out := buf.String() assert.Contains(t, out, "Has more: yes") - assert.Contains(t, out, "Next: kernel profile list --page 2 --per-page 2") + assert.Contains(t, out, "Next: kernel profiles list --page 2 --per-page 2") assert.Contains(t, out, "p0") assert.Contains(t, out, "p1") assert.NotContains(t, out, "p2") diff --git a/cmd/projects.go b/cmd/projects.go index 28cedeb..8895d12 100644 --- a/cmd/projects.go +++ b/cmd/projects.go @@ -12,6 +12,7 @@ import ( "github.com/kernel/kernel-go-sdk/packages/param" "github.com/kernel/kernel-go-sdk/packages/respjson" "github.com/pterm/pterm" + "github.com/samber/lo" "github.com/spf13/cobra" ) @@ -37,7 +38,12 @@ type ProjectsCmd struct { limits ProjectLimitsService } -type ProjectsListInput struct{} +type ProjectsListInput struct { + Page int + PerPage int + Name string + Query string +} type ProjectsCreateInput struct { Name string @@ -87,21 +93,65 @@ func resolveProjectArg(ctx context.Context, projects ProjectListService, val str } func (c ProjectsCmd) List(ctx context.Context, in ProjectsListInput) error { - projects, err := c.projects.List(ctx, kernel.ProjectListParams{}) + page := in.Page + perPage := in.PerPage + if page <= 0 { + page = 1 + } + if perPage <= 0 { + perPage = 20 + } + + params := kernel.ProjectListParams{} + if in.Name != "" { + params.Name = kernel.Opt(in.Name) + } + if in.Query != "" { + params.Query = kernel.Opt(in.Query) + } + // Request one extra item to detect whether another page exists without a + // second call; the pagination response headers are not exposed by the SDK. + params.Limit = kernel.Opt(int64(perPage + 1)) + params.Offset = kernel.Opt(int64((page - 1) * perPage)) + + projects, err := c.projects.List(ctx, params) if err != nil { return util.CleanedUpSdkError{Err: err} } - if projects == nil || len(projects.Items) == 0 { + var items []kernel.Project + if projects != nil { + items = projects.Items + } + + hasMore := len(items) > perPage + if hasMore { + items = items[:perPage] + } + itemsThisPage := len(items) + + if len(items) == 0 { pterm.Info.Println("No projects found") return nil } table := pterm.TableData{{"ID", "Name", "Status", "Created At"}} - for _, p := range projects.Items { + for _, p := range items { table = append(table, []string{p.ID, p.Name, string(p.Status), util.FormatLocal(p.CreatedAt)}) } PrintTableNoPad(table, true) + + pterm.Printf("\nPage: %d Per-page: %d Items this page: %d Has more: %s\n", page, perPage, itemsThisPage, lo.Ternary(hasMore, "yes", "no")) + if hasMore { + nextCmd := fmt.Sprintf("kernel projects list --page %d --per-page %d", page+1, perPage) + if in.Name != "" { + nextCmd += fmt.Sprintf(" --name %q", in.Name) + } + if in.Query != "" { + nextCmd += fmt.Sprintf(" --query %q", in.Query) + } + pterm.Printf("Next: %s\n", nextCmd) + } return nil } @@ -323,7 +373,16 @@ func getProjectsHandler(cmd *cobra.Command) ProjectsCmd { func runProjectsList(cmd *cobra.Command, args []string) error { c := getProjectsHandler(cmd) - return c.List(cmd.Context(), ProjectsListInput{}) + page, _ := cmd.Flags().GetInt("page") + perPage, _ := cmd.Flags().GetInt("per-page") + name, _ := cmd.Flags().GetString("name") + query, _ := cmd.Flags().GetString("query") + return c.List(cmd.Context(), ProjectsListInput{ + Page: page, + PerPage: perPage, + Name: name, + Query: query, + }) } func runProjectsCreate(cmd *cobra.Command, args []string) error { @@ -477,6 +536,11 @@ var projectsSetLimitsCompatCmd = &cobra.Command{ } func init() { + projectsListCmd.Flags().Int("page", 1, "Page number (1-based)") + projectsListCmd.Flags().Int("per-page", 20, "Items per page (default 20)") + projectsListCmd.Flags().String("name", "", "Exact-match filter on project name") + projectsListCmd.Flags().String("query", "", "Search projects by name") + projectsUpdateCmd.Flags().String("name", "", "New project name (1-255 characters)") projectsUpdateCmd.Flags().String("status", "", "New project status: active or archived") addJSONOutputFlag(projectsUpdateCmd) diff --git a/cmd/proxies/list.go b/cmd/proxies/list.go index 83a8f74..1efb5f3 100644 --- a/cmd/proxies/list.go +++ b/cmd/proxies/list.go @@ -22,6 +22,12 @@ func (p ProxyCmd) List(ctx context.Context, in ProxyListInput) error { } params := kernel.ProxyListParams{} + if in.Name != "" { + params.Name = kernel.String(in.Name) + } + if in.Query != "" { + params.Query = kernel.String(in.Query) + } if in.Limit > 0 { params.Limit = kernel.Int(int64(in.Limit)) } @@ -154,9 +160,11 @@ func formatProxyConfig(proxy *kernel.ProxyListResponse) string { func runProxiesList(cmd *cobra.Command, args []string) error { client := util.GetKernelClient(cmd) output, _ := cmd.Flags().GetString("output") + name, _ := cmd.Flags().GetString("name") + query, _ := cmd.Flags().GetString("query") limit, _ := cmd.Flags().GetInt("limit") offset, _ := cmd.Flags().GetInt("offset") svc := client.Proxies p := ProxyCmd{proxies: &svc} - return p.List(cmd.Context(), ProxyListInput{Limit: limit, Offset: offset, Output: output}) + return p.List(cmd.Context(), ProxyListInput{Name: name, Query: query, Limit: limit, Offset: offset, Output: output}) } diff --git a/cmd/proxies/proxies.go b/cmd/proxies/proxies.go index 7642001..a3229c9 100644 --- a/cmd/proxies/proxies.go +++ b/cmd/proxies/proxies.go @@ -93,6 +93,8 @@ func init() { // Add output flags addJSONOutputFlag(proxiesListCmd) + proxiesListCmd.Flags().String("name", "", "Exact-match filter on proxy name") + proxiesListCmd.Flags().String("query", "", "Search proxies by name, host, or IP address (IDs match by exact value)") proxiesListCmd.Flags().Int("limit", 0, "Maximum number of proxies to return") proxiesListCmd.Flags().Int("offset", 0, "Number of proxies to skip (for pagination)") addJSONOutputFlag(proxiesGetCmd) diff --git a/cmd/proxies/types.go b/cmd/proxies/types.go index 7ca96f9..ee0b624 100644 --- a/cmd/proxies/types.go +++ b/cmd/proxies/types.go @@ -27,6 +27,8 @@ type ProxyCmd struct { // Input types for proxy operations type ProxyListInput struct { + Name string + Query string Limit int Offset int Output string diff --git a/go.mod b/go.mod index 54ec0a7..e0179f0 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.85.0 + github.com/kernel/kernel-go-sdk v0.86.2-0.20260807210813-d44daa947c65 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 a872a5b..5ab5c60 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.85.0 h1:rACZOx5dcjO4rasFmMoh2GS14w4yRBbYXxb92eGvD2E= -github.com/kernel/kernel-go-sdk v0.85.0/go.mod h1:EeZzSuHZVeHKxKCPUzxou2bovNGhXaz0RXrSqKNf1AQ= +github.com/kernel/kernel-go-sdk v0.86.2-0.20260807210813-d44daa947c65 h1:RMs7d3+YUqUJd8Ye8ThW4RdQA0URJ9dN58Rx2+d54L4= +github.com/kernel/kernel-go-sdk v0.86.2-0.20260807210813-d44daa947c65/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=