diff --git a/AGENTS.md b/AGENTS.md index 8d376cf..98d7308 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -280,6 +280,13 @@ For full flag/argument reference, use `band --help`. This section cove - **`tollfree template` is account-gated.** The underlying endpoint requires the `TollFreeTemplateAssignmentSearch` account setting (off by default; Bandwidth enables it on request). Expect exit 2 with a "not enabled on account" message until then — that is the correct behavior, not a bug. Numbers must be in-service on the account, toll-free (800/888/877/866/855/844/833), and at most 5000 per invocation. - **The template name is the answer, not a carrier name.** The CLI returns `templateName` exactly as the registry stores it; mapping template names to ingress carriers is operator knowledge the API does not expose. +### Insights + +- **`insights` commands are usage aggregates, not call logs.** Each returns time slices whose granularity the API picks from the window size (hourly for days, monthly for months) — it is not configurable. History caps at one year. With no `--since`/`--until`, the window is the last 7 days. +- **Feature-gated:** requires the Monitoring API feature on the account; expect exit 2 with a "not enabled" message otherwise — correct behavior, not a bug. +- **A number's traffic profile in one pass:** run `insights minutes-of-use`, `insights completed-calls`, and `insights average-durations` with the same `--to +1800... --since 30d` filters. Add `--call-type TOLLFREE-IN` to isolate toll-free ingress. Phone-number filters are slow on large accounts per the API docs — narrow with `--direction`/`--subaccount` when possible. +- **`--call-type` accepts dash or underscore forms** (`TOLLFREE-IN` and `TOLLFREE_IN` both work; the CLI normalizes). + ### VCPs - **`vcp delete` fails if numbers are assigned.** Move them first with `vcp assign `. diff --git a/README.md b/README.md index 6752b5a..f8ada9f 100644 --- a/README.md +++ b/README.md @@ -436,6 +436,18 @@ Sub-accounts (formerly known as sites) are the top-level container. Locations (f |---------|-------------| | `band tollfree template ` | Look up the routing template assigned to toll-free numbers (account-gated; 403 until enabled) | +### Insights (voice usage) + +| Command | What it does | +|---------|-------------| +| `band insights minutes-of-use` | Aggregated minutes of use per time slice | +| `band insights completed-calls` | Completed call counts per time slice | +| `band insights failed-calls` | Failed call counts per time slice | +| `band insights connection-rates` | Call connection rates per time slice | +| `band insights average-durations` | Average call durations per time slice | + +All five share the same filters: `--to`/`--from` (comma-separated E.164), `--direction`, `--call-type` (e.g. `TOLLFREE-IN`), `--subaccount`, and `--since`/`--until` (RFC3339 or relative like `30d`). Requires the Monitoring API feature on the account. + ### Messaging | Command | What it does | diff --git a/cmd/insights/insights.go b/cmd/insights/insights.go new file mode 100644 index 0000000..94a9104 --- /dev/null +++ b/cmd/insights/insights.go @@ -0,0 +1,23 @@ +// Package insights implements `band insights`, read-only voice usage and +// quality aggregates from the Bandwidth Insights Monitoring API. +package insights + +import "github.com/spf13/cobra" + +// Cmd is the `band insights` parent command. +var Cmd = &cobra.Command{ + Use: "insights", + Short: "Voice usage and quality aggregates (minutes of use, call counts, connection rates)", + Long: `Read aggregated voice traffic data from the Bandwidth Insights API: +minutes of use, completed and failed calls, connection rates, and average +call durations — filterable by phone number, direction, call type, and +sub-account. + +Results are broken into time slices whose granularity scales with the +requested window (hours for a few days, months for long ranges). History +goes back at most one year. Defaults to the last 7 days when no time range +is given. + +Requires the Monitoring API feature on your account. If you get a 403 +error, ask your Bandwidth account manager to enable it.`, +} diff --git a/cmd/insights/insights_test.go b/cmd/insights/insights_test.go new file mode 100644 index 0000000..b2904e1 --- /dev/null +++ b/cmd/insights/insights_test.go @@ -0,0 +1,136 @@ +package insights + +import ( + "testing" + "time" +) + +func TestCmdStructure(t *testing.T) { + if Cmd.Use != "insights" { + t.Errorf("Use = %q, want %q", Cmd.Use, "insights") + } + + subs := map[string]bool{} + for _, c := range Cmd.Commands() { + subs[c.Use] = true + } + for _, name := range []string{"minutes-of-use", "completed-calls", "failed-calls", "connection-rates", "average-durations"} { + if !subs[name] { + t.Errorf("missing subcommand %q", name) + } + } +} + +func TestParseTimeFlag(t *testing.T) { + now := time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC) + tests := []struct { + input string + want string + wantErr bool + }{ + {input: "7d", want: "2026-08-17T12:00:00Z"}, + {input: "24h", want: "2026-08-23T12:00:00Z"}, + {input: "90m", want: "2026-08-24T10:30:00Z"}, + {input: "2026-07-01T00:00:00Z", want: "2026-07-01T00:00:00Z"}, + {input: "2026-07-01T00:00:00-05:00", want: "2026-07-01T00:00:00-05:00"}, + {input: "yesterday", wantErr: true}, + {input: "2026-07-01", wantErr: true}, + {input: "7w", wantErr: true}, + // Beyond the one-year history cap; large enough values would + // otherwise overflow time.Duration and land in the future. + {input: "401d", wantErr: true}, + {input: "106752d", wantErr: true}, + {input: "9999999999999m", wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got, err := parseTimeFlag(tt.input, now) + if tt.wantErr { + if err == nil { + t.Fatalf("parseTimeFlag(%q) = %q, want error", tt.input, got) + } + return + } + if err != nil { + t.Fatalf("parseTimeFlag(%q) error: %v", tt.input, err) + } + if got != tt.want { + t.Errorf("parseTimeFlag(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestBuildMonitorQuery(t *testing.T) { + now := time.Date(2026, 8, 24, 12, 0, 0, 0, time.UTC) + q, err := buildMonitorQuery(monitorFlags{ + To: "+18005551234,+18885551234", + Direction: "inbound", + CallType: "tollfree-in", + Since: "30d", + }, now) + if err != nil { + t.Fatalf("buildMonitorQuery error: %v", err) + } + want := map[string]string{ + "toPhoneNumber[eq]": "+18005551234,+18885551234", + "direction[eq]": "INBOUND", + "callType[eq]": "TOLLFREE_IN", + "timestamp[gte]": "2026-07-25T12:00:00Z", + } + for k, v := range want { + if q.Get(k) != v { + t.Errorf("q[%s] = %q, want %q", k, q.Get(k), v) + } + } + if q.Get("timestamp[lte]") != "" { + t.Error("timestamp[lte] should be unset when --until absent") + } + // accountId[eq] is added by the caller after auth resolves the account. + if q.Get("accountId[eq]") != "" { + t.Error("accountId[eq] should not be set by buildMonitorQuery") + } + + if _, err := buildMonitorQuery(monitorFlags{Direction: "SIDEWAYS"}, now); err == nil { + t.Error("invalid direction should be a flag error") + } + if _, err := buildMonitorQuery(monitorFlags{CallType: "banana"}, now); err == nil { + t.Error("invalid call type should be a flag error") + } +} + +func TestNormalizeCallType(t *testing.T) { + for input, want := range map[string]string{ + "TOLLFREE-IN": "TOLLFREE_IN", + "tollfree_in": "TOLLFREE_IN", + "local": "LOCAL", + } { + got, err := normalizeCallType(input) + if err != nil { + t.Errorf("normalizeCallType(%q) error: %v", input, err) + continue + } + if got != want { + t.Errorf("normalizeCallType(%q) = %q, want %q", input, got, want) + } + } + if _, err := normalizeCallType("banana"); err == nil { + t.Error("normalizeCallType should reject values outside the enum") + } +} + +func TestUnwrapMonitorData(t *testing.T) { + env := map[string]interface{}{ + "links": []interface{}{}, + "data": map[string]interface{}{"aggregation": "hourly", "slices": []interface{}{}}, + "errors": []interface{}{}, + } + got, ok := unwrapMonitorData(env).(map[string]interface{}) + if !ok || got["aggregation"] != "hourly" { + t.Errorf("unwrap = %#v, want data object", unwrapMonitorData(env)) + } + odd := map[string]interface{}{"surprise": true} + if unwrapMonitorData(odd) == nil { + t.Error("unexpected shape should pass through") + } +} diff --git a/cmd/insights/monitors.go b/cmd/insights/monitors.go new file mode 100644 index 0000000..931a41e --- /dev/null +++ b/cmd/insights/monitors.go @@ -0,0 +1,224 @@ +package insights + +import ( + "errors" + "fmt" + "net/url" + "regexp" + "strconv" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/Bandwidth/cli/internal/api" + "github.com/Bandwidth/cli/internal/cmdutil" + "github.com/Bandwidth/cli/internal/output" +) + +// monitorFlags are the filters shared by every monitor subcommand. The +// Insights API encodes them deepObject-style (e.g. timestamp[gte]=...). +type monitorFlags struct { + To string + From string + Direction string + CallType string + Subaccount string + Since string + Until string +} + +// monitor describes one /v1/monitors/voice endpoint exposed as a subcommand. +type monitor struct { + use string // subcommand name; matches the API path segment + short string +} + +// monitors are the v1 set: the aggregates that together describe a number's +// or account's traffic profile. The API's other monitors (calls-per-second, +// concurrent-calls, error-percentages, network-efficiency-ratios, +// short-calls, call-data) can be added to this table as needed. +var monitors = []monitor{ + {use: "minutes-of-use", short: "Aggregated minutes of use per time slice"}, + {use: "completed-calls", short: "Completed call counts per time slice"}, + {use: "failed-calls", short: "Failed call counts per time slice"}, + {use: "connection-rates", short: "Call connection rates per time slice"}, + {use: "average-durations", short: "Average call durations per time slice"}, +} + +func init() { + for _, m := range monitors { + Cmd.AddCommand(newMonitorCmd(m)) + } +} + +func newMonitorCmd(m monitor) *cobra.Command { + flags := &monitorFlags{} + cmd := &cobra.Command{ + Use: m.use, + Short: m.short, + Long: m.short + `. + +All filters are optional; unfiltered requests cover the whole account over +the last 7 days. Phone-number filters accept comma-separated E.164 values +and can be slow on large accounts — add other filters to narrow the scope.`, + Example: fmt.Sprintf(` band insights %[1]s + band insights %[1]s --to +18005551234 --since 30d + band insights %[1]s --call-type TOLLFREE-IN --direction INBOUND --since 2026-07-01T00:00:00Z`, m.use), + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return runMonitor(cmd, m.use, flags) + }, + } + cmd.Flags().StringVar(&flags.To, "to", "", "Filter by destination number(s), comma-separated E.164") + cmd.Flags().StringVar(&flags.From, "from", "", "Filter by originating number(s), comma-separated E.164") + cmd.Flags().StringVar(&flags.Direction, "direction", "", "Filter by call direction: INBOUND or OUTBOUND") + cmd.Flags().StringVar(&flags.CallType, "call-type", "", "Filter by call type (e.g. TOLLFREE-IN, TOLLFREE-OUT, LOCAL, INTERSTATE)") + cmd.Flags().StringVar(&flags.Subaccount, "subaccount", "", "Filter by sub-account ID") + cmd.Flags().StringVar(&flags.Since, "since", "", "Start of the time range: RFC3339 or relative (e.g. 30d, 24h, 90m); default 7 days ago") + cmd.Flags().StringVar(&flags.Until, "until", "", "End of the time range: RFC3339 or relative; default now") + return cmd +} + +// relativeTimeRe matches the relative time shorthand: d, h, or m. +var relativeTimeRe = regexp.MustCompile(`^(\d+)([dhm])$`) + +// maxRelativeTime bounds the relative shorthand. The API returns at most one +// year of history, so anything past ~400 days is either a typo or an integer +// large enough to overflow time.Duration arithmetic — reject both. +const maxRelativeTime = 400 * 24 * time.Hour + +// parseTimeFlag converts a --since/--until value to RFC3339. Relative values +// are anchored at now; RFC3339 values pass through verbatim (the API handles +// timezone interpretation). +func parseTimeFlag(value string, now time.Time) (string, error) { + if m := relativeTimeRe.FindStringSubmatch(value); m != nil { + n, err := strconv.Atoi(m[1]) + if err != nil { + return "", cmdutil.NewFlagError(fmt.Sprintf("invalid relative time %q", value)) + } + var unit time.Duration + switch m[2] { + case "d": + unit = 24 * time.Hour + case "h": + unit = time.Hour + case "m": + unit = time.Minute + } + // Bound before multiplying: a large enough n wraps time.Duration + // negative and would silently produce a timestamp in the future. + if int64(n) > int64(maxRelativeTime/unit) { + return "", cmdutil.NewFlagError(fmt.Sprintf("relative time %q is out of range: the API keeps at most one year of history", value)) + } + return now.Add(-time.Duration(n) * unit).UTC().Format(time.RFC3339), nil + } + if _, err := time.Parse(time.RFC3339, value); err != nil { + return "", cmdutil.NewFlagError(fmt.Sprintf("invalid time %q: use RFC3339 (2026-07-01T00:00:00Z) or relative shorthand (30d, 24h, 90m)", value)) + } + return value, nil +} + +// callTypes is the API's callType filter enum (underscore form). +var callTypes = map[string]bool{ + "EMERGENCY": true, "INBOUND_TFOOS": true, "INFORMATION": true, + "INTERNATIONAL": true, "INTERNATIONAL_INTERNAL": true, "INTERSTATE": true, + "INTRASTATE": true, "INTL_BLOCK": true, "LOCAL": true, "OPERATOR": true, + "OTHER_N11": true, "SIPURI_EXT": true, "TOLLFREE_IN": true, + "TOLLFREE_OUT": true, "UNDETERMINED": true, +} + +// normalizeCallType uppercases and converts dashes to underscores, then +// validates against the API enum: the query filter uses TOLLFREE_IN while +// responses (and Bandwidth docs) render TOLLFREE-IN, so accept either form. +func normalizeCallType(v string) (string, error) { + n := strings.ReplaceAll(strings.ToUpper(v), "-", "_") + if !callTypes[n] { + return "", cmdutil.NewFlagError(fmt.Sprintf("invalid --call-type %q: use one of LOCAL, INTERSTATE, INTRASTATE, INTERNATIONAL, TOLLFREE-IN, TOLLFREE-OUT, EMERGENCY, ... (see the Insights API docs for the full list)", v)) + } + return n, nil +} + +// buildMonitorQuery renders the deepObject query parameters for a monitor +// request and validates every flag. It runs before authentication so flag +// misuse fails deterministically (exit 6) regardless of login state; the +// caller adds the required accountId[eq] once auth has resolved the account. +func buildMonitorQuery(f monitorFlags, now time.Time) (url.Values, error) { + q := url.Values{} + if f.To != "" { + q.Set("toPhoneNumber[eq]", f.To) + } + if f.From != "" { + q.Set("fromPhoneNumber[eq]", f.From) + } + if f.Direction != "" { + d := strings.ToUpper(f.Direction) + if d != "INBOUND" && d != "OUTBOUND" { + return nil, cmdutil.NewFlagError(fmt.Sprintf("invalid --direction %q: use INBOUND or OUTBOUND", f.Direction)) + } + q.Set("direction[eq]", d) + } + if f.CallType != "" { + ct, err := normalizeCallType(f.CallType) + if err != nil { + return nil, err + } + q.Set("callType[eq]", ct) + } + if f.Subaccount != "" { + q.Set("subAccount[eq]", f.Subaccount) + } + if f.Since != "" { + ts, err := parseTimeFlag(f.Since, now) + if err != nil { + return nil, err + } + q.Set("timestamp[gte]", ts) + } + if f.Until != "" { + ts, err := parseTimeFlag(f.Until, now) + if err != nil { + return nil, err + } + q.Set("timestamp[lte]", ts) + } + return q, nil +} + +// unwrapMonitorData extracts the data object from the {links, data, errors} +// envelope. Unexpected shapes pass through raw. +func unwrapMonitorData(result interface{}) interface{} { + if m, ok := result.(map[string]interface{}); ok { + if data, ok := m["data"]; ok && data != nil { + return data + } + } + return result +} + +func runMonitor(cmd *cobra.Command, endpoint string, flags *monitorFlags) error { + // Validate flags before authenticating so misuse fails fast (exit 6) + // even when logged out. + q, err := buildMonitorQuery(*flags, time.Now()) + if err != nil { + return err + } + + client, acctID, err := cmdutil.InsightsClient(cmdutil.AccountIDFlag(cmd)) + if err != nil { + return err + } + q.Set("accountId[eq]", acctID) + + var result interface{} + if err := client.Get(cmd.Context(), "/v1/monitors/voice/"+endpoint+"?"+q.Encode(), &result); err != nil { + var apiErr *api.APIError + if errors.As(err, &apiErr) && apiErr.StatusCode == 403 { + return fmt.Errorf("the Monitoring API feature is not enabled on account %s — ask your Bandwidth account manager to enable it: %w", acctID, err) + } + return fmt.Errorf("getting %s: %w", endpoint, err) + } + + format, plain := cmdutil.OutputFlags(cmd) + return output.StdoutAuto(format, plain, unwrapMonitorData(result)) +} diff --git a/cmd/root.go b/cmd/root.go index e0502c0..8ed0bbb 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -25,6 +25,7 @@ import ( bxmlcmd "github.com/Bandwidth/cli/cmd/bxml" callcmd "github.com/Bandwidth/cli/cmd/call" customerprofilecmd "github.com/Bandwidth/cli/cmd/customerprofile" + insightscmd "github.com/Bandwidth/cli/cmd/insights" locationcmd "github.com/Bandwidth/cli/cmd/location" messagecmd "github.com/Bandwidth/cli/cmd/message" numbercmd "github.com/Bandwidth/cli/cmd/number" @@ -108,6 +109,7 @@ func init() { rootCmd.AddCommand(numbercmd.Cmd) rootCmd.AddCommand(callcmd.Cmd) rootCmd.AddCommand(customerprofilecmd.Cmd) + rootCmd.AddCommand(insightscmd.Cmd) rootCmd.AddCommand(messagecmd.Cmd) rootCmd.AddCommand(recordingcmd.Cmd) rootCmd.AddCommand(transcriptioncmd.Cmd) diff --git a/internal/cmdutil/helpers.go b/internal/cmdutil/helpers.go index 13096de..5af54ea 100644 --- a/internal/cmdutil/helpers.go +++ b/internal/cmdutil/helpers.go @@ -90,6 +90,16 @@ func voiceHostForEnvironment(env string) string { } } +// insightsHost returns the Insights API base host. The Insights API publishes +// a single production host (no test environment). BW_INSIGHTS_URL overrides +// the base URL for local proxies. +func insightsHost() string { + if v := os.Getenv("BW_INSIGHTS_URL"); v != "" { + return strings.TrimRight(v, "/") + } + return "https://insights.bandwidth.com" +} + // messagingHost returns the Messaging API base host. The Bandwidth Messaging // API is PRODUCTION-ONLY — there is no public test/sandbox host, so unlike the // api/voice clients it does NOT vary by --environment. (Confirmed against all @@ -261,6 +271,31 @@ func PlatformClient(accountIDOverride string) (*api.Client, string, error) { return api.NewClient(apiHostForEnvironment(env), tm), acctID, nil } +// InsightsClient returns a JSON client for the Bandwidth Insights API. +// Insights is production-only (single published host), so — like +// MessagingClient — the OAuth token must be minted against the PROD API host: +// authenticate() would mint against the test realm under --environment test, +// and a test-realm token is rejected by the prod Insights endpoint. +func InsightsClient(accountIDOverride string) (*api.Client, string, error) { + cfg, p, clientSecret, err := loadConfigAndAuth() + if err != nil { + return nil, "", err + } + acctID, err := resolveAccountID(cfg, p, accountIDOverride) + if err != nil { + return nil, "", err + } + env, err := resolveEnvironment(p.Environment) + if err != nil { + return nil, "", err + } + if env != "" && env != "prod" { + ui.Warnf("Bandwidth Insights has no test environment — this request hits PRODUCTION data regardless of --environment.") + } + tm := auth.NewTokenManager(p.ClientID, clientSecret, apiHostForEnvironment("prod")) + return api.NewClient(insightsHost()+"/api", tm), acctID, nil +} + // messagingProdOnlyWarning returns a user warning when env is a non-production // environment, because the Bandwidth Messaging API is production-only (there is // no test host). Worded to be accurate for any messaging command (not just