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..9f649be6 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,66 @@ 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. 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 + // 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 +594,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..622c0f1c 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,268 @@ 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 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. + +Each labeled series (e.g. one per replica) is returned independently with its +full list of raw data points. + +Examples: + # 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 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 --output json + + # 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 + + # 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 { + 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 + } + 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") + } + + return renderMetricSeries(cmd.OutOrStdout(), cfg.Output, *resp.JSON200) + }, + } + + 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, minimum 60s)") + cmd.Flags().VarP((*outputFlag)(&output), "output", "o", "Output format (json, yaml, table)") + + cmd.MarkFlagRequired("metric") + cmd.MarkFlagRequired("from") + cmd.MarkFlagRequired("to") + + 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 renderMetricSeries(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: + 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 { + val = fmt.Sprintf("%.3f", *p.Value) + } + table.Append(label, p.Time.UTC().Format(time.RFC3339), val) + } + } + return table.Render() + } +} + // 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..b7526e0d 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,86 @@ 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"` +} + +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", + } + + 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. 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 +} + +// 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 (ServiceMetricsSeriesOutput) Schema() *jsonschema.Schema { + return util.Must(jsonschema.For[ServiceMetricsSeriesOutput](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 +671,49 @@ 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). +Each series contains its full list of raw data points. + +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{ + ReadOnlyHint: true, + OpenWorldHint: util.Ptr(false), + Title: "Get Metric Series Data", + }, + }, s.handleServiceMetricsSeries) + } } // handleServiceList handles the service_list MCP tool @@ -1229,3 +1353,112 @@ 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{}, common.ExitWithErrorFromStatusCode(resp.StatusCode(), 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, + } + if input.BucketSeconds > 0 { + bs := input.BucketSeconds + 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, common.ExitWithErrorFromStatusCode(resp.StatusCode(), resp.JSON4XX) + } + + // 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 + } + + return nil, ServiceMetricsSeriesOutput{Series: series}, 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/openapi.yaml b/openapi.yaml index 38fd8474..f2bd4e85 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,115 @@ 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: 60 + description: | + 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: | + 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: