Skip to content
Open
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
2 changes: 2 additions & 0 deletions REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1936,6 +1936,8 @@ Query Event Gateway metrics (events, requests, attempts, queue depth, pending ev

**Common flags (all metrics subcommands):** `--start`, `--end` (required), `--granularity` (e.g. 1h, 5m, 1d), `--measures`, `--dimensions`, `--source-id`, `--destination-id`, `--connection-id`, `--status`, `--output` (json).

`--delivery-group` filters by delivery group on `metrics events` and `metrics attempts` only. The requests and transformations endpoints do not accept it, so the flag is not offered there, and it cannot be combined with `--measures pending` or per-issue metrics.

## Utilities

<!-- GENERATE:completion|ci:START -->
Expand Down
8 changes: 1 addition & 7 deletions pkg/cmd/gateway.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,7 @@ func requireGatewayProject(cfg *config.Config) error {
if cfg.Profile.ProjectId == "" {
return fmt.Errorf("no project selected. Run 'hookdeck project use' to select a project")
}
projectType := cfg.Profile.ProjectType
if projectType == "" && cfg.Profile.ProjectProduct != "" {
projectType = config.ProductToProjectType(cfg.Profile.ProjectProduct)
}
if projectType == "" && cfg.Profile.ProjectMode != "" {
projectType = config.ModeToProjectType(cfg.Profile.ProjectMode)
}
projectType := cfg.Profile.ResolveProjectType()
if projectType == "" {
// Resolve team/project/mode/type from API (authoritative for the key). Do not clear
// guest_url here — gateway PreRun may run for users who still have a guest upgrade link.
Expand Down
25 changes: 19 additions & 6 deletions pkg/cmd/metrics.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,25 +68,38 @@ type metricsCommonFlags struct {
output string
}

// metricsFlagOpts omits flags the target endpoint would reject. A flag the API
// refuses is worse than a missing one: the filter is silently accepted by cobra
// and comes back as an opaque 422 from the server.
type metricsFlagOpts struct {
// skipIssueID omits --issue-id for subcommands that take the id as an
// argument instead (e.g. events-by-issue <issue-id>).
skipIssueID bool
// skipDeliveryGroup omits --delivery-group. Only the events, attempts and
// queue-depth filter schemas accept delivery_group; requests and
// transformations do not, and their filters are additionalProperties:false.
skipDeliveryGroup bool
}

// addMetricsCommonFlags adds common metrics flags to cmd and binds them to f.
// For subcommands that take a required resource id as an argument (e.g. events-by-issue <issue-id>),
// pass skipIssueID true so --issue-id is not added as a flag.
func addMetricsCommonFlags(cmd *cobra.Command, f *metricsCommonFlags) {
addMetricsCommonFlagsEx(cmd, f, false)
addMetricsCommonFlagsEx(cmd, f, metricsFlagOpts{})
}

func addMetricsCommonFlagsEx(cmd *cobra.Command, f *metricsCommonFlags, skipIssueID bool) {
func addMetricsCommonFlagsEx(cmd *cobra.Command, f *metricsCommonFlags, opts metricsFlagOpts) {
cmd.Flags().StringVar(&f.start, "start", "", "Start of time range (ISO 8601 date-time, required)")
cmd.Flags().StringVar(&f.end, "end", "", "End of time range (ISO 8601 date-time, required)")
cmd.Flags().StringVar(&f.granularity, "granularity", "", granularityHelp)
cmd.Flags().StringVar(&f.measures, "measures", "", "Comma-separated list of measures to return")
cmd.Flags().StringVar(&f.dimensions, "dimensions", "", "Comma-separated dimensions to group by (e.g. connection_id, source_id, destination_id, delivery_group, status)")
cmd.Flags().StringVar(&f.sourceID, "source-id", "", "Filter by source ID")
cmd.Flags().StringVar(&f.destinationID, "destination-id", "", "Filter by destination ID")
cmd.Flags().StringVar(&f.deliveryGroup, "delivery-group", "", "Filter by delivery group")
if !opts.skipDeliveryGroup {
cmd.Flags().StringVar(&f.deliveryGroup, "delivery-group", "", "Filter by delivery group")
}
cmd.Flags().StringVar(&f.connectionID, "connection-id", "", "Filter by connection ID")
cmd.Flags().StringVar(&f.status, "status", "", "Filter by status (e.g. SUCCESSFUL, FAILED)")
if !skipIssueID {
if !opts.skipIssueID {
cmd.Flags().StringVar(&f.issueID, "issue-id", "", "Filter by issue ID (required for per-issue metrics, e.g. when using --dimensions issue_id)")
}
cmd.Flags().StringVar(&f.output, "output", "", "Output format (json)")
Expand Down
74 changes: 74 additions & 0 deletions pkg/cmd/metrics_delivery_group_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package cmd

import (
"context"
"testing"

"github.com/hookdeck/hookdeck-cli/pkg/hookdeck"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// TestDeliveryGroupFlagOnlyWhereTheAPIAcceptsIt covers the filter schemas the API
// actually declares: delivery_group exists on events, attempts and queue-depth,
// and not on requests or transformations. Those filters are additionalProperties:
// false, so offering the flag where it is not accepted turns a typo-level mistake
// into an opaque 422 from the server.
func TestDeliveryGroupFlagOnlyWhereTheAPIAcceptsIt(t *testing.T) {
tests := []struct {
name string
cmd *cobra.Command
expected bool
}{
{"events", newMetricsEventsCmd().cmd, true},
{"attempts", newMetricsAttemptsCmd().cmd, true},
{"requests", newMetricsRequestsCmd().cmd, false},
{"transformations", newMetricsTransformationsCmd().cmd, false},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
flag := tt.cmd.Flags().Lookup("delivery-group")
if tt.expected {
assert.NotNil(t, flag, "%s accepts delivery_group and should offer the flag", tt.name)
} else {
assert.Nil(t, flag, "%s rejects unknown filters; the flag must not be offered", tt.name)
}
})
}
}

// TestEventMetricsRejectDeliveryGroupOnUnsupportedRoutes covers the two routes
// `metrics events` can take where the target endpoint has no delivery_group in
// its filter schema. The client must say so rather than let the API answer 422.
func TestEventMetricsRejectDeliveryGroupOnUnsupportedRoutes(t *testing.T) {
t.Run("pending timeseries", func(t *testing.T) {
_, err := queryEventMetricsConsolidated(context.Background(), nil, hookdeck.MetricsQueryParams{
Measures: []string{"pending"},
Granularity: "1h",
DeliveryGroup: "dg_1",
})
require.Error(t, err)
assert.Contains(t, err.Error(), "--delivery-group")
})

t.Run("per-issue", func(t *testing.T) {
_, err := queryEventMetricsConsolidated(context.Background(), nil, hookdeck.MetricsQueryParams{
Dimensions: []string{"issue_id"},
IssueID: "iss_1",
DeliveryGroup: "dg_1",
})
require.Error(t, err)
assert.Contains(t, err.Error(), "--delivery-group")
})

t.Run("per-issue still reports the missing issue id first", func(t *testing.T) {
_, err := queryEventMetricsConsolidated(context.Background(), nil, hookdeck.MetricsQueryParams{
Dimensions: []string{"issue_id"},
DeliveryGroup: "dg_1",
})
require.Error(t, err)
assert.Contains(t, err.Error(), "--issue-id")
})
}
6 changes: 6 additions & 0 deletions pkg/cmd/metrics_events.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,9 @@ func queryEventMetricsConsolidated(ctx context.Context, client *hookdeck.Client,
// 2. If measures include "pending" with granularity → QueryEventsPendingTimeseries
// API expects measures[]=count; "pending" is only used for routing.
if hasMeasure(params, map[string]bool{"pending": true}) && params.Granularity != "" {
if params.DeliveryGroup != "" {
return nil, errors.New("--delivery-group cannot be used with --measures pending; the pending timeseries endpoint filters on destination only")
}
pendingParams := params
pendingParams.Measures = []string{"count"}
return client.QueryEventsPendingTimeseries(ctx, pendingParams)
Expand All @@ -85,6 +88,9 @@ func queryEventMetricsConsolidated(ctx context.Context, client *hookdeck.Client,
if params.IssueID == "" {
return nil, errors.New("per-issue metrics require --issue-id (required when using --dimensions issue_id)")
}
if params.DeliveryGroup != "" {
return nil, errors.New("--delivery-group cannot be used with per-issue metrics; the events-by-issue endpoint does not filter on delivery group")
}
return client.QueryEventsByIssue(ctx, params)
}
// 4. Default → QueryEventMetrics
Expand Down
5 changes: 3 additions & 2 deletions pkg/cmd/metrics_requests.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import (
const metricsRequestsMeasures = "count, accepted_count, rejected_count, discarded_count, avg_events_per_request, avg_ignored_per_request"

type metricsRequestsCmd struct {
cmd *cobra.Command
cmd *cobra.Command
flags metricsCommonFlags
}

Expand All @@ -23,7 +23,8 @@ func newMetricsRequestsCmd() *metricsRequestsCmd {
Long: LongBeta(`Query metrics for requests (acceptance, rejection, etc.). Measures: ` + metricsRequestsMeasures + `.`),
RunE: c.runE,
}
addMetricsCommonFlags(c.cmd, &c.flags)
// The requests filter schema has no delivery_group, and rejects unknown filters.
addMetricsCommonFlagsEx(c.cmd, &c.flags, metricsFlagOpts{skipDeliveryGroup: true})
return c
}

Expand Down
5 changes: 3 additions & 2 deletions pkg/cmd/metrics_transformations.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import (
const metricsTransformationsMeasures = "count, successful_count, failed_count, error_rate, error_count, warn_count, info_count, debug_count"

type metricsTransformationsCmd struct {
cmd *cobra.Command
cmd *cobra.Command
flags metricsCommonFlags
}

Expand All @@ -23,7 +23,8 @@ func newMetricsTransformationsCmd() *metricsTransformationsCmd {
Long: LongBeta(`Query metrics for transformations. Measures: ` + metricsTransformationsMeasures + `.`),
RunE: c.runE,
}
addMetricsCommonFlags(c.cmd, &c.flags)
// The transformations filter schema has no delivery_group, and rejects unknown filters.
addMetricsCommonFlagsEx(c.cmd, &c.flags, metricsFlagOpts{skipDeliveryGroup: true})
return c
}

Expand Down
8 changes: 1 addition & 7 deletions pkg/cmd/whoami.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,13 +68,7 @@ func (lc *whoamiCmd) runWhoamiCmd(cmd *cobra.Command, args []string) error {
fmt.Printf("%s\n", note)
}

projectType := Config.Profile.ProjectType
if projectType == "" && Config.Profile.ProjectProduct != "" {
projectType = config.ProductToProjectType(Config.Profile.ProjectProduct)
}
if projectType == "" && Config.Profile.ProjectMode != "" {
projectType = config.ModeToProjectType(Config.Profile.ProjectMode)
}
projectType := Config.Profile.ResolveProjectType()
if projectType == "" && projectProduct != "" {
projectType = config.ProductToProjectType(projectProduct)
}
Expand Down
16 changes: 2 additions & 14 deletions pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -274,14 +274,7 @@ func (c *Config) setProfileFieldsInViper(v *viper.Viper) {
v.Set(c.Profile.getConfigField("project_id"), c.Profile.ProjectId)
v.Set(c.Profile.getConfigField("project_product"), c.Profile.ProjectProduct)
v.Set(c.Profile.getConfigField("project_mode"), c.Profile.ProjectMode)
projectType := c.Profile.ProjectType
if projectType == "" && c.Profile.ProjectProduct != "" {
projectType = ProductToProjectType(c.Profile.ProjectProduct)
}
if projectType == "" && c.Profile.ProjectMode != "" {
projectType = ModeToProjectType(c.Profile.ProjectMode)
}
v.Set(c.Profile.getConfigField("project_type"), projectType)
v.Set(c.Profile.getConfigField("project_type"), c.Profile.ResolveProjectType())
if c.Profile.GuestURL != "" {
v.Set(c.Profile.getConfigField("guest_url"), c.Profile.GuestURL)
}
Expand Down Expand Up @@ -401,12 +394,7 @@ func (c *Config) constructConfig() {

// ProjectType: prefer project_type, then derive from the public product, then legacy mode.
c.Profile.ProjectType = stringCoalesce(c.Profile.ProjectType, c.viper.GetString(c.Profile.getConfigField("project_type")), c.viper.GetString("project_type"), "")
if c.Profile.ProjectType == "" && c.Profile.ProjectProduct != "" {
c.Profile.ProjectType = ProductToProjectType(c.Profile.ProjectProduct)
}
if c.Profile.ProjectType == "" && c.Profile.ProjectMode != "" {
c.Profile.ProjectType = ModeToProjectType(c.Profile.ProjectMode)
}
c.Profile.ProjectType = c.Profile.ResolveProjectType()
if c.Profile.ProjectProduct == "" && c.Profile.ProjectMode != "" {
c.Profile.ProjectProduct = ModeToProduct(c.Profile.ProjectMode)
}
Expand Down
24 changes: 24 additions & 0 deletions pkg/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,11 @@ func TestInitConfig(t *testing.T) {

assert.Equal(t, "inbound", c.Profile.ProjectMode)
assert.Equal(t, "Gateway", c.Profile.ProjectType)
// The upgrade path: a config written before 2026-09-01 has no
// project_product, so it has to be derived from the legacy mode.
// Without this, an upgraded user has an empty product until they
// log in again, and IsGatewayProject("") fails every gateway command.
assert.Equal(t, "event_gateway", c.Profile.ProjectProduct)
})

t.Run("project_type and project_mode - prefer project_type", func(t *testing.T) {
Expand Down Expand Up @@ -313,6 +318,25 @@ func TestWriteConfig(t *testing.T) {
contentBytes, _ := ioutil.ReadFile(c.viper.ConfigFileUsed())
assert.Contains(t, string(contentBytes), `project_id = 'new_team_id'`)
assert.Contains(t, string(contentBytes), `project_type = 'Gateway'`)
// A legacy mode in, the public product written back out.
assert.Contains(t, string(contentBytes), `project_product = 'event_gateway'`)
assert.Contains(t, string(contentBytes), `project_mode = 'inbound'`)
})

t.Run("use project with a product", func(t *testing.T) {
t.Parallel()

c := Config{LogLevel: "info"}
c.ConfigFileFlag = setupTempConfig(t, "./testdata/default-profile.toml")
c.InitConfig()

err := c.UseProject("new_team_id", "outpost")

assert.NoError(t, err)
contentBytes, _ := ioutil.ReadFile(c.viper.ConfigFileUsed())
assert.Contains(t, string(contentBytes), `project_product = 'outpost'`)
assert.Contains(t, string(contentBytes), `project_type = 'Outpost'`)
assert.Contains(t, string(contentBytes), `project_mode = 'outpost'`)
})

t.Run("use profile", func(t *testing.T) {
Expand Down
35 changes: 21 additions & 14 deletions pkg/config/profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@ import (
)

type Profile struct {
Name string // profile name
APIKey string
ProjectId string
Name string // profile name
APIKey string
ProjectId string
ProjectProduct string
ProjectMode string
ProjectType string // display type: Gateway, Outpost, Console
GuestURL string // URL to create permanent account for guest users
ProjectMode string
ProjectType string // display type: Gateway, Outpost, Console
GuestURL string // URL to create permanent account for guest users

Config *Config
}
Expand All @@ -21,19 +21,26 @@ func (p *Profile) getConfigField(field string) string {
return p.Name + "." + field
}

// ResolveProjectType returns the display type for this profile: the stored type
// if there is one, otherwise derived from the product, otherwise from the legacy
// mode. This precedence was open-coded in five places; keep it here so a caller
// cannot get the order subtly wrong.
func (p *Profile) ResolveProjectType() string {
if p.ProjectType != "" {
return p.ProjectType
}
if t := ProductToProjectType(p.ProjectProduct); t != "" {
return t
}
return ModeToProjectType(p.ProjectMode)
}

func (p *Profile) SaveProfile() error {
p.Config.viper.Set(p.getConfigField("api_key"), p.APIKey)
p.Config.viper.Set(p.getConfigField("project_id"), p.ProjectId)
p.Config.viper.Set(p.getConfigField("project_product"), p.ProjectProduct)
p.Config.viper.Set(p.getConfigField("project_mode"), p.ProjectMode)
projectType := p.ProjectType
if projectType == "" && p.ProjectProduct != "" {
projectType = ProductToProjectType(p.ProjectProduct)
}
if projectType == "" && p.ProjectMode != "" {
projectType = ModeToProjectType(p.ProjectMode)
}
p.Config.viper.Set(p.getConfigField("project_type"), projectType)
p.Config.viper.Set(p.getConfigField("project_type"), p.ResolveProjectType())
p.Config.viper.Set(p.getConfigField("guest_url"), p.GuestURL)

if err := p.removeLegacyConfigKeys(); err != nil {
Expand Down
32 changes: 23 additions & 9 deletions pkg/config/profile_credentials.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@ package config

import "github.com/hookdeck/hookdeck-cli/pkg/hookdeck"

// resolveProduct returns the product to store. It prefers the current
// team_product field and falls back to the pre-2026-09-01 team_mode, so a
// response missing the new field leaves the profile usable rather than blank:
// an empty product makes IsGatewayProject false and fails every gateway command.
func resolveProduct(product, legacyMode string) string {
if product != "" {
return product
}
return ModeToProduct(legacyMode)
}

// ApplyValidateAPIKeyResponse updates project fields from GET /cli-auth/validate.
// When clearGuestURL is true, GuestURL is cleared (e.g. hookdeck login re-verify).
// When false, GuestURL is left unchanged (e.g. gateway PreRun resolving type only).
Expand All @@ -10,9 +21,10 @@ func (p *Profile) ApplyValidateAPIKeyResponse(resp *hookdeck.ValidateAPIKeyRespo
return
}
p.ProjectId = resp.ProjectID
p.ProjectProduct = resp.ProjectProduct
p.ProjectMode = ProductToLegacyMode(resp.ProjectProduct)
p.ProjectType = ProductToProjectType(resp.ProjectProduct)
product := resolveProduct(resp.ProjectProduct, resp.ProjectMode)
p.ProjectProduct = product
p.ProjectMode = ProductToLegacyMode(product)
p.ProjectType = ProductToProjectType(product)
if clearGuestURL {
p.GuestURL = ""
}
Expand All @@ -26,18 +38,20 @@ func (p *Profile) ApplyPollAPIKeyResponse(resp *hookdeck.PollAPIKeyResponse, gue
}
p.APIKey = resp.APIKey
p.ProjectId = resp.ProjectID
p.ProjectProduct = resp.ProjectProduct
p.ProjectMode = ProductToLegacyMode(resp.ProjectProduct)
p.ProjectType = ProductToProjectType(resp.ProjectProduct)
product := resolveProduct(resp.ProjectProduct, resp.ProjectMode)
p.ProjectProduct = product
p.ProjectMode = ProductToLegacyMode(product)
p.ProjectType = ProductToProjectType(product)
p.GuestURL = guestURL
}

// ApplyCIClient applies credentials from hookdeck login --ci.
func (p *Profile) ApplyCIClient(ci hookdeck.CIClient) {
p.APIKey = ci.APIKey
p.ProjectId = ci.ProjectID
p.ProjectProduct = ci.ProjectProduct
p.ProjectMode = ProductToLegacyMode(ci.ProjectProduct)
p.ProjectType = ProductToProjectType(ci.ProjectProduct)
product := resolveProduct(ci.ProjectProduct, ci.ProjectMode)
p.ProjectProduct = product
p.ProjectMode = ProductToLegacyMode(product)
p.ProjectType = ProductToProjectType(product)
p.GuestURL = ""
}
Loading
Loading