Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 17 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ This command interactively guides you through setting up a new configuration pro

* **Nudgebee API Endpoint**: The URL of the Nudgebee API (e.g., `https://api.nudgebee.com`).
* **Nudgebee API Key**: Your personal API token (`sk-nb-…`), created under **Settings → API Tokens**. `nbctl` sends it directly as a Bearer token on every request. Tokens created before direct token auth was supported are rejected with a 401 and must be recreated.
* **Nudgebee Username**: Your Nudgebee account username (e.g., your email).
* **Nudgebee Username**: Your Nudgebee account username (e.g., your email). Optional: used by `nubi` and `mcp`, not needed to authenticate.
* **Default Account ID**: The ID of the Nudgebee account you wish to interact with by default.

After collecting the information, `nbctl` will attempt to validate your credentials by making a test API call.
Expand Down Expand Up @@ -171,12 +171,17 @@ Add the following to your Claude Desktop configuration file (usually `~/Library/

Once configured, restart Claude Desktop. You can then ask questions like "List my Nudgebee accounts" or "Show me high severity security recommendations".

### Limiting the available commands

Set `NUDGEBEE_ENABLED_COMMANDS` to a comma-separated list of top-level commands (e.g. `metrics,logs`) to remove every other command from `nbctl` (`help`, `version` and `completion` always stay). This is meant for embedding `nbctl` in a restricted environment; it is a convenience, not an access control.

### Persistent Flags

The following flags can be used with any `nbctl` command:

* `--log-level <level>`: Sets the logging level. Accepted values are `debug`, `info` (default), `warn`, and `error`.
* `--verbose`: Enables verbose logging, including detailed GraphQL requests and responses. Useful for debugging API interactions.
* `--verbose`: Enables verbose logging, including detailed GraphQL requests and responses, to `nbctl_graphql.log` in the current directory. Credential headers (`Authorization`, cookies) are redacted. Useful for debugging API interactions.
* `--http-timeout <duration>`: Timeout for each API request, as a duration (`50s`, `2m`) or seconds. Default `30s`; `0` disables it. Also set by `NUDGEBEE_HTTP_TIMEOUT`.
* `--format <format>`: Specifies the output format for command results. Currently, `json` is supported in addition to the default human-readable `text` format.

Example:
Expand Down Expand Up @@ -538,6 +543,10 @@ Queries logs from the Nudgebee API based on various filters.
* `--offset <int>`: Specifies an offset for pagination. Default is 0.
* `--only-message`: If set, only the log messages are displayed, without timestamp, severity, or labels.

With `-o json`, the backend's log entries are printed unchanged (an array of `{timestamp, severity, message, labels}`). When the result has exactly `--limit` lines, a warning on stderr says it is probably cut off and gives the `--offset` for the next page.

The `metrics` and `logs` commands report an empty result on stderr (e.g. `No values found for log label "severity" ...`), so an empty stdout is never ambiguous; with `-o json` stdout is still `[]`.

Example:

```bash
Expand Down Expand Up @@ -598,13 +607,17 @@ Queries metrics from the Nudgebee API based on a PromQL-like query string and va
* `--account-id <id>`: The account ID to query metrics from. If not provided, it attempts to read it from the configuration.
* `--start-time <RFC3339>`: Filters metrics starting from this time. Defaults to 1 hour ago.
* `--end-time <RFC3339>`: Filters metrics up to this time. Defaults to the current time.
* `--metric-provider <provider>`: Filters metrics by a specific metric provider.
* `--only-metric`: If set, only the metric names are displayed, without attributes.
* `--step <duration>`: Resolution of a range query (e.g. `30s`, `5m`). Default: chosen by the backend.
* `--instant`: Run an instant query instead of a range query.
* `--chart`: Plot the series in the terminal.

With `-o json`, the backend's `results` are printed unchanged (an array of `{query_key, query, payload: [{metric, timestamps, values}]}`), so large results can be redirected to a file and read by scripts. Failed queries and backend notes are reported on stderr.

Example:

```bash
nbctl metrics query --account-id 123e4567-e89b-12d3-a456-426614174000 --query "node_memory_usage_bytes" --start-time "2023-10-26T00:00:00Z"
nbctl metrics query --query 'rate(container_cpu_usage_seconds_total[5m])' --start-time "2026-10-01T00:00:00Z" --end-time "2026-10-08T00:00:00Z" --step 5m -o json > cpu.json
```

#### `nbctl nubi`
Expand Down
24 changes: 24 additions & 0 deletions cmd/list_output.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package cmd

import (
"encoding/json"
"fmt"

"github.com/nudgebee/nbctl/pkg/format"
"github.com/spf13/cobra"
)

// printRows prints a list result. An empty result prints nothing in text mode
// and [] in JSON mode, and explains itself with emptyMsg on stderr, so callers
// (people and scripts alike) can tell "nothing found" from a silent failure.
func printRows(cmd *cobra.Command, table format.TabularData, count int, emptyMsg string) error {
if count > 0 {
format.GetFormat().Print(table)
return nil
}
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), emptyMsg)
if format.GetFormat().Get() == "json" {
return format.GetFormat().PrintRawJSON(json.RawMessage("[]"))
}
return nil
}
19 changes: 9 additions & 10 deletions cmd/logs_list_label_values.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ import (
"github.com/spf13/cobra"
)

// LogsListLabelValuesQuery lists the values of a log label in a time window ($query is "start=<ns>&end=<ns>").
const LogsListLabelValuesQuery = `query FetchLogLabelValues($accountId: String!, $labelName: String!, $query: String!) {
logs_list_label_values(request: {account_id: $accountId, label_name: $labelName, request: {query: $query}}) {
value
}
}`

var logsListLabelValuesCmd = &cobra.Command{
Use: "list-label-values",
Short: "List log label values",
Expand Down Expand Up @@ -47,13 +54,7 @@ var logsListLabelValuesCmd = &cobra.Command{

query := fmt.Sprintf("start=%d&end=%d", startTime.UnixNano(), endTime.UnixNano())

req := client.NewRequest(`
query FetchLogLabelValues($accountId: String!, $labelName: String!, $query: String!) {
logs_list_label_values(request: {account_id: $accountId, label_name: $labelName, request: {query: $query}}) {
value
}
}
`)
req := client.NewRequest(LogsListLabelValuesQuery)

req.Var("accountId", accountId)
req.Var("labelName", labelName)
Expand All @@ -75,9 +76,7 @@ var logsListLabelValuesCmd = &cobra.Command{
{Header: "Value", Field: "Value"},
},
}
format.GetFormat().Print(table)

return nil
return printRows(cmd, table, len(respData.LogsListLabelValues), fmt.Sprintf("No values found for log label %q between %s and %s (it may be a field inside log lines rather than an indexed label).", labelName, startTime.Format(time.RFC3339), endTime.Format(time.RFC3339)))
},
}

Expand Down
19 changes: 9 additions & 10 deletions cmd/logs_list_labels.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ import (
"github.com/spf13/cobra"
)

// LogsListLabelsQuery lists log labels in a time window ($query is "start=<ns>&end=<ns>").
const LogsListLabelsQuery = `query FetchLogLabels($accountId: String!, $query: String!) {
logs_list_labels(request: {account_id: $accountId, request: {query: $query}}) {
label
}
}`

var logsListLabelsCmd = &cobra.Command{
Use: "list-labels",
Short: "List log labels",
Expand Down Expand Up @@ -46,13 +53,7 @@ var logsListLabelsCmd = &cobra.Command{

query := fmt.Sprintf("start=%d&end=%d", startTime.UnixNano(), endTime.UnixNano())

req := client.NewRequest(`
query FetchLogLabels($accountId: String!, $query: String!) {
logs_list_labels(request: {account_id: $accountId, request: {query: $query}}) {
label
}
}
`)
req := client.NewRequest(LogsListLabelsQuery)

req.Var("accountId", accountId)
req.Var("query", query)
Expand All @@ -73,9 +74,7 @@ var logsListLabelsCmd = &cobra.Command{
{Header: "Label", Field: "Label"},
},
}
format.GetFormat().Print(table)

return nil
return printRows(cmd, table, len(respData.LogsListLabels), fmt.Sprintf("No log labels found between %s and %s.", startTime.Format(time.RFC3339), endTime.Format(time.RFC3339)))
},
}

Expand Down
89 changes: 62 additions & 27 deletions cmd/logs_query.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,19 @@ import (
"github.com/spf13/cobra"
)

// LogsQueryQuery fetches log lines through the logs_list action.
const LogsQueryQuery = `query FetchLogs($request: FetchLogRequest!) {
logs_list(request: $request) {
logs {
timestamp
severity
message
labels
}
suggestion
}
}`

var logsQueryCmd = &cobra.Command{
Use: "query",
Short: "Query logs",
Expand Down Expand Up @@ -44,27 +57,12 @@ var logsQueryCmd = &cobra.Command{
return fmt.Errorf("invalid end-time format: %w", err)
}

// Convert to Unix milliseconds
startTimeMs := startTime.UnixNano() / int64(time.Millisecond)
endTimeMs := endTime.UnixNano() / int64(time.Millisecond)

req := client.NewRequest(`
query FetchLogs($request: FetchLogRequest!) {
logs_list(request: $request) {
logs {
timestamp
severity
message
labels
}
}
}
`)
req := client.NewRequest(LogsQueryQuery)

requestVars := map[string]any{
"account_id": accountId,
"end_time": endTimeMs,
"start_time": startTimeMs,
"end_time": endTime.UnixMilli(),
"start_time": startTime.UnixMilli(),
"query": queryStr,
"limit": limit,
"offset": offset,
Expand All @@ -73,26 +71,63 @@ var logsQueryCmd = &cobra.Command{

var respData struct {
LogsList struct {
Logs []struct {
Timestamp string `json:"timestamp"`
Severity string `json:"severity"`
Message string `json:"message"`
Labels json.RawMessage `json:"labels"`
} `json:"logs"`
Logs json.RawMessage `json:"logs"`
Suggestion string `json:"suggestion"`
} `json:"logs_list"`
}

if err := graphqlClient.Run(context.Background(), req, &respData); err != nil {
return err
}

if len(respData.LogsList.Logs) == 0 {
fmt.Println("No logs found.")
if respData.LogsList.Suggestion != "" {
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Suggestion: %s\n", respData.LogsList.Suggestion)
}

raw := respData.LogsList.Logs
if len(raw) == 0 || string(raw) == "null" {
raw = json.RawMessage("[]")
}
Comment thread
blue4209211 marked this conversation as resolved.

var logs []struct {
Timestamp string `json:"timestamp"`
Severity string `json:"severity"`
Message string `json:"message"`
Labels json.RawMessage `json:"labels"`
}
// JSON output passes entries through, so for JSON only count them.
jsonOutput := format.GetFormat().Get() == "json"
count := -1 // unknown until decoded
if jsonOutput {
var entries []json.RawMessage
if json.Unmarshal(raw, &entries) == nil {
count = len(entries)
}
} else {
if err := json.Unmarshal(raw, &logs); err != nil {
return fmt.Errorf("failed to decode logs: %w", err)
}
count = len(logs)
}

window := fmt.Sprintf("between %s and %s", startTime.Format(time.RFC3339), endTime.Format(time.RFC3339))
switch {
case count == 0:
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "No logs found %s.\n", window)
case limit > 0 && count >= limit:
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "Returned %d lines = --limit; results are probably cut off. Narrow --start-time/--end-time or the query, or page with --offset %d.\n", count, offset+count)
}

// JSON output is the backend's log entries, unchanged.
if jsonOutput {
return format.GetFormat().PrintRawJSON(raw)
}
if count <= 0 {
return nil
}

table := format.TabularData{
Data: respData.LogsList.Logs,
Data: logs,
Fields: []format.TableField{
{Header: "Timestamp", Field: "Timestamp"},
{Header: "Severity", Field: "Severity"},
Expand Down
20 changes: 10 additions & 10 deletions cmd/metrics_list_label_values.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,20 @@ package cmd

import (
"context"
"fmt"

"github.com/nudgebee/nbctl/pkg/client"
"github.com/nudgebee/nbctl/pkg/format"
"github.com/spf13/cobra"
)

// MetricsListLabelValuesQuery lists the values of a metric label.
const MetricsListLabelValuesQuery = `query MetricsLabelValueList($accountId: String!, $labelName: String!) {
metrics_list_label_values(request: {account_id: $accountId, label: $labelName}) {
value
}
}`

var metricsListLabelValuesCmd = &cobra.Command{
Use: "list-label-values",
Short: "List metric label values",
Expand All @@ -21,13 +29,7 @@ var metricsListLabelValuesCmd = &cobra.Command{

label, _ := cmd.Flags().GetString("label")

req := client.NewRequest(`
query MetricsLabelValueList($accountId: String!, $labelName: String!) {
metrics_list_label_values(request: {account_id: $accountId, label: $labelName}) {
value
}
}
`)
req := client.NewRequest(MetricsListLabelValuesQuery)

req.Var("accountId", accountId)
req.Var("labelName", label)
Expand All @@ -48,9 +50,7 @@ var metricsListLabelValuesCmd = &cobra.Command{
{Header: "Value", Field: "Value"},
},
}
format.GetFormat().Print(table)

return nil
return printRows(cmd, table, len(respData.MetricsListLabelValues), fmt.Sprintf("No values found for label %q.", label))
},
}

Expand Down
20 changes: 10 additions & 10 deletions cmd/metrics_list_labels.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,20 @@ package cmd

import (
"context"
"fmt"

"github.com/nudgebee/nbctl/pkg/client"
"github.com/nudgebee/nbctl/pkg/format"
"github.com/spf13/cobra"
)

// MetricsListLabelsQuery lists the labels of a metric.
const MetricsListLabelsQuery = `query MetricsLabelList($accountId: String!, $metricName: String!) {
metrics_list_labels(request: {account_id: $accountId, metric: $metricName}) {
label
}
}`

var metricsListLabelsCmd = &cobra.Command{
Use: "list-labels",
Short: "List metric labels",
Expand All @@ -21,13 +29,7 @@ var metricsListLabelsCmd = &cobra.Command{

metric, _ := cmd.Flags().GetString("metric")

req := client.NewRequest(`
query MetricsLabelList($accountId: String!, $metricName: String!) {
metrics_list_labels(request: {account_id: $accountId, metric: $metricName}) {
label
}
}
`)
req := client.NewRequest(MetricsListLabelsQuery)

req.Var("accountId", accountId)
req.Var("metricName", metric)
Expand All @@ -48,9 +50,7 @@ var metricsListLabelsCmd = &cobra.Command{
{Header: "Label", Field: "Label"},
},
}
format.GetFormat().Print(table)

return nil
return printRows(cmd, table, len(respData.MetricsListLabels), fmt.Sprintf("No labels found for metric %q.", metric))
},
}

Expand Down
Loading
Loading