From 41253c76e7866352787b276c7e88912df6142135 Mon Sep 17 00:00:00 2001 From: Rob Kiefer Date: Mon, 22 Jun 2026 14:50:43 -0400 Subject: [PATCH 1/6] Add metrics commands as experimental These commands can be used to get either summaries (default) of metrics, or the full series of data points. We introduce a TIGER_EXPERIMENTAL env var to enable this since the REST endpoint is currently marked as preview. After some testing, we can graduate this. --- CLAUDE.md | 27 ++ internal/tiger/api/client.go | 285 ++++++++++++++++++++ internal/tiger/api/mocks/mock_client.go | 120 +++++++++ internal/tiger/api/types.go | 65 ++++- internal/tiger/cmd/root.go | 12 +- internal/tiger/cmd/service.go | 335 +++++++++++++++++++++++- internal/tiger/mcp/server.go | 15 +- internal/tiger/mcp/server_test.go | 2 +- internal/tiger/mcp/service_tools.go | 291 +++++++++++++++++++- internal/tiger/util/metrics.go | 100 +++++++ openapi.yaml | 169 ++++++++++++ 11 files changed, 1398 insertions(+), 23 deletions(-) create mode 100644 internal/tiger/util/metrics.go diff --git a/CLAUDE.md b/CLAUDE.md index d63d8c51..399800a7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -309,6 +309,33 @@ All configuration options also have corresponding `TIGER_` environment variables For a complete list of global command-line flags, see `internal/tiger/cmd/root.go`. Note that not all config options have corresponding global flags, and not all global flags correspond to config options. +### Experimental Feature Gating + +Some surfaces (currently `tiger service metrics …` CLI commands and the +`service_metrics_*` MCP tools) call gateway endpoints marked `x-preview: true` +in `openapi.yaml` — their request/response shape is still in flux. Upstream +(`savannah-gateway/internal/rest/openapi.yaml`) uses the same marker; the +Stainless SDK pipeline drops `x-preview` operations entirely, but +oapi-codegen ignores the extension, so tiger-cli's generated v1 client +includes them. We gate access at registration time, behind an +intentionally-undocumented env var — `TIGER_EXPERIMENTAL` (default `false`). + +**This is env-var only** — deliberately not a config-file key, not a flag, and +not surfaced by `tiger config show`. It mirrors ghost's `GHOST_EXPERIMENTAL` +pattern: `strconv.ParseBool(os.Getenv("TIGER_EXPERIMENTAL"))` is read once at +build time in `buildRootCmd` (CLI) and once at `NewServer` (MCP), and +threaded through to the subtree/tool registration sites as a plain bool. + +- CLI: `buildServiceCmd(experimental bool)` guards `cmd.AddCommand(buildServiceMetricsCmd())` with `if experimental { … }`. When the env var is unset, the `metrics` subtree isn't added to the command tree at all — the command literally does not exist (no help entry, no tab completion, `unknown command` error like any typo). +- MCP: `registerServiceTools(readOnly, experimental bool)` guards the metrics tool `addTool` calls the same way, so the tools aren't advertised to MCP clients when the env var is off. Restart the MCP server after toggling. + +**Do not mention `TIGER_EXPERIMENTAL` in user-facing docs, command help, spec +files, or error messages.** When a feature graduates, remove the `x-preview: +true` marker upstream, delete the `if experimental { … }` wrapper (both +CLI and MCP), and drop the `experimental bool` parameter from the affected +builder. The call sites already use the normal `cfg.Client` (v1) — no client +wiring needs to change. + ### MCP Server Architecture The Tiger MCP server provides AI assistants with programmatic access to Tiger resources through the Model Context Protocol (MCP). diff --git a/internal/tiger/api/client.go b/internal/tiger/api/client.go index 7b13cf95..09cd00cc 100644 --- a/internal/tiger/api/client.go +++ b/internal/tiger/api/client.go @@ -148,6 +148,14 @@ type ClientInterface interface { // GetServiceLogs request GetServiceLogs(ctx context.Context, projectId ProjectId, serviceId ServiceId, params *GetServiceLogsParams, reqEditors ...RequestEditorFn) (*http.Response, error) + // GetServiceMetricsAvailableSeries request + GetServiceMetricsAvailableSeries(ctx context.Context, projectId ProjectId, serviceId ServiceId, reqEditors ...RequestEditorFn) (*http.Response, error) + + // GetServiceMetricsSeriesWithBody request with any body + GetServiceMetricsSeriesWithBody(ctx context.Context, projectId ProjectId, serviceId ServiceId, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + GetServiceMetricsSeries(ctx context.Context, projectId ProjectId, serviceId ServiceId, body GetServiceMetricsSeriesJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + // GetReplicaSets request GetReplicaSets(ctx context.Context, projectId ProjectId, serviceId ServiceId, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -499,6 +507,42 @@ func (c *Client) GetServiceLogs(ctx context.Context, projectId ProjectId, servic return c.Client.Do(req) } +func (c *Client) GetServiceMetricsAvailableSeries(ctx context.Context, projectId ProjectId, serviceId ServiceId, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetServiceMetricsAvailableSeriesRequest(c.Server, projectId, serviceId) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) GetServiceMetricsSeriesWithBody(ctx context.Context, projectId ProjectId, serviceId ServiceId, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetServiceMetricsSeriesRequestWithBody(c.Server, projectId, serviceId, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) GetServiceMetricsSeries(ctx context.Context, projectId ProjectId, serviceId ServiceId, body GetServiceMetricsSeriesJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetServiceMetricsSeriesRequest(c.Server, projectId, serviceId, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + func (c *Client) GetReplicaSets(ctx context.Context, projectId ProjectId, serviceId ServiceId, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewGetReplicaSetsRequest(c.Server, projectId, serviceId) if err != nil { @@ -1591,6 +1635,101 @@ func NewGetServiceLogsRequest(server string, projectId ProjectId, serviceId Serv return req, nil } +// NewGetServiceMetricsAvailableSeriesRequest generates requests for GetServiceMetricsAvailableSeries +func NewGetServiceMetricsAvailableSeriesRequest(server string, projectId ProjectId, serviceId ServiceId) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "project_id", runtime.ParamLocationPath, projectId) + if err != nil { + return nil, err + } + + var pathParam1 string + + pathParam1, err = runtime.StyleParamWithLocation("simple", false, "service_id", runtime.ParamLocationPath, serviceId) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/projects/%s/services/%s/metrics/available-series", pathParam0, pathParam1) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewGetServiceMetricsSeriesRequest calls the generic GetServiceMetricsSeries builder with application/json body +func NewGetServiceMetricsSeriesRequest(server string, projectId ProjectId, serviceId ServiceId, body GetServiceMetricsSeriesJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewGetServiceMetricsSeriesRequestWithBody(server, projectId, serviceId, "application/json", bodyReader) +} + +// NewGetServiceMetricsSeriesRequestWithBody generates requests for GetServiceMetricsSeries with any type of body +func NewGetServiceMetricsSeriesRequestWithBody(server string, projectId ProjectId, serviceId ServiceId, contentType string, body io.Reader) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "project_id", runtime.ParamLocationPath, projectId) + if err != nil { + return nil, err + } + + var pathParam1 string + + pathParam1, err = runtime.StyleParamWithLocation("simple", false, "service_id", runtime.ParamLocationPath, serviceId) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/projects/%s/services/%s/metrics/series", pathParam0, pathParam1) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + // NewGetReplicaSetsRequest generates requests for GetReplicaSets func NewGetReplicaSetsRequest(server string, projectId ProjectId, serviceId ServiceId) (*http.Request, error) { var err error @@ -2760,6 +2899,14 @@ type ClientWithResponsesInterface interface { // GetServiceLogsWithResponse request GetServiceLogsWithResponse(ctx context.Context, projectId ProjectId, serviceId ServiceId, params *GetServiceLogsParams, reqEditors ...RequestEditorFn) (*GetServiceLogsResponse, error) + // GetServiceMetricsAvailableSeriesWithResponse request + GetServiceMetricsAvailableSeriesWithResponse(ctx context.Context, projectId ProjectId, serviceId ServiceId, reqEditors ...RequestEditorFn) (*GetServiceMetricsAvailableSeriesResponse, error) + + // GetServiceMetricsSeriesWithBodyWithResponse request with any body + GetServiceMetricsSeriesWithBodyWithResponse(ctx context.Context, projectId ProjectId, serviceId ServiceId, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*GetServiceMetricsSeriesResponse, error) + + GetServiceMetricsSeriesWithResponse(ctx context.Context, projectId ProjectId, serviceId ServiceId, body GetServiceMetricsSeriesJSONRequestBody, reqEditors ...RequestEditorFn) (*GetServiceMetricsSeriesResponse, error) + // GetReplicaSetsWithResponse request GetReplicaSetsWithResponse(ctx context.Context, projectId ProjectId, serviceId ServiceId, reqEditors ...RequestEditorFn) (*GetReplicaSetsResponse, error) @@ -3190,6 +3337,52 @@ func (r GetServiceLogsResponse) StatusCode() int { return 0 } +type GetServiceMetricsAvailableSeriesResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *[]string + JSON4XX *ClientError +} + +// Status returns HTTPResponse.Status +func (r GetServiceMetricsAvailableSeriesResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetServiceMetricsAvailableSeriesResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type GetServiceMetricsSeriesResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *[]MetricSeries + JSON4XX *ClientError +} + +// Status returns HTTPResponse.Status +func (r GetServiceMetricsSeriesResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetServiceMetricsSeriesResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + type GetReplicaSetsResponse struct { Body []byte HTTPResponse *http.Response @@ -3882,6 +4075,32 @@ func (c *ClientWithResponses) GetServiceLogsWithResponse(ctx context.Context, pr return ParseGetServiceLogsResponse(rsp) } +// GetServiceMetricsAvailableSeriesWithResponse request returning *GetServiceMetricsAvailableSeriesResponse +func (c *ClientWithResponses) GetServiceMetricsAvailableSeriesWithResponse(ctx context.Context, projectId ProjectId, serviceId ServiceId, reqEditors ...RequestEditorFn) (*GetServiceMetricsAvailableSeriesResponse, error) { + rsp, err := c.GetServiceMetricsAvailableSeries(ctx, projectId, serviceId, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetServiceMetricsAvailableSeriesResponse(rsp) +} + +// GetServiceMetricsSeriesWithBodyWithResponse request with arbitrary body returning *GetServiceMetricsSeriesResponse +func (c *ClientWithResponses) GetServiceMetricsSeriesWithBodyWithResponse(ctx context.Context, projectId ProjectId, serviceId ServiceId, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*GetServiceMetricsSeriesResponse, error) { + rsp, err := c.GetServiceMetricsSeriesWithBody(ctx, projectId, serviceId, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetServiceMetricsSeriesResponse(rsp) +} + +func (c *ClientWithResponses) GetServiceMetricsSeriesWithResponse(ctx context.Context, projectId ProjectId, serviceId ServiceId, body GetServiceMetricsSeriesJSONRequestBody, reqEditors ...RequestEditorFn) (*GetServiceMetricsSeriesResponse, error) { + rsp, err := c.GetServiceMetricsSeries(ctx, projectId, serviceId, body, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetServiceMetricsSeriesResponse(rsp) +} + // GetReplicaSetsWithResponse request returning *GetReplicaSetsResponse func (c *ClientWithResponses) GetReplicaSetsWithResponse(ctx context.Context, projectId ProjectId, serviceId ServiceId, reqEditors ...RequestEditorFn) (*GetReplicaSetsResponse, error) { rsp, err := c.GetReplicaSets(ctx, projectId, serviceId, reqEditors...) @@ -4641,6 +4860,72 @@ func ParseGetServiceLogsResponse(rsp *http.Response) (*GetServiceLogsResponse, e return response, nil } +// ParseGetServiceMetricsAvailableSeriesResponse parses an HTTP response from a GetServiceMetricsAvailableSeriesWithResponse call +func ParseGetServiceMetricsAvailableSeriesResponse(rsp *http.Response) (*GetServiceMetricsAvailableSeriesResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetServiceMetricsAvailableSeriesResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest []string + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode/100 == 4: + var dest ClientError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON4XX = &dest + + } + + return response, nil +} + +// ParseGetServiceMetricsSeriesResponse parses an HTTP response from a GetServiceMetricsSeriesWithResponse call +func ParseGetServiceMetricsSeriesResponse(rsp *http.Response) (*GetServiceMetricsSeriesResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetServiceMetricsSeriesResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest []MetricSeries + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode/100 == 4: + var dest ClientError + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON4XX = &dest + + } + + return response, nil +} + // ParseGetReplicaSetsResponse parses an HTTP response from a GetReplicaSetsWithResponse call func ParseGetReplicaSetsResponse(rsp *http.Response) (*GetReplicaSetsResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) diff --git a/internal/tiger/api/mocks/mock_client.go b/internal/tiger/api/mocks/mock_client.go index d8873f36..89387258 100644 --- a/internal/tiger/api/mocks/mock_client.go +++ b/internal/tiger/api/mocks/mock_client.go @@ -622,6 +622,66 @@ func (mr *MockClientInterfaceMockRecorder) GetServiceLogs(ctx, projectId, servic return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetServiceLogs", reflect.TypeOf((*MockClientInterface)(nil).GetServiceLogs), varargs...) } +// GetServiceMetricsAvailableSeries mocks base method. +func (m *MockClientInterface) GetServiceMetricsAvailableSeries(ctx context.Context, projectId api.ProjectId, serviceId api.ServiceId, reqEditors ...api.RequestEditorFn) (*http.Response, error) { + m.ctrl.T.Helper() + varargs := []any{ctx, projectId, serviceId} + for _, a := range reqEditors { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "GetServiceMetricsAvailableSeries", varargs...) + ret0, _ := ret[0].(*http.Response) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetServiceMetricsAvailableSeries indicates an expected call of GetServiceMetricsAvailableSeries. +func (mr *MockClientInterfaceMockRecorder) GetServiceMetricsAvailableSeries(ctx, projectId, serviceId any, reqEditors ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx, projectId, serviceId}, reqEditors...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetServiceMetricsAvailableSeries", reflect.TypeOf((*MockClientInterface)(nil).GetServiceMetricsAvailableSeries), varargs...) +} + +// GetServiceMetricsSeries mocks base method. +func (m *MockClientInterface) GetServiceMetricsSeries(ctx context.Context, projectId api.ProjectId, serviceId api.ServiceId, body api.GetServiceMetricsSeriesJSONRequestBody, reqEditors ...api.RequestEditorFn) (*http.Response, error) { + m.ctrl.T.Helper() + varargs := []any{ctx, projectId, serviceId, body} + for _, a := range reqEditors { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "GetServiceMetricsSeries", varargs...) + ret0, _ := ret[0].(*http.Response) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetServiceMetricsSeries indicates an expected call of GetServiceMetricsSeries. +func (mr *MockClientInterfaceMockRecorder) GetServiceMetricsSeries(ctx, projectId, serviceId, body any, reqEditors ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx, projectId, serviceId, body}, reqEditors...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetServiceMetricsSeries", reflect.TypeOf((*MockClientInterface)(nil).GetServiceMetricsSeries), varargs...) +} + +// GetServiceMetricsSeriesWithBody mocks base method. +func (m *MockClientInterface) GetServiceMetricsSeriesWithBody(ctx context.Context, projectId api.ProjectId, serviceId api.ServiceId, contentType string, body io.Reader, reqEditors ...api.RequestEditorFn) (*http.Response, error) { + m.ctrl.T.Helper() + varargs := []any{ctx, projectId, serviceId, contentType, body} + for _, a := range reqEditors { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "GetServiceMetricsSeriesWithBody", varargs...) + ret0, _ := ret[0].(*http.Response) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetServiceMetricsSeriesWithBody indicates an expected call of GetServiceMetricsSeriesWithBody. +func (mr *MockClientInterfaceMockRecorder) GetServiceMetricsSeriesWithBody(ctx, projectId, serviceId, contentType, body any, reqEditors ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx, projectId, serviceId, contentType, body}, reqEditors...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetServiceMetricsSeriesWithBody", reflect.TypeOf((*MockClientInterface)(nil).GetServiceMetricsSeriesWithBody), varargs...) +} + // GetServices mocks base method. func (m *MockClientInterface) GetServices(ctx context.Context, projectId api.ProjectId, reqEditors ...api.RequestEditorFn) (*http.Response, error) { m.ctrl.T.Helper() @@ -1706,6 +1766,66 @@ func (mr *MockClientWithResponsesInterfaceMockRecorder) GetServiceLogsWithRespon return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetServiceLogsWithResponse", reflect.TypeOf((*MockClientWithResponsesInterface)(nil).GetServiceLogsWithResponse), varargs...) } +// GetServiceMetricsAvailableSeriesWithResponse mocks base method. +func (m *MockClientWithResponsesInterface) GetServiceMetricsAvailableSeriesWithResponse(ctx context.Context, projectId api.ProjectId, serviceId api.ServiceId, reqEditors ...api.RequestEditorFn) (*api.GetServiceMetricsAvailableSeriesResponse, error) { + m.ctrl.T.Helper() + varargs := []any{ctx, projectId, serviceId} + for _, a := range reqEditors { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "GetServiceMetricsAvailableSeriesWithResponse", varargs...) + ret0, _ := ret[0].(*api.GetServiceMetricsAvailableSeriesResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetServiceMetricsAvailableSeriesWithResponse indicates an expected call of GetServiceMetricsAvailableSeriesWithResponse. +func (mr *MockClientWithResponsesInterfaceMockRecorder) GetServiceMetricsAvailableSeriesWithResponse(ctx, projectId, serviceId any, reqEditors ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx, projectId, serviceId}, reqEditors...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetServiceMetricsAvailableSeriesWithResponse", reflect.TypeOf((*MockClientWithResponsesInterface)(nil).GetServiceMetricsAvailableSeriesWithResponse), varargs...) +} + +// GetServiceMetricsSeriesWithBodyWithResponse mocks base method. +func (m *MockClientWithResponsesInterface) GetServiceMetricsSeriesWithBodyWithResponse(ctx context.Context, projectId api.ProjectId, serviceId api.ServiceId, contentType string, body io.Reader, reqEditors ...api.RequestEditorFn) (*api.GetServiceMetricsSeriesResponse, error) { + m.ctrl.T.Helper() + varargs := []any{ctx, projectId, serviceId, contentType, body} + for _, a := range reqEditors { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "GetServiceMetricsSeriesWithBodyWithResponse", varargs...) + ret0, _ := ret[0].(*api.GetServiceMetricsSeriesResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetServiceMetricsSeriesWithBodyWithResponse indicates an expected call of GetServiceMetricsSeriesWithBodyWithResponse. +func (mr *MockClientWithResponsesInterfaceMockRecorder) GetServiceMetricsSeriesWithBodyWithResponse(ctx, projectId, serviceId, contentType, body any, reqEditors ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx, projectId, serviceId, contentType, body}, reqEditors...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetServiceMetricsSeriesWithBodyWithResponse", reflect.TypeOf((*MockClientWithResponsesInterface)(nil).GetServiceMetricsSeriesWithBodyWithResponse), varargs...) +} + +// GetServiceMetricsSeriesWithResponse mocks base method. +func (m *MockClientWithResponsesInterface) GetServiceMetricsSeriesWithResponse(ctx context.Context, projectId api.ProjectId, serviceId api.ServiceId, body api.GetServiceMetricsSeriesJSONRequestBody, reqEditors ...api.RequestEditorFn) (*api.GetServiceMetricsSeriesResponse, error) { + m.ctrl.T.Helper() + varargs := []any{ctx, projectId, serviceId, body} + for _, a := range reqEditors { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "GetServiceMetricsSeriesWithResponse", varargs...) + ret0, _ := ret[0].(*api.GetServiceMetricsSeriesResponse) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetServiceMetricsSeriesWithResponse indicates an expected call of GetServiceMetricsSeriesWithResponse. +func (mr *MockClientWithResponsesInterfaceMockRecorder) GetServiceMetricsSeriesWithResponse(ctx, projectId, serviceId, body any, reqEditors ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx, projectId, serviceId, body}, reqEditors...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetServiceMetricsSeriesWithResponse", reflect.TypeOf((*MockClientWithResponsesInterface)(nil).GetServiceMetricsSeriesWithResponse), varargs...) +} + // GetServiceWithResponse mocks base method. func (m *MockClientWithResponsesInterface) GetServiceWithResponse(ctx context.Context, projectId api.ProjectId, serviceId api.ServiceId, reqEditors ...api.RequestEditorFn) (*api.GetServiceResponse, error) { m.ctrl.T.Helper() diff --git a/internal/tiger/api/types.go b/internal/tiger/api/types.go index 05321883..0c6b5df6 100644 --- a/internal/tiger/api/types.go +++ b/internal/tiger/api/types.go @@ -110,7 +110,7 @@ type AuthInfo struct { PublicKey string `json:"public_key"` } `json:"apiKey,omitempty"` - // Oauth OAuth user details. Present for OAuth (PKCE) user callers. + // Oauth OAuth user details. Present for OAuth user callers. Oauth *struct { User struct { // Email The user's email @@ -125,12 +125,12 @@ type AuthInfo struct { } `json:"oauth,omitempty"` // Type Discriminator for the credential shape. `apiKey` for PAT callers; - // `oauth` for OAuth (PKCE) user callers. + // `oauth` for OAuth user callers. Type AuthInfoType `json:"type"` } // AuthInfoType Discriminator for the credential shape. `apiKey` for PAT callers; -// `oauth` for OAuth (PKCE) user callers. +// `oauth` for OAuth user callers. type AuthInfoType string // ConnectionPooler defines model for ConnectionPooler. @@ -209,6 +209,62 @@ type HAReplica struct { SyncReplicaCount *int `json:"sync_replica_count,omitempty"` } +// MetricDataPoint A single time/value pair within a MetricSeries. +type MetricDataPoint struct { + // Time The bucket start timestamp. + Time time.Time `json:"time"` + + // Value The aggregated value at this bucket. Buckets without collected + // data are omitted from the parent series' `data` array; the API + // does not currently emit null values. + Value *float64 `json:"value,omitempty"` +} + +// MetricLabelFilter A single key/value label match applied to a metric series query. +type MetricLabelFilter struct { + // Key Label key to match against, e.g. `role` or `job_id`. + Key string `json:"key"` + + // Value Label value to match. Case sensitivity follows the underlying metric's storage convention. + Value string `json:"value"` +} + +// MetricSeries One labeled time series — the label set plus its per-bucket data points. +type MetricSeries struct { + // Data Per-bucket data points for this label set. + Data []MetricDataPoint `json:"data"` + + // Labels Label set identifying this series. The key set depends on the + // metric (e.g. `role`, `ordinal`). + Labels map[string]string `json:"labels"` +} + +// MetricsSeriesRequest Parameters for a single getServiceMetricsSeries query. Sent as a JSON +// body so callers can pass an unbounded filter set without query-string +// length pressure. +type MetricsSeriesRequest struct { + // BucketSeconds Aggregation bucket size in seconds. Defaults to 60 for windows ≤1 + // hour, 3600 for longer windows. Clamped to [1, window_seconds]. + BucketSeconds *int `json:"bucket_seconds,omitempty"` + + // Filters Label filters applied to the series query. Recognized label names + // depend on the metric. The most common is `role` (value `primary` or + // `replica`). When `role` is omitted, some metrics default to primary + // and others return data for all roles (one series per role); pass an + // explicit role for deterministic results. Role label values in the + // response are lowercased. + Filters *[]MetricLabelFilter `json:"filters,omitempty"` + + // From Start of the time window (RFC3339; nanosecond precision accepted). + From time.Time `json:"from"` + + // Name Metric series name. Use getServiceMetricsAvailableSeries to discover valid values. + Name string `json:"name"` + + // To End of the time window (RFC3339; nanosecond precision accepted). + To time.Time `json:"to"` +} + // Peering defines model for Peering. type Peering struct { ErrorMessage *string `json:"error_message,omitempty"` @@ -534,6 +590,9 @@ type DetachServiceFromVPCJSONRequestBody = ServiceVPCInput // ForkServiceJSONRequestBody defines body for ForkService for application/json ContentType. type ForkServiceJSONRequestBody = ForkServiceCreate +// GetServiceMetricsSeriesJSONRequestBody defines body for GetServiceMetricsSeries for application/json ContentType. +type GetServiceMetricsSeriesJSONRequestBody = MetricsSeriesRequest + // CreateReplicaSetJSONRequestBody defines body for CreateReplicaSet for application/json ContentType. type CreateReplicaSetJSONRequestBody = ReadReplicaSetCreate diff --git a/internal/tiger/cmd/root.go b/internal/tiger/cmd/root.go index 1f3fbe24..51c1e2bb 100644 --- a/internal/tiger/cmd/root.go +++ b/internal/tiger/cmd/root.go @@ -4,6 +4,8 @@ import ( "context" "errors" "fmt" + "os" + "strconv" "strings" "time" @@ -21,6 +23,14 @@ import ( ) func buildRootCmd(ctx context.Context) (*cobra.Command, error) { + // TIGER_EXPERIMENTAL toggles preview-stage commands and MCP tools. Read at + // build time so ungated subtrees are never added to the command tree — the + // command literally does not exist when the env var is unset/false, so it + // won't appear in help, tab-completion, or `unknown command` disambiguation. + // This is intentionally undocumented (env var only, no config key); see + // CLAUDE.md's "Experimental Feature Gating" section. + experimental, _ := strconv.ParseBool(os.Getenv("TIGER_EXPERIMENTAL")) + var configDir string var debug bool var serviceID string @@ -145,7 +155,7 @@ tiger auth login cmd.AddCommand(buildUpgradeCmd()) cmd.AddCommand(buildConfigCmd()) cmd.AddCommand(buildAuthCmd()) - cmd.AddCommand(buildServiceCmd()) + cmd.AddCommand(buildServiceCmd(experimental)) cmd.AddCommand(buildDbCmd()) cmd.AddCommand(buildMCPCmd()) diff --git a/internal/tiger/cmd/service.go b/internal/tiger/cmd/service.go index 892c1301..23a7d26e 100644 --- a/internal/tiger/cmd/service.go +++ b/internal/tiger/cmd/service.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "os" + "sort" "strings" "time" @@ -21,8 +22,11 @@ import ( "github.com/timescale/tiger-cli/internal/tiger/util" ) -// buildServiceCmd creates the main service command with all subcommands -func buildServiceCmd() *cobra.Command { +// buildServiceCmd creates the main service command with all subcommands. +// experimental gates preview-stage subcommands (currently `metrics`); when +// false, those subtrees are not added to the tree at all — matching ghost's +// TIGER_EXPERIMENTAL pattern. See CLAUDE.md's "Experimental Feature Gating". +func buildServiceCmd(experimental bool) *cobra.Command { cmd := &cobra.Command{ Use: "service", Aliases: []string{"services", "svc"}, @@ -42,6 +46,11 @@ func buildServiceCmd() *cobra.Command { cmd.AddCommand(buildServiceResizeCmd()) cmd.AddCommand(buildServiceLogsCmd()) + // Experimental commands, unregistered until the preview graduates. + if experimental { + cmd.AddCommand(buildServiceMetricsCmd()) + } + return cmd } @@ -1727,6 +1736,328 @@ func listServices(cmd *cobra.Command) ([]api.Service, error) { return *resp.JSON200, nil } +// buildServiceMetricsCmd creates the metrics subcommand group. The metrics +// surface targets gateway endpoints marked `x-preview: true` in the OpenAPI +// spec — their request/response contract is still in flux. Registration is +// gated on TIGER_EXPERIMENTAL in buildServiceCmd, so this builder is only +// called when the env var is set; the tree doesn't include `metrics` at all +// otherwise. +func buildServiceMetricsCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "metrics", + Short: "View service metrics", + Long: `Commands for querying time-series metrics for a Tiger Cloud service.`, + } + cmd.AddCommand(buildServiceMetricsAvailableSeriesCmd()) + cmd.AddCommand(buildServiceMetricsSeriesCmd()) + return cmd +} + +// buildServiceMetricsAvailableSeriesCmd lists the metric series available for a service +func buildServiceMetricsAvailableSeriesCmd() *cobra.Command { + var output string + + cmd := &cobra.Command{ + Use: "available-series [service-id]", + Short: "List available metric series", + Long: `List the names of all metric series available for a service.`, + Args: cobra.MaximumNArgs(1), + PreRunE: bindFlags("output"), + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := common.LoadConfig(cmd.Context()) + if err != nil { + cmd.SilenceUsage = true + return err + } + + serviceID, err := getServiceID(cfg.Config, args) + if err != nil { + return err + } + + cmd.SilenceUsage = true + + ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second) + defer cancel() + + resp, err := cfg.Client.GetServiceMetricsAvailableSeriesWithResponse(ctx, cfg.ProjectID, serviceID) + if err != nil { + return fmt.Errorf("failed to list metric series: %w", err) + } + + if resp.StatusCode() != http.StatusOK { + return common.ExitWithErrorFromStatusCode(resp.StatusCode(), resp.JSON4XX) + } + + if resp.JSON200 == nil { + return fmt.Errorf("empty response from API") + } + + series := *resp.JSON200 + out := cmd.OutOrStdout() + + switch strings.ToLower(cfg.Output) { + case "json": + return util.SerializeToJSON(out, series) + case "yaml": + return util.SerializeToYAML(out, series) + default: + for _, s := range series { + fmt.Fprintln(out, s) + } + } + return nil + }, + } + + cmd.Flags().VarP((*outputFlag)(&output), "output", "o", "Output format (json, yaml, table)") + return cmd +} + +// buildServiceMetricsSeriesCmd fetches time-series data for a named metric +func buildServiceMetricsSeriesCmd() *cobra.Command { + var metric string + var from string + var to string + var role string + var filters []string + var bucketSeconds int + var mode string + var output string + + cmd := &cobra.Command{ + Use: "series [service-id]", + Short: "Get metric series data", + Long: `Get time-series data for a specific metric. + +Use 'tiger service metrics available-series' to discover valid metric names. + +By default, returns a per-series summary (min/max/avg/p50/p95) over the time +window. Use --mode=full to get individual data points. + +Each labeled series (e.g. one per replica) is returned independently; the +output shows one summary block per series, identified by its label set. + +Examples: + # Summarize CPU usage for the last hour + tiger service metrics series --metric timescale_cloud_system_cpu_usage_millicores \ + --from 2026-05-13T00:00:00Z --to 2026-05-13T01:00:00Z + + # Get all memory data points as JSON + tiger service metrics series --metric timescale_cloud_system_memory_usage_bytes \ + --from 2026-05-13T00:00:00Z --to 2026-05-13T01:00:00Z \ + --mode full --output json + + # Summarize only the primary instance + tiger service metrics series --metric timescale_cloud_system_cpu_usage_millicores \ + --from 2026-05-13T00:00:00Z --to 2026-05-13T01:00:00Z --role PRIMARY + + # Filter by an arbitrary label + tiger service metrics series --metric some_metric_name \ + --from 2026-05-13T00:00:00Z --to 2026-05-13T01:00:00Z \ + --filter ordinal=0`, + Args: cobra.MaximumNArgs(1), + PreRunE: bindFlags("output"), + RunE: func(cmd *cobra.Command, args []string) error { + if metric == "" { + return fmt.Errorf("--metric is required") + } + if from == "" { + return fmt.Errorf("--from is required") + } + if to == "" { + return fmt.Errorf("--to is required") + } + fromTime, err := time.Parse(time.RFC3339, from) + if err != nil { + return fmt.Errorf("--from must be RFC3339 (e.g., 2026-05-13T00:00:00Z): %w", err) + } + toTime, err := time.Parse(time.RFC3339, to) + if err != nil { + return fmt.Errorf("--to must be RFC3339 (e.g., 2026-05-13T01:00:00Z): %w", err) + } + + labelFilters, err := parseMetricFilters(role, filters) + if err != nil { + return err + } + + cfg, err := common.LoadConfig(cmd.Context()) + if err != nil { + cmd.SilenceUsage = true + return err + } + + serviceID, err := getServiceID(cfg.Config, args) + if err != nil { + return err + } + + cmd.SilenceUsage = true + + body := api.MetricsSeriesRequest{ + Name: metric, + From: fromTime, + To: toTime, + } + if bucketSeconds > 0 { + bs := bucketSeconds + body.BucketSeconds = &bs + } else { + bs := util.DefaultBucketSeconds(fromTime, toTime) + body.BucketSeconds = &bs + } + if len(labelFilters) > 0 { + body.Filters = &labelFilters + } + + ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second) + defer cancel() + + resp, err := cfg.Client.GetServiceMetricsSeriesWithResponse(ctx, cfg.ProjectID, serviceID, body) + if err != nil { + return fmt.Errorf("failed to fetch metric series: %w", err) + } + + if resp.StatusCode() != http.StatusOK { + return common.ExitWithErrorFromStatusCode(resp.StatusCode(), resp.JSON4XX) + } + + if resp.JSON200 == nil { + return fmt.Errorf("empty response from API") + } + + series := *resp.JSON200 + out := cmd.OutOrStdout() + + if strings.ToLower(mode) == "full" { + return renderMetricsFull(out, cfg.Output, series) + } + return renderMetricsSummary(out, cfg.Output, metric, fromTime, toTime, series) + }, + } + + cmd.Flags().StringVar(&metric, "metric", "", "Metric series name (required)") + cmd.Flags().StringVar(&from, "from", "", "Start of the time window (RFC3339, required)") + cmd.Flags().StringVar(&to, "to", "", "End of the time window (RFC3339, required)") + cmd.Flags().StringVar(&role, "role", "", "Filter to a specific instance role (PRIMARY or REPLICA)") + cmd.Flags().StringSliceVar(&filters, "filter", nil, "Arbitrary label filter as name=value (repeatable)") + cmd.Flags().IntVar(&bucketSeconds, "bucket-seconds", 0, "Aggregation bucket size in seconds (default: auto)") + cmd.Flags().StringVar(&mode, "mode", "summary", "Output mode: summary (default) or full") + cmd.Flags().VarP((*outputFlag)(&output), "output", "o", "Output format (json, yaml, table)") + + return cmd +} + +// parseMetricFilters merges --role and --filter into the preview label filter +// list. Server-side label values are lowercased on response, so we lowercase +// role values here too for symmetry. +func parseMetricFilters(role string, filters []string) ([]api.MetricLabelFilter, error) { + var out []api.MetricLabelFilter + if role != "" { + out = append(out, api.MetricLabelFilter{Key: "role", Value: strings.ToLower(role)}) + } + for _, f := range filters { + k, v, ok := strings.Cut(f, "=") + if !ok || k == "" || v == "" { + return nil, fmt.Errorf("--filter must be name=value, got %q", f) + } + out = append(out, api.MetricLabelFilter{Key: k, Value: v}) + } + return out, nil +} + +// labelString renders a label map as Prometheus-style `{name="value",...}` for +// human-friendly output. Keys are sorted for stable display. +func labelString(labels map[string]string) string { + if len(labels) == 0 { + return "{}" + } + keys := make([]string, 0, len(labels)) + for k := range labels { + keys = append(keys, k) + } + sort.Strings(keys) + parts := make([]string, 0, len(keys)) + for _, k := range keys { + parts = append(parts, fmt.Sprintf("%s=%q", k, labels[k])) + } + return "{" + strings.Join(parts, ",") + "}" +} + +func renderMetricsFull(out io.Writer, output string, series []api.MetricSeries) error { + switch strings.ToLower(output) { + case "json": + return util.SerializeToJSON(out, series) + case "yaml": + return util.SerializeToYAML(out, series) + default: + table := tablewriter.NewWriter(out) + table.Header("SERIES", "TIME", "VALUE") + for _, s := range series { + label := labelString(s.Labels) + for _, p := range s.Data { + val := "null" + if p.Value != nil { + val = fmt.Sprintf("%.3f", *p.Value) + } + table.Append(label, p.Time.UTC().Format(time.RFC3339), val) + } + } + return table.Render() + } +} + +func renderMetricsSummary(out io.Writer, output, metric string, from, to time.Time, series []api.MetricSeries) error { + summaries := summarizeSeries(metric, from, to, series) + + switch strings.ToLower(output) { + case "json": + return util.SerializeToJSON(out, summaries) + case "yaml": + return util.SerializeToYAML(out, summaries) + } + + if len(summaries) == 0 { + fmt.Fprintln(out, "No data points returned for the requested time window.") + return nil + } + + for i, s := range summaries { + if i > 0 { + fmt.Fprintln(out) + } + fmt.Fprintf(out, "metric: %s\n", s.Name) + fmt.Fprintf(out, "labels: %s\n", labelString(s.Labels)) + fmt.Fprintf(out, "from: %s\n", s.From.UTC().Format(time.RFC3339)) + fmt.Fprintf(out, "to: %s\n", s.To.UTC().Format(time.RFC3339)) + fmt.Fprintf(out, "count: %d\n", s.Count) + fmt.Fprintf(out, "min: %.3f (at %s)\n", s.Min, s.MinTime.UTC().Format(time.RFC3339)) + fmt.Fprintf(out, "max: %.3f (at %s)\n", s.Max, s.MaxTime.UTC().Format(time.RFC3339)) + fmt.Fprintf(out, "avg: %.3f\n", s.Avg) + fmt.Fprintf(out, "p50: %.3f\n", s.P50) + fmt.Fprintf(out, "p95: %.3f\n", s.P95) + } + return nil +} + +// summarizeSeries collapses each labeled series into its own MetricSummary so +// that aggregates (min/avg/etc.) are computed per replica rather than across +// every role at once. +func summarizeSeries(metric string, from, to time.Time, series []api.MetricSeries) []util.MetricSummary { + summaries := make([]util.MetricSummary, 0, len(series)) + for _, s := range series { + pts := make([]util.MetricPoint, len(s.Data)) + for i, p := range s.Data { + pts[i] = util.MetricPoint{Time: p.Time, Value: p.Value} + } + if summary := util.SummarizeMetrics(metric, s.Labels, pts, from, to); summary != nil { + summaries = append(summaries, *summary) + } + } + return summaries +} + // getServiceID determines the service ID from args or config func getServiceID(cfg *config.Config, args []string) (string, error) { var serviceID string diff --git a/internal/tiger/mcp/server.go b/internal/tiger/mcp/server.go index 18e3e99b..7b970a7e 100644 --- a/internal/tiger/mcp/server.go +++ b/internal/tiger/mcp/server.go @@ -6,7 +6,9 @@ import ( "errors" "fmt" "net/http" + "os" "slices" + "strconv" "time" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -67,10 +69,13 @@ func NewServer(ctx context.Context, cfg *config.Config) (*Server, error) { mcpServer: mcpServer, } - // Register all tools (including proxied docs tools). readOnly is captured - // here and threaded through registration only. + // Register all tools (including proxied docs tools). readOnly and + // experimental are captured here and threaded through registration only. + // experimental follows the ghost pattern — env-var only, undocumented; see + // CLAUDE.md's "Experimental Feature Gating". readOnly := cfg != nil && cfg.ReadOnly - server.registerTools(ctx, readOnly) + experimental, _ := strconv.ParseBool(os.Getenv("TIGER_EXPERIMENTAL")) + server.registerTools(ctx, readOnly, experimental) // Add analytics tracking middleware server.mcpServer.AddReceivingMiddleware(server.analyticsMiddleware) @@ -93,9 +98,9 @@ func (s *Server) HTTPHandler() http.Handler { } // registerTools registers all available MCP tools -func (s *Server) registerTools(ctx context.Context, readOnly bool) { +func (s *Server) registerTools(ctx context.Context, readOnly, experimental bool) { // Service management tools - s.registerServiceTools(readOnly) + s.registerServiceTools(readOnly, experimental) // Database operation tools s.registerDatabaseTools(readOnly) diff --git a/internal/tiger/mcp/server_test.go b/internal/tiger/mcp/server_test.go index f0a7659e..1eea55e4 100644 --- a/internal/tiger/mcp/server_test.go +++ b/internal/tiger/mcp/server_test.go @@ -34,7 +34,7 @@ func registeredToolNames(t *testing.T, readOnly bool) []string { Version: config.Version, }, nil), } - s.registerServiceTools(readOnly) + s.registerServiceTools(readOnly, false) s.registerDatabaseTools(readOnly) clientTransport, serverTransport := mcp.NewInMemoryTransports() diff --git a/internal/tiger/mcp/service_tools.go b/internal/tiger/mcp/service_tools.go index b1a66bd6..c7ab7763 100644 --- a/internal/tiger/mcp/service_tools.go +++ b/internal/tiger/mcp/service_tools.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "net/http" + "strings" "time" "github.com/google/jsonschema-go/jsonschema" @@ -27,16 +28,18 @@ const ( // MCP tool names. Centralized so the read-only gate (see errors.go) and the // tool registrations share a single source of truth. const ( - toolServiceList = "service_list" - toolServiceGet = "service_get" - toolServiceCreate = "service_create" - toolServiceFork = "service_fork" - toolServiceStart = "service_start" - toolServiceStop = "service_stop" - toolServiceResize = "service_resize" - toolServiceUpdatePassword = "service_update_password" - toolServiceLogs = "service_logs" - toolDBExecuteQuery = "db_execute_query" + toolServiceList = "service_list" + toolServiceGet = "service_get" + toolServiceCreate = "service_create" + toolServiceFork = "service_fork" + toolServiceStart = "service_start" + toolServiceStop = "service_stop" + toolServiceResize = "service_resize" + toolServiceUpdatePassword = "service_update_password" + toolServiceLogs = "service_logs" + toolServiceMetricsAvailable = "service_metrics_available" + toolServiceMetricsSeries = "service_metrics_series" + toolDBExecuteQuery = "db_execute_query" ) // Wait timeout for MCP tool operations @@ -417,8 +420,102 @@ func (ServiceLogsOutput) Schema() *jsonschema.Schema { return util.Must(jsonschema.For[ServiceLogsOutput](nil)) } +// ServiceMetricsAvailableInput represents input for service_metrics_available +type ServiceMetricsAvailableInput struct { + ServiceID string `json:"service_id"` +} + +func (ServiceMetricsAvailableInput) Schema() *jsonschema.Schema { + schema := util.Must(jsonschema.For[ServiceMetricsAvailableInput](nil)) + setServiceIDSchemaProperties(schema) + return schema +} + +// ServiceMetricsAvailableOutput represents output for service_metrics_available +type ServiceMetricsAvailableOutput struct { + Series []string `json:"series"` +} + +func (ServiceMetricsAvailableOutput) Schema() *jsonschema.Schema { + return util.Must(jsonschema.For[ServiceMetricsAvailableOutput](nil)) +} + +// MetricLabelFilterInput mirrors api.MetricLabelFilter for the tool schema. +type MetricLabelFilterInput struct { + Key string `json:"key"` + Value string `json:"value"` +} + +// ServiceMetricsSeriesInput represents input for service_metrics_series +type ServiceMetricsSeriesInput struct { + ServiceID string `json:"service_id"` + MetricName string `json:"metric_name"` + From string `json:"from"` + To string `json:"to"` + Role string `json:"role,omitempty"` + Filters []MetricLabelFilterInput `json:"filters,omitempty"` + BucketSeconds int `json:"bucket_seconds,omitempty"` + Mode string `json:"mode,omitempty"` +} + +func (ServiceMetricsSeriesInput) Schema() *jsonschema.Schema { + schema := util.Must(jsonschema.For[ServiceMetricsSeriesInput](nil)) + + setServiceIDSchemaProperties(schema) + + schema.Properties["metric_name"].Description = "Name of the metric series to fetch. Use service_metrics_available to discover valid names." + schema.Properties["metric_name"].Examples = []any{ + "timescale_cloud_system_cpu_usage_millicores", + "timescale_cloud_system_memory_usage_bytes", + "timescale_cloud_system_disk_usage_bytes", + "timescale_cloud_database_qps", + "timescale_cloud_database_num_connections", + } + + schema.Properties["from"].Description = "Start of the time window (RFC3339 format)." + schema.Properties["from"].Examples = []any{"2026-05-13T00:00:00Z", "2026-05-13T09:00:00Z"} + + schema.Properties["to"].Description = "End of the time window (RFC3339 format)." + schema.Properties["to"].Examples = []any{"2026-05-13T01:00:00Z", "2026-05-13T10:00:00Z"} + + schema.Properties["role"].Description = "Convenience filter for the 'role' label. Omit to include all roles. Equivalent to passing {key:\"role\", value:\"primary\"|\"replica\"} via filters." + schema.Properties["role"].Enum = []any{"PRIMARY", "REPLICA"} + + schema.Properties["filters"].Description = "Arbitrary label filters applied to the series query. Recognized label names depend on the metric (e.g. 'role', 'ordinal', 'job_id')." + + schema.Properties["bucket_seconds"].Description = "Aggregation bucket size in seconds. When 0 or omitted, auto-selected: 60s for windows ≤1 hour, 3600s for longer windows." + schema.Properties["bucket_seconds"].Minimum = util.Ptr(1.0) + schema.Properties["bucket_seconds"].Examples = []any{60, 300, 3600} + + schema.Properties["mode"].Description = "Output mode. 'summary' (default) returns aggregated stats (min/max/avg/p50/p95) per labeled series — efficient for triage. 'full' returns the raw labeled series with all individual data points." + schema.Properties["mode"].Enum = []any{"summary", "full"} + schema.Properties["mode"].Default = util.Must(json.Marshal("summary")) + + return schema +} + +// ServiceMetricsSummaryOutput is returned when mode=summary. The endpoint +// returns one MetricSeries per distinct label set (e.g. one per replica), so +// the output is a list of per-series summaries rather than a single rollup. +type ServiceMetricsSummaryOutput struct { + Summaries []util.MetricSummary `json:"summaries"` +} + +func (ServiceMetricsSummaryOutput) Schema() *jsonschema.Schema { + return util.Must(jsonschema.For[ServiceMetricsSummaryOutput](nil)) +} + +// ServiceMetricsFullOutput is returned when mode=full +type ServiceMetricsFullOutput struct { + Series []api.MetricSeries `json:"series"` +} + +func (ServiceMetricsFullOutput) Schema() *jsonschema.Schema { + return util.Must(jsonschema.For[ServiceMetricsFullOutput](nil)) +} + // registerServiceTools registers service management tools with comprehensive schemas and descriptions -func (s *Server) registerServiceTools(readOnly bool) { +func (s *Server) registerServiceTools(readOnly, experimental bool) { // service_list addTool(s, readOnly, &mcp.Tool{ Name: toolServiceList, @@ -590,6 +687,52 @@ Supports filtering by time (via since/until parameters) and node (for services w Title: "Get Service Logs", }, }, s.handleServiceLogs) + + // Metrics tools target gateway endpoints marked `x-preview: true`. They + // are registered only when the experimental gate is on at server startup; + // the user must restart the MCP server after toggling the gate. Handler + // bodies re-check the gate defensively in case config changes mid-session. + if experimental { + // service_metrics_available + addTool(s, readOnly, &mcp.Tool{ + Name: toolServiceMetricsAvailable, + Title: "List Available Metric Series", + Description: "List the names of all metric series available for a service. " + + "Call this first to discover what metrics exist before fetching data with service_metrics_series.", + InputSchema: ServiceMetricsAvailableInput{}.Schema(), + OutputSchema: ServiceMetricsAvailableOutput{}.Schema(), + Annotations: &mcp.ToolAnnotations{ + ReadOnlyHint: true, + OpenWorldHint: util.Ptr(false), + Title: "List Available Metric Series", + }, + }, s.handleServiceMetricsAvailable) + + // service_metrics_series + addTool(s, readOnly, &mcp.Tool{ + Name: toolServiceMetricsSeries, + Title: "Get Metric Series Data", + Description: `Fetch time-series data for a named metric over a specified time window. + +Use service_metrics_available first to discover valid metric names. + +The response groups data points by their label set — a single request may +return multiple labeled series (e.g. one per replica, one per worker ordinal). + +Two modes: +- summary (default): Returns per-series aggregated stats (count, min/max with timestamps, avg, p50, p95). Efficient for triage — minimal token usage. +- full: Returns each labeled series with its full data point list. Use when you need the raw time series. + +Available metrics include: CPU usage/allocation, memory usage/total, disk usage, disk I/O (read/write bytes and ops), queries per second, and active connections.`, + InputSchema: ServiceMetricsSeriesInput{}.Schema(), + OutputSchema: ServiceMetricsSummaryOutput{}.Schema(), + Annotations: &mcp.ToolAnnotations{ + ReadOnlyHint: true, + OpenWorldHint: util.Ptr(false), + Title: "Get Metric Series Data", + }, + }, s.handleServiceMetricsSeries) + } } // handleServiceList handles the service_list MCP tool @@ -1229,3 +1372,129 @@ func (s *Server) handleServiceLogs(ctx context.Context, req *mcp.CallToolRequest return nil, ServiceLogsOutput{Logs: logs}, nil } + +// handleServiceMetricsAvailable handles the service_metrics_available MCP tool +func (s *Server) handleServiceMetricsAvailable(ctx context.Context, req *mcp.CallToolRequest, input ServiceMetricsAvailableInput) (*mcp.CallToolResult, ServiceMetricsAvailableOutput, error) { + cfg, err := common.LoadConfig(ctx) + if err != nil { + return nil, ServiceMetricsAvailableOutput{}, err + } + + logging.Debug("MCP: Listing available metric series", + zap.String("project_id", cfg.ProjectID), + zap.String("service_id", input.ServiceID), + ) + + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + resp, err := cfg.Client.GetServiceMetricsAvailableSeriesWithResponse(ctx, cfg.ProjectID, input.ServiceID) + if err != nil { + return nil, ServiceMetricsAvailableOutput{}, fmt.Errorf("failed to list metric series: %w", err) + } + + if resp.StatusCode() != http.StatusOK { + return nil, ServiceMetricsAvailableOutput{}, resp.JSON4XX + } + + if resp.JSON200 == nil { + return nil, ServiceMetricsAvailableOutput{Series: []string{}}, nil + } + + return nil, ServiceMetricsAvailableOutput{Series: *resp.JSON200}, nil +} + +// handleServiceMetricsSeries handles the service_metrics_series MCP tool +func (s *Server) handleServiceMetricsSeries(ctx context.Context, req *mcp.CallToolRequest, input ServiceMetricsSeriesInput) (*mcp.CallToolResult, any, error) { + cfg, err := common.LoadConfig(ctx) + if err != nil { + return nil, nil, err + } + + logging.Debug("MCP: Fetching metric series", + zap.String("project_id", cfg.ProjectID), + zap.String("service_id", input.ServiceID), + zap.String("metric", input.MetricName), + zap.String("from", input.From), + zap.String("to", input.To), + ) + + fromTime, err := time.Parse(time.RFC3339, input.From) + if err != nil { + return nil, nil, fmt.Errorf("from must be RFC3339 (e.g., 2026-05-13T00:00:00Z): %w", err) + } + toTime, err := time.Parse(time.RFC3339, input.To) + if err != nil { + return nil, nil, fmt.Errorf("to must be RFC3339 (e.g., 2026-05-13T01:00:00Z): %w", err) + } + + filters := buildMetricFilters(input.Role, input.Filters) + + body := api.MetricsSeriesRequest{ + Name: input.MetricName, + From: fromTime, + To: toTime, + } + bs := input.BucketSeconds + if bs <= 0 { + bs = util.DefaultBucketSeconds(fromTime, toTime) + } + body.BucketSeconds = &bs + if len(filters) > 0 { + body.Filters = &filters + } + + ctx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + + resp, err := cfg.Client.GetServiceMetricsSeriesWithResponse(ctx, cfg.ProjectID, input.ServiceID, body) + if err != nil { + return nil, nil, fmt.Errorf("failed to fetch metric series: %w", err) + } + + if resp.StatusCode() != http.StatusOK { + return nil, nil, resp.JSON4XX + } + + var series []api.MetricSeries + if resp.JSON200 != nil { + series = *resp.JSON200 + } + + if input.Mode == "full" { + return nil, ServiceMetricsFullOutput{Series: series}, nil + } + + // Default: per-series summaries. + summaries := make([]util.MetricSummary, 0, len(series)) + for _, ms := range series { + pts := make([]util.MetricPoint, len(ms.Data)) + for i, p := range ms.Data { + pts[i] = util.MetricPoint{Time: p.Time, Value: p.Value} + } + if summary := util.SummarizeMetrics(input.MetricName, ms.Labels, pts, fromTime, toTime); summary != nil { + summaries = append(summaries, *summary) + } + } + if len(summaries) == 0 { + return nil, ServiceMetricsSummaryOutput{}, fmt.Errorf("no data points returned for the requested time window") + } + return nil, ServiceMetricsSummaryOutput{Summaries: summaries}, nil +} + +// buildMetricFilters merges the convenience Role input with the arbitrary +// Filters slice into the preview label filter list. Role values are +// lowercased to match the gateway's response normalization. +func buildMetricFilters(role string, filters []MetricLabelFilterInput) []api.MetricLabelFilter { + var out []api.MetricLabelFilter + if role != "" { + out = append(out, api.MetricLabelFilter{Key: "role", Value: strings.ToLower(role)}) + } + for _, f := range filters { + if f.Key == "" || f.Value == "" { + continue + } + out = append(out, api.MetricLabelFilter{Key: f.Key, Value: f.Value}) + } + return out +} diff --git a/internal/tiger/util/metrics.go b/internal/tiger/util/metrics.go new file mode 100644 index 00000000..3f300b26 --- /dev/null +++ b/internal/tiger/util/metrics.go @@ -0,0 +1,100 @@ +package util + +import ( + "sort" + "time" +) + +// MetricPoint is a single data point used for summary computation. +type MetricPoint struct { + Time time.Time + Value *float64 +} + +// MetricSummary holds aggregated statistics for one labeled metric series over a time window. +type MetricSummary struct { + Name string `json:"name"` + Labels map[string]string `json:"labels,omitempty"` + From time.Time `json:"from"` + To time.Time `json:"to"` + Count int `json:"count"` + Min float64 `json:"min"` + MinTime time.Time `json:"min_time"` + Max float64 `json:"max"` + MaxTime time.Time `json:"max_time"` + Avg float64 `json:"avg"` + P50 float64 `json:"p50"` + P95 float64 `json:"p95"` +} + +// SummarizeMetrics computes aggregated stats from a slice of data points. +// Points with nil values are skipped. Returns nil when no non-nil points exist. +func SummarizeMetrics(name string, labels map[string]string, points []MetricPoint, from, to time.Time) *MetricSummary { + var vals []float64 + var times []time.Time + for _, p := range points { + if p.Value == nil { + continue + } + vals = append(vals, *p.Value) + times = append(times, p.Time) + } + if len(vals) == 0 { + return nil + } + + minVal, maxVal := vals[0], vals[0] + minTime, maxTime := times[0], times[0] + var sum float64 + for i, v := range vals { + sum += v + if v < minVal { + minVal = v + minTime = times[i] + } + if v > maxVal { + maxVal = v + maxTime = times[i] + } + } + + sorted := make([]float64, len(vals)) + copy(sorted, vals) + sort.Float64s(sorted) + + return &MetricSummary{ + Name: name, + Labels: labels, + From: from, + To: to, + Count: len(vals), + Min: minVal, + MinTime: minTime, + Max: maxVal, + MaxTime: maxTime, + Avg: sum / float64(len(vals)), + P50: percentile(sorted, 50), + P95: percentile(sorted, 95), + } +} + +// DefaultBucketSeconds returns a sensible bucket size for the given time window. +// Windows of one hour or less use per-minute buckets; longer windows use per-hour. +func DefaultBucketSeconds(from, to time.Time) int { + if to.Sub(from) <= time.Hour { + return 60 + } + return 3600 +} + +// percentile returns the p-th percentile of a pre-sorted slice using nearest-rank. +func percentile(sorted []float64, p float64) float64 { + if len(sorted) == 0 { + return 0 + } + idx := int((p / 100.0) * float64(len(sorted))) + if idx >= len(sorted) { + idx = len(sorted) - 1 + } + return sorted[idx] +} diff --git a/openapi.yaml b/openapi.yaml index 38fd8474..d534ae3c 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -684,6 +684,70 @@ paths: '4XX': $ref: '#/components/responses/ClientError' + /projects/{project_id}/services/{service_id}/metrics/available-series: + get: + operationId: getServiceMetricsAvailableSeries + x-preview: true + tags: + - Services + summary: List available metric series + description: | + **Preview — this endpoint is experimental and may change without notice.** + + Returns the names of all metric series available for a service. + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/ServiceId' + responses: + '200': + description: Available metric series names. + content: + application/json: + schema: + type: array + items: + type: string + '4XX': + $ref: '#/components/responses/ClientError' + + /projects/{project_id}/services/{service_id}/metrics/series: + post: + operationId: getServiceMetricsSeries + x-preview: true + tags: + - Services + summary: Get a metric series + description: | + **Preview — this endpoint is experimental and may change without notice.** + + Returns time-series data points for a named metric within a time window. + Use getServiceMetricsAvailableSeries to discover valid metric names. + + Parameters are sent as a JSON body so callers can pass an unbounded + filter set without query-string length pressure. The response is a list + of MetricSeries, each carrying its label set as a structured map + alongside its per-bucket data points. + parameters: + - $ref: '#/components/parameters/ProjectId' + - $ref: '#/components/parameters/ServiceId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/MetricsSeriesRequest' + responses: + '200': + description: Metric series matching the request. + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/MetricSeries' + '4XX': + $ref: '#/components/responses/ClientError' + /projects/{project_id}/services/{service_id}/setHA: post: operationId: setHAReplica @@ -1453,6 +1517,111 @@ components: lastCursor: type: string description: Opaque cursor for the next page of results. Present when more log entries exist older than the last entry in this response. Absent when there are no further results. + + MetricsSeriesRequest: + x-preview: true + type: object + description: | + Parameters for a single getServiceMetricsSeries query. Sent as a JSON + body so callers can pass an unbounded filter set without query-string + length pressure. + required: + - name + - from + - to + properties: + name: + type: string + description: Metric series name. Use getServiceMetricsAvailableSeries to discover valid values. + example: "timescale_cloud_system_cpu_usage_millicores" + from: + type: string + format: date-time + description: Start of the time window (RFC3339; nanosecond precision accepted). + example: "2026-06-25T10:00:00Z" + to: + type: string + format: date-time + description: End of the time window (RFC3339; nanosecond precision accepted). + example: "2026-06-25T11:00:00Z" + bucket_seconds: + type: integer + minimum: 1 + description: | + Aggregation bucket size in seconds. Defaults to 60 for windows ≤1 + hour, 3600 for longer windows. Clamped to [1, window_seconds]. + example: 60 + filters: + type: array + description: | + Label filters applied to the series query. Recognized label names + depend on the metric. The most common is `role` (value `primary` or + `replica`). When `role` is omitted, some metrics default to primary + and others return data for all roles (one series per role); pass an + explicit role for deterministic results. Role label values in the + response are lowercased. + items: + $ref: '#/components/schemas/MetricLabelFilter' + + MetricLabelFilter: + x-preview: true + type: object + description: A single key/value label match applied to a metric series query. + required: + - key + - value + properties: + key: + type: string + description: Label key to match against, e.g. `role` or `job_id`. + example: "role" + value: + type: string + description: Label value to match. Case sensitivity follows the underlying metric's storage convention. + example: "primary" + + MetricSeries: + x-preview: true + type: object + description: One labeled time series — the label set plus its per-bucket data points. + required: + - labels + - data + properties: + labels: + type: object + additionalProperties: + type: string + description: | + Label set identifying this series. The key set depends on the + metric (e.g. `role`, `ordinal`). + data: + type: array + description: Per-bucket data points for this label set. + items: + $ref: '#/components/schemas/MetricDataPoint' + + MetricDataPoint: + x-preview: true + type: object + description: A single time/value pair within a MetricSeries. + required: + - time + properties: + time: + type: string + format: date-time + description: The bucket start timestamp. + example: "2026-06-25T10:00:00Z" + value: + type: number + format: double + description: | + The aggregated value at this bucket. Buckets without collected + data are omitted from the parent series' `data` array; the API + does not currently emit null values. + example: 247.5 + Project: type: object required: From 8609d1357d0179d8c8a007e390c80ae3741e9274 Mon Sep 17 00:00:00 2001 From: Adrin Lopez Calvo Date: Fri, 3 Jul 2026 17:39:30 +0200 Subject: [PATCH 2/6] fix(mcp): use union output schema for service_metrics_series The handler returns either ServiceMetricsSummaryOutput or ServiceMetricsFullOutput depending on the requested mode, but the tool was registered with only the summary schema. Full-mode responses failed validation with `unexpected additional properties ["series"]`. Register an anyOf union of both shapes. Top-level `Type: "object"` is required by the MCP SDK. --- internal/tiger/mcp/service_tools.go | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/internal/tiger/mcp/service_tools.go b/internal/tiger/mcp/service_tools.go index c7ab7763..1e404ff7 100644 --- a/internal/tiger/mcp/service_tools.go +++ b/internal/tiger/mcp/service_tools.go @@ -514,6 +514,18 @@ func (ServiceMetricsFullOutput) Schema() *jsonschema.Schema { return util.Must(jsonschema.For[ServiceMetricsFullOutput](nil)) } +// serviceMetricsSeriesOutputSchema is the union of the summary and full output +// shapes. The handler returns one or the other depending on the requested mode. +func serviceMetricsSeriesOutputSchema() *jsonschema.Schema { + return &jsonschema.Schema{ + Type: "object", + AnyOf: []*jsonschema.Schema{ + ServiceMetricsSummaryOutput{}.Schema(), + ServiceMetricsFullOutput{}.Schema(), + }, + } +} + // registerServiceTools registers service management tools with comprehensive schemas and descriptions func (s *Server) registerServiceTools(readOnly, experimental bool) { // service_list @@ -725,7 +737,7 @@ Two modes: Available metrics include: CPU usage/allocation, memory usage/total, disk usage, disk I/O (read/write bytes and ops), queries per second, and active connections.`, InputSchema: ServiceMetricsSeriesInput{}.Schema(), - OutputSchema: ServiceMetricsSummaryOutput{}.Schema(), + OutputSchema: serviceMetricsSeriesOutputSchema(), Annotations: &mcp.ToolAnnotations{ ReadOnlyHint: true, OpenWorldHint: util.Ptr(false), From a21802887fcdda42bb87a8dfc5ab7cdbab5a8e95 Mon Sep 17 00:00:00 2001 From: Adrin Lopez Calvo Date: Tue, 7 Jul 2026 11:11:23 +0200 Subject: [PATCH 3/6] refactor(metrics): drop summary mode and client-side bucket defaulting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MCP: drop Mode input, drop ServiceMetricsSummaryOutput and the union output schema, rename ServiceMetricsFullOutput to ServiceMetricsSeriesOutput. Handler always returns raw series. - CLI: drop --mode flag, drop renderMetricsSummary and summarizeSeries, rename renderMetricsFull to renderMetricSeries. - Drop client-side DefaultBucketSeconds — the server auto-selects a default bucket size based on the time window. bucket_seconds/ --bucket-seconds remain as optional overrides. - Delete now-empty internal/tiger/util/metrics.go. --- internal/tiger/cmd/service.go | 83 +++-------------------- internal/tiger/mcp/service_tools.go | 74 ++++---------------- internal/tiger/util/metrics.go | 100 ---------------------------- 3 files changed, 22 insertions(+), 235 deletions(-) delete mode 100644 internal/tiger/util/metrics.go diff --git a/internal/tiger/cmd/service.go b/internal/tiger/cmd/service.go index 23a7d26e..fafee13e 100644 --- a/internal/tiger/cmd/service.go +++ b/internal/tiger/cmd/service.go @@ -1822,7 +1822,6 @@ func buildServiceMetricsSeriesCmd() *cobra.Command { var role string var filters []string var bucketSeconds int - var mode string var output string cmd := &cobra.Command{ @@ -1832,23 +1831,19 @@ func buildServiceMetricsSeriesCmd() *cobra.Command { Use 'tiger service metrics available-series' to discover valid metric names. -By default, returns a per-series summary (min/max/avg/p50/p95) over the time -window. Use --mode=full to get individual data points. - -Each labeled series (e.g. one per replica) is returned independently; the -output shows one summary block per series, identified by its label set. +Each labeled series (e.g. one per replica) is returned independently with its +full list of raw data points. Examples: - # Summarize CPU usage for the last hour + # Fetch CPU usage for the last hour tiger service metrics series --metric timescale_cloud_system_cpu_usage_millicores \ --from 2026-05-13T00:00:00Z --to 2026-05-13T01:00:00Z - # Get all memory data points as JSON + # Get memory data points as JSON tiger service metrics series --metric timescale_cloud_system_memory_usage_bytes \ - --from 2026-05-13T00:00:00Z --to 2026-05-13T01:00:00Z \ - --mode full --output json + --from 2026-05-13T00:00:00Z --to 2026-05-13T01:00:00Z --output json - # Summarize only the primary instance + # Fetch data for the primary instance only tiger service metrics series --metric timescale_cloud_system_cpu_usage_millicores \ --from 2026-05-13T00:00:00Z --to 2026-05-13T01:00:00Z --role PRIMARY @@ -1903,9 +1898,6 @@ Examples: if bucketSeconds > 0 { bs := bucketSeconds body.BucketSeconds = &bs - } else { - bs := util.DefaultBucketSeconds(fromTime, toTime) - body.BucketSeconds = &bs } if len(labelFilters) > 0 { body.Filters = &labelFilters @@ -1927,13 +1919,7 @@ Examples: return fmt.Errorf("empty response from API") } - series := *resp.JSON200 - out := cmd.OutOrStdout() - - if strings.ToLower(mode) == "full" { - return renderMetricsFull(out, cfg.Output, series) - } - return renderMetricsSummary(out, cfg.Output, metric, fromTime, toTime, series) + return renderMetricSeries(cmd.OutOrStdout(), cfg.Output, *resp.JSON200) }, } @@ -1942,8 +1928,7 @@ Examples: cmd.Flags().StringVar(&to, "to", "", "End of the time window (RFC3339, required)") cmd.Flags().StringVar(&role, "role", "", "Filter to a specific instance role (PRIMARY or REPLICA)") cmd.Flags().StringSliceVar(&filters, "filter", nil, "Arbitrary label filter as name=value (repeatable)") - cmd.Flags().IntVar(&bucketSeconds, "bucket-seconds", 0, "Aggregation bucket size in seconds (default: auto)") - cmd.Flags().StringVar(&mode, "mode", "summary", "Output mode: summary (default) or full") + cmd.Flags().IntVar(&bucketSeconds, "bucket-seconds", 0, "Aggregation bucket size in seconds (optional; server auto-selects based on the time window when omitted)") cmd.Flags().VarP((*outputFlag)(&output), "output", "o", "Output format (json, yaml, table)") return cmd @@ -1985,7 +1970,7 @@ func labelString(labels map[string]string) string { return "{" + strings.Join(parts, ",") + "}" } -func renderMetricsFull(out io.Writer, output string, series []api.MetricSeries) error { +func renderMetricSeries(out io.Writer, output string, series []api.MetricSeries) error { switch strings.ToLower(output) { case "json": return util.SerializeToJSON(out, series) @@ -2008,56 +1993,6 @@ func renderMetricsFull(out io.Writer, output string, series []api.MetricSeries) } } -func renderMetricsSummary(out io.Writer, output, metric string, from, to time.Time, series []api.MetricSeries) error { - summaries := summarizeSeries(metric, from, to, series) - - switch strings.ToLower(output) { - case "json": - return util.SerializeToJSON(out, summaries) - case "yaml": - return util.SerializeToYAML(out, summaries) - } - - if len(summaries) == 0 { - fmt.Fprintln(out, "No data points returned for the requested time window.") - return nil - } - - for i, s := range summaries { - if i > 0 { - fmt.Fprintln(out) - } - fmt.Fprintf(out, "metric: %s\n", s.Name) - fmt.Fprintf(out, "labels: %s\n", labelString(s.Labels)) - fmt.Fprintf(out, "from: %s\n", s.From.UTC().Format(time.RFC3339)) - fmt.Fprintf(out, "to: %s\n", s.To.UTC().Format(time.RFC3339)) - fmt.Fprintf(out, "count: %d\n", s.Count) - fmt.Fprintf(out, "min: %.3f (at %s)\n", s.Min, s.MinTime.UTC().Format(time.RFC3339)) - fmt.Fprintf(out, "max: %.3f (at %s)\n", s.Max, s.MaxTime.UTC().Format(time.RFC3339)) - fmt.Fprintf(out, "avg: %.3f\n", s.Avg) - fmt.Fprintf(out, "p50: %.3f\n", s.P50) - fmt.Fprintf(out, "p95: %.3f\n", s.P95) - } - return nil -} - -// summarizeSeries collapses each labeled series into its own MetricSummary so -// that aggregates (min/avg/etc.) are computed per replica rather than across -// every role at once. -func summarizeSeries(metric string, from, to time.Time, series []api.MetricSeries) []util.MetricSummary { - summaries := make([]util.MetricSummary, 0, len(series)) - for _, s := range series { - pts := make([]util.MetricPoint, len(s.Data)) - for i, p := range s.Data { - pts[i] = util.MetricPoint{Time: p.Time, Value: p.Value} - } - if summary := util.SummarizeMetrics(metric, s.Labels, pts, from, to); summary != nil { - summaries = append(summaries, *summary) - } - } - return summaries -} - // getServiceID determines the service ID from args or config func getServiceID(cfg *config.Config, args []string) (string, error) { var serviceID string diff --git a/internal/tiger/mcp/service_tools.go b/internal/tiger/mcp/service_tools.go index 1e404ff7..6b4106f2 100644 --- a/internal/tiger/mcp/service_tools.go +++ b/internal/tiger/mcp/service_tools.go @@ -455,7 +455,6 @@ type ServiceMetricsSeriesInput struct { Role string `json:"role,omitempty"` Filters []MetricLabelFilterInput `json:"filters,omitempty"` BucketSeconds int `json:"bucket_seconds,omitempty"` - Mode string `json:"mode,omitempty"` } func (ServiceMetricsSeriesInput) Schema() *jsonschema.Schema { @@ -483,47 +482,22 @@ func (ServiceMetricsSeriesInput) Schema() *jsonschema.Schema { schema.Properties["filters"].Description = "Arbitrary label filters applied to the series query. Recognized label names depend on the metric (e.g. 'role', 'ordinal', 'job_id')." - schema.Properties["bucket_seconds"].Description = "Aggregation bucket size in seconds. When 0 or omitted, auto-selected: 60s for windows ≤1 hour, 3600s for longer windows." + schema.Properties["bucket_seconds"].Description = "Aggregation bucket size in seconds. Optional — when omitted, the server automatically selects a bucket size based on the size of the time window." schema.Properties["bucket_seconds"].Minimum = util.Ptr(1.0) schema.Properties["bucket_seconds"].Examples = []any{60, 300, 3600} - schema.Properties["mode"].Description = "Output mode. 'summary' (default) returns aggregated stats (min/max/avg/p50/p95) per labeled series — efficient for triage. 'full' returns the raw labeled series with all individual data points." - schema.Properties["mode"].Enum = []any{"summary", "full"} - schema.Properties["mode"].Default = util.Must(json.Marshal("summary")) - return schema } -// ServiceMetricsSummaryOutput is returned when mode=summary. The endpoint -// returns one MetricSeries per distinct label set (e.g. one per replica), so -// the output is a list of per-series summaries rather than a single rollup. -type ServiceMetricsSummaryOutput struct { - Summaries []util.MetricSummary `json:"summaries"` -} - -func (ServiceMetricsSummaryOutput) Schema() *jsonschema.Schema { - return util.Must(jsonschema.For[ServiceMetricsSummaryOutput](nil)) -} - -// ServiceMetricsFullOutput is returned when mode=full -type ServiceMetricsFullOutput struct { +// ServiceMetricsSeriesOutput is the response from service_metrics_series. The +// endpoint returns one MetricSeries per distinct label set (e.g. one per +// replica). +type ServiceMetricsSeriesOutput struct { Series []api.MetricSeries `json:"series"` } -func (ServiceMetricsFullOutput) Schema() *jsonschema.Schema { - return util.Must(jsonschema.For[ServiceMetricsFullOutput](nil)) -} - -// serviceMetricsSeriesOutputSchema is the union of the summary and full output -// shapes. The handler returns one or the other depending on the requested mode. -func serviceMetricsSeriesOutputSchema() *jsonschema.Schema { - return &jsonschema.Schema{ - Type: "object", - AnyOf: []*jsonschema.Schema{ - ServiceMetricsSummaryOutput{}.Schema(), - ServiceMetricsFullOutput{}.Schema(), - }, - } +func (ServiceMetricsSeriesOutput) Schema() *jsonschema.Schema { + return util.Must(jsonschema.For[ServiceMetricsSeriesOutput](nil)) } // registerServiceTools registers service management tools with comprehensive schemas and descriptions @@ -730,14 +704,11 @@ Use service_metrics_available first to discover valid metric names. The response groups data points by their label set — a single request may return multiple labeled series (e.g. one per replica, one per worker ordinal). - -Two modes: -- summary (default): Returns per-series aggregated stats (count, min/max with timestamps, avg, p50, p95). Efficient for triage — minimal token usage. -- full: Returns each labeled series with its full data point list. Use when you need the raw time series. +Each series contains its full list of raw data points. Available metrics include: CPU usage/allocation, memory usage/total, disk usage, disk I/O (read/write bytes and ops), queries per second, and active connections.`, InputSchema: ServiceMetricsSeriesInput{}.Schema(), - OutputSchema: serviceMetricsSeriesOutputSchema(), + OutputSchema: ServiceMetricsSeriesOutput{}.Schema(), Annotations: &mcp.ToolAnnotations{ ReadOnlyHint: true, OpenWorldHint: util.Ptr(false), @@ -1447,11 +1418,10 @@ func (s *Server) handleServiceMetricsSeries(ctx context.Context, req *mcp.CallTo From: fromTime, To: toTime, } - bs := input.BucketSeconds - if bs <= 0 { - bs = util.DefaultBucketSeconds(fromTime, toTime) + if input.BucketSeconds > 0 { + bs := input.BucketSeconds + body.BucketSeconds = &bs } - body.BucketSeconds = &bs if len(filters) > 0 { body.Filters = &filters } @@ -1473,25 +1443,7 @@ func (s *Server) handleServiceMetricsSeries(ctx context.Context, req *mcp.CallTo series = *resp.JSON200 } - if input.Mode == "full" { - return nil, ServiceMetricsFullOutput{Series: series}, nil - } - - // Default: per-series summaries. - summaries := make([]util.MetricSummary, 0, len(series)) - for _, ms := range series { - pts := make([]util.MetricPoint, len(ms.Data)) - for i, p := range ms.Data { - pts[i] = util.MetricPoint{Time: p.Time, Value: p.Value} - } - if summary := util.SummarizeMetrics(input.MetricName, ms.Labels, pts, fromTime, toTime); summary != nil { - summaries = append(summaries, *summary) - } - } - if len(summaries) == 0 { - return nil, ServiceMetricsSummaryOutput{}, fmt.Errorf("no data points returned for the requested time window") - } - return nil, ServiceMetricsSummaryOutput{Summaries: summaries}, nil + return nil, ServiceMetricsSeriesOutput{Series: series}, nil } // buildMetricFilters merges the convenience Role input with the arbitrary diff --git a/internal/tiger/util/metrics.go b/internal/tiger/util/metrics.go deleted file mode 100644 index 3f300b26..00000000 --- a/internal/tiger/util/metrics.go +++ /dev/null @@ -1,100 +0,0 @@ -package util - -import ( - "sort" - "time" -) - -// MetricPoint is a single data point used for summary computation. -type MetricPoint struct { - Time time.Time - Value *float64 -} - -// MetricSummary holds aggregated statistics for one labeled metric series over a time window. -type MetricSummary struct { - Name string `json:"name"` - Labels map[string]string `json:"labels,omitempty"` - From time.Time `json:"from"` - To time.Time `json:"to"` - Count int `json:"count"` - Min float64 `json:"min"` - MinTime time.Time `json:"min_time"` - Max float64 `json:"max"` - MaxTime time.Time `json:"max_time"` - Avg float64 `json:"avg"` - P50 float64 `json:"p50"` - P95 float64 `json:"p95"` -} - -// SummarizeMetrics computes aggregated stats from a slice of data points. -// Points with nil values are skipped. Returns nil when no non-nil points exist. -func SummarizeMetrics(name string, labels map[string]string, points []MetricPoint, from, to time.Time) *MetricSummary { - var vals []float64 - var times []time.Time - for _, p := range points { - if p.Value == nil { - continue - } - vals = append(vals, *p.Value) - times = append(times, p.Time) - } - if len(vals) == 0 { - return nil - } - - minVal, maxVal := vals[0], vals[0] - minTime, maxTime := times[0], times[0] - var sum float64 - for i, v := range vals { - sum += v - if v < minVal { - minVal = v - minTime = times[i] - } - if v > maxVal { - maxVal = v - maxTime = times[i] - } - } - - sorted := make([]float64, len(vals)) - copy(sorted, vals) - sort.Float64s(sorted) - - return &MetricSummary{ - Name: name, - Labels: labels, - From: from, - To: to, - Count: len(vals), - Min: minVal, - MinTime: minTime, - Max: maxVal, - MaxTime: maxTime, - Avg: sum / float64(len(vals)), - P50: percentile(sorted, 50), - P95: percentile(sorted, 95), - } -} - -// DefaultBucketSeconds returns a sensible bucket size for the given time window. -// Windows of one hour or less use per-minute buckets; longer windows use per-hour. -func DefaultBucketSeconds(from, to time.Time) int { - if to.Sub(from) <= time.Hour { - return 60 - } - return 3600 -} - -// percentile returns the p-th percentile of a pre-sorted slice using nearest-rank. -func percentile(sorted []float64, p float64) float64 { - if len(sorted) == 0 { - return 0 - } - idx := int((p / 100.0) * float64(len(sorted))) - if idx >= len(sorted) { - idx = len(sorted) - 1 - } - return sorted[idx] -} From 9e7866cefb4e9782d749aa5093dceae4222587c6 Mon Sep 17 00:00:00 2001 From: Adrin Lopez Calvo Date: Tue, 7 Jul 2026 11:24:26 +0200 Subject: [PATCH 4/6] refactor(metrics): use MarkFlagRequired and trim example metrics - CLI: use cobra.MarkFlagRequired for --metric, --from, --to instead of manual empty-string checks; drops the "(required)" suffix from flag help since cobra now surfaces it automatically. - MCP: drop timescale_cloud_database_qps and timescale_cloud_database_num_connections from metric_name examples and tool description. --- internal/tiger/cmd/service.go | 19 +++++++------------ internal/tiger/mcp/service_tools.go | 4 +--- 2 files changed, 8 insertions(+), 15 deletions(-) diff --git a/internal/tiger/cmd/service.go b/internal/tiger/cmd/service.go index fafee13e..ae9e2c1e 100644 --- a/internal/tiger/cmd/service.go +++ b/internal/tiger/cmd/service.go @@ -1854,15 +1854,6 @@ Examples: Args: cobra.MaximumNArgs(1), PreRunE: bindFlags("output"), RunE: func(cmd *cobra.Command, args []string) error { - if metric == "" { - return fmt.Errorf("--metric is required") - } - if from == "" { - return fmt.Errorf("--from is required") - } - if to == "" { - return fmt.Errorf("--to is required") - } fromTime, err := time.Parse(time.RFC3339, from) if err != nil { return fmt.Errorf("--from must be RFC3339 (e.g., 2026-05-13T00:00:00Z): %w", err) @@ -1923,14 +1914,18 @@ Examples: }, } - cmd.Flags().StringVar(&metric, "metric", "", "Metric series name (required)") - cmd.Flags().StringVar(&from, "from", "", "Start of the time window (RFC3339, required)") - cmd.Flags().StringVar(&to, "to", "", "End of the time window (RFC3339, required)") + cmd.Flags().StringVar(&metric, "metric", "", "Metric series name") + cmd.Flags().StringVar(&from, "from", "", "Start of the time window (RFC3339)") + cmd.Flags().StringVar(&to, "to", "", "End of the time window (RFC3339)") cmd.Flags().StringVar(&role, "role", "", "Filter to a specific instance role (PRIMARY or REPLICA)") cmd.Flags().StringSliceVar(&filters, "filter", nil, "Arbitrary label filter as name=value (repeatable)") cmd.Flags().IntVar(&bucketSeconds, "bucket-seconds", 0, "Aggregation bucket size in seconds (optional; server auto-selects based on the time window when omitted)") cmd.Flags().VarP((*outputFlag)(&output), "output", "o", "Output format (json, yaml, table)") + cmd.MarkFlagRequired("metric") + cmd.MarkFlagRequired("from") + cmd.MarkFlagRequired("to") + return cmd } diff --git a/internal/tiger/mcp/service_tools.go b/internal/tiger/mcp/service_tools.go index 6b4106f2..322ca980 100644 --- a/internal/tiger/mcp/service_tools.go +++ b/internal/tiger/mcp/service_tools.go @@ -467,8 +467,6 @@ func (ServiceMetricsSeriesInput) Schema() *jsonschema.Schema { "timescale_cloud_system_cpu_usage_millicores", "timescale_cloud_system_memory_usage_bytes", "timescale_cloud_system_disk_usage_bytes", - "timescale_cloud_database_qps", - "timescale_cloud_database_num_connections", } schema.Properties["from"].Description = "Start of the time window (RFC3339 format)." @@ -706,7 +704,7 @@ The response groups data points by their label set — a single request may return multiple labeled series (e.g. one per replica, one per worker ordinal). Each series contains its full list of raw data points. -Available metrics include: CPU usage/allocation, memory usage/total, disk usage, disk I/O (read/write bytes and ops), queries per second, and active connections.`, +Available metrics include: CPU usage/allocation, memory usage/total, disk usage, and disk I/O (read/write bytes and ops).`, InputSchema: ServiceMetricsSeriesInput{}.Schema(), OutputSchema: ServiceMetricsSeriesOutput{}.Schema(), Annotations: &mcp.ToolAnnotations{ From 82458446bee162af0b5a4b0cf80013b4afccbd1a Mon Sep 17 00:00:00 2001 From: Adrin Lopez Calvo Date: Tue, 7 Jul 2026 11:32:12 +0200 Subject: [PATCH 5/6] chore(openapi): sync bucket_seconds constraints from gateway MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors timescale/savannah-gateway#1851: the metrics-api service now owns the bucket-per-window defaulting policy. Bumps minimum to 60s, updates the description to describe the new tiered auto-select (1m ≤1h, 1h ≤30d, 1d beyond), and propagates the constraint to the MCP tool schema and CLI --bucket-seconds help text. --- internal/tiger/api/types.go | 8 ++++++-- internal/tiger/cmd/service.go | 2 +- internal/tiger/mcp/service_tools.go | 4 ++-- openapi.yaml | 12 ++++++++---- 4 files changed, 17 insertions(+), 9 deletions(-) diff --git a/internal/tiger/api/types.go b/internal/tiger/api/types.go index 0c6b5df6..9f649be6 100644 --- a/internal/tiger/api/types.go +++ b/internal/tiger/api/types.go @@ -243,8 +243,12 @@ type MetricSeries struct { // body so callers can pass an unbounded filter set without query-string // length pressure. type MetricsSeriesRequest struct { - // BucketSeconds Aggregation bucket size in seconds. Defaults to 60 for windows ≤1 - // hour, 3600 for longer windows. Clamped to [1, window_seconds]. + // BucketSeconds Aggregation bucket size in seconds. Optional: when omitted the + // server picks a default that matches the window (roughly 1m for + // windows up to 1h, 1h for up to 30d, 1d beyond that). Minimum is + // 60s (finer buckets don't add resolution given scrape intervals). + // Must be coarse enough for the window's tier; requests that ask + // for a finer bucket than the tier can produce are rejected. BucketSeconds *int `json:"bucket_seconds,omitempty"` // Filters Label filters applied to the series query. Recognized label names diff --git a/internal/tiger/cmd/service.go b/internal/tiger/cmd/service.go index ae9e2c1e..74c73abb 100644 --- a/internal/tiger/cmd/service.go +++ b/internal/tiger/cmd/service.go @@ -1919,7 +1919,7 @@ Examples: cmd.Flags().StringVar(&to, "to", "", "End of the time window (RFC3339)") cmd.Flags().StringVar(&role, "role", "", "Filter to a specific instance role (PRIMARY or REPLICA)") cmd.Flags().StringSliceVar(&filters, "filter", nil, "Arbitrary label filter as name=value (repeatable)") - cmd.Flags().IntVar(&bucketSeconds, "bucket-seconds", 0, "Aggregation bucket size in seconds (optional; server auto-selects based on the time window when omitted)") + cmd.Flags().IntVar(&bucketSeconds, "bucket-seconds", 0, "Aggregation bucket size in seconds (optional; server auto-selects based on the time window when omitted, minimum 60s)") cmd.Flags().VarP((*outputFlag)(&output), "output", "o", "Output format (json, yaml, table)") cmd.MarkFlagRequired("metric") diff --git a/internal/tiger/mcp/service_tools.go b/internal/tiger/mcp/service_tools.go index 322ca980..ca348439 100644 --- a/internal/tiger/mcp/service_tools.go +++ b/internal/tiger/mcp/service_tools.go @@ -480,8 +480,8 @@ func (ServiceMetricsSeriesInput) Schema() *jsonschema.Schema { schema.Properties["filters"].Description = "Arbitrary label filters applied to the series query. Recognized label names depend on the metric (e.g. 'role', 'ordinal', 'job_id')." - schema.Properties["bucket_seconds"].Description = "Aggregation bucket size in seconds. Optional — when omitted, the server automatically selects a bucket size based on the size of the time window." - schema.Properties["bucket_seconds"].Minimum = util.Ptr(1.0) + schema.Properties["bucket_seconds"].Description = "Aggregation bucket size in seconds. Optional — when omitted, the server picks a default matched to the window (roughly 1m for windows up to 1h, 1h for up to 30d, 1d beyond that). Minimum 60s." + schema.Properties["bucket_seconds"].Minimum = util.Ptr(60.0) schema.Properties["bucket_seconds"].Examples = []any{60, 300, 3600} return schema diff --git a/openapi.yaml b/openapi.yaml index d534ae3c..f2bd4e85 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1546,11 +1546,15 @@ components: example: "2026-06-25T11:00:00Z" bucket_seconds: type: integer - minimum: 1 + minimum: 60 description: | - Aggregation bucket size in seconds. Defaults to 60 for windows ≤1 - hour, 3600 for longer windows. Clamped to [1, window_seconds]. - example: 60 + Aggregation bucket size in seconds. Optional: when omitted the + server picks a default that matches the window (roughly 1m for + windows up to 1h, 1h for up to 30d, 1d beyond that). Minimum is + 60s (finer buckets don't add resolution given scrape intervals). + Must be coarse enough for the window's tier; requests that ask + for a finer bucket than the tier can produce are rejected. + example: 3600 filters: type: array description: | From acfdf7a444158851053e7473e22a718b83b3a299 Mon Sep 17 00:00:00 2001 From: Rob Kiefer Date: Tue, 7 Jul 2026 17:16:02 -0400 Subject: [PATCH 6/6] fix(metrics): Make errors consistent, output labels even for empty list of data points --- internal/tiger/cmd/service.go | 10 ++++++++++ internal/tiger/mcp/service_tools.go | 10 ++++++---- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/internal/tiger/cmd/service.go b/internal/tiger/cmd/service.go index 74c73abb..622c0f1c 100644 --- a/internal/tiger/cmd/service.go +++ b/internal/tiger/cmd/service.go @@ -1972,10 +1972,20 @@ func renderMetricSeries(out io.Writer, output string, series []api.MetricSeries) case "yaml": return util.SerializeToYAML(out, series) default: + if len(series) == 0 { + fmt.Fprintln(out, "No metric data returned for the requested window.") + return nil + } table := tablewriter.NewWriter(out) table.Header("SERIES", "TIME", "VALUE") for _, s := range series { label := labelString(s.Labels) + if len(s.Data) == 0 { + // Keep matched-but-empty series visible instead of dropping + // them: emit one placeholder row for the label. + table.Append(label, "", "(no data)") + continue + } for _, p := range s.Data { val := "null" if p.Value != nil { diff --git a/internal/tiger/mcp/service_tools.go b/internal/tiger/mcp/service_tools.go index ca348439..b7526e0d 100644 --- a/internal/tiger/mcp/service_tools.go +++ b/internal/tiger/mcp/service_tools.go @@ -1375,7 +1375,7 @@ func (s *Server) handleServiceMetricsAvailable(ctx context.Context, req *mcp.Cal } if resp.StatusCode() != http.StatusOK { - return nil, ServiceMetricsAvailableOutput{}, resp.JSON4XX + return nil, ServiceMetricsAvailableOutput{}, common.ExitWithErrorFromStatusCode(resp.StatusCode(), resp.JSON4XX) } if resp.JSON200 == nil { @@ -1433,11 +1433,13 @@ func (s *Server) handleServiceMetricsSeries(ctx context.Context, req *mcp.CallTo } if resp.StatusCode() != http.StatusOK { - return nil, nil, resp.JSON4XX + return nil, nil, common.ExitWithErrorFromStatusCode(resp.StatusCode(), resp.JSON4XX) } - var series []api.MetricSeries - if resp.JSON200 != nil { + // Default to a non-nil slice so an empty result marshals to `[]` rather + // than `null`, which would fail the required-array output-schema validation. + series := []api.MetricSeries{} + if resp.JSON200 != nil && *resp.JSON200 != nil { series = *resp.JSON200 }