From 95d3bda74e3f821da02dddd455f4ac5aeda48a22 Mon Sep 17 00:00:00 2001 From: Thibault Le Ouay Ducasse Date: Fri, 8 May 2026 21:39:06 +0200 Subject: [PATCH] feat: add monitors logs and log-info commands for HTTP response logs --- docs/openstatus-docs.md | 49 ++++ go.mod | 6 +- go.sum | 12 +- internal/monitors/monitor_log_info.go | 276 +++++++++++++++++++++ internal/monitors/monitor_log_info_test.go | 97 ++++++++ internal/monitors/monitor_logs.go | 270 ++++++++++++++++++++ internal/monitors/monitor_logs_test.go | 111 +++++++++ internal/monitors/monitors.go | 2 + internal/monitors/monitors_test.go | 20 +- 9 files changed, 825 insertions(+), 18 deletions(-) create mode 100644 internal/monitors/monitor_log_info.go create mode 100644 internal/monitors/monitor_log_info_test.go create mode 100644 internal/monitors/monitor_logs.go create mode 100644 internal/monitors/monitor_logs_test.go diff --git a/docs/openstatus-docs.md b/docs/openstatus-docs.md index 5e349e3..c72c80d 100644 --- a/docs/openstatus-docs.md +++ b/docs/openstatus-docs.md @@ -166,6 +166,55 @@ The following flags are supported: | `--all` | List all monitors including inactive ones | `false` | *none* | | `--access-token="…"` (`-t`) | OpenStatus API Access Token | | `OPENSTATUS_API_TOKEN` | +### `monitors logs` subcommand + +List HTTP response logs for a monitor. + +> openstatus monitors logs +> openstatus monitors logs 12345 +> openstatus monitors logs 12345 --limit 10 +> openstatus monitors logs 12345 --limit 5 --offset 5 +> openstatus monitors logs 12345 --from 2026-05-06T00:00:00Z --to 2026-05-07T00:00:00Z + +List HTTP response logs for a monitor from the 14-day retention window. Supports pagination and time filtering. + +Usage: + +```bash +$ openstatus [GLOBAL FLAGS] monitors logs [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Default value | Environment variables | +|-----------------------------|------------------------------------------|:-------------:|:----------------------:| +| `--access-token="…"` (`-t`) | OpenStatus API Access Token | | `OPENSTATUS_API_TOKEN` | +| `--limit="…"` | Maximum number of logs to return (1-100) | `0` | *none* | +| `--offset="…"` | Number of logs to skip for pagination | `0` | *none* | +| `--from="…"` | Start of time window (RFC 3339 format) | | *none* | +| `--to="…"` | End of time window (RFC 3339 format) | | *none* | + +### `monitors log-info` subcommand + +Get detailed HTTP response log for a monitor. + +> openstatus monitors log-info +> openstatus monitors log-info 12345 abc-def-ghi + +Fetch a single HTTP response log with full details including timing phases, response headers, and assertion results. + +Usage: + +```bash +$ openstatus [GLOBAL FLAGS] monitors log-info [COMMAND FLAGS] [ARGUMENTS...] +``` + +The following flags are supported: + +| Name | Description | Default value | Environment variables | +|-----------------------------|-----------------------------|:-------------:|:----------------------:| +| `--access-token="…"` (`-t`) | OpenStatus API Access Token | | `OPENSTATUS_API_TOKEN` | + ### `monitors trigger` subcommand Trigger a monitor execution. diff --git a/go.mod b/go.mod index b82524f..effb4b8 100644 --- a/go.mod +++ b/go.mod @@ -5,9 +5,9 @@ go 1.25 require github.com/urfave/cli/v3 v3.0.0-alpha9.2 // direct require ( - buf.build/gen/go/openstatus/api/connectrpc/gosimple v1.19.1-20260323160652-4be687fa490b.2 - buf.build/gen/go/openstatus/api/protocolbuffers/go v1.36.11-20260323160652-4be687fa490b.1 - connectrpc.com/connect v1.19.1 + buf.build/gen/go/openstatus/api/connectrpc/gosimple v1.19.2-20260505152507-ee6c0b5379e7.1 + buf.build/gen/go/openstatus/api/protocolbuffers/go v1.36.11-20260505152507-ee6c0b5379e7.1 + connectrpc.com/connect v1.19.2 github.com/briandowns/spinner v1.23.2 github.com/charmbracelet/huh v1.0.0 github.com/fatih/color v1.18.0 diff --git a/go.sum b/go.sum index 0c75bd8..fb6ee2d 100644 --- a/go.sum +++ b/go.sum @@ -2,12 +2,12 @@ buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-202512091757 buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20251209175733-2a1774d88802.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM= buf.build/gen/go/gnostic/gnostic/protocolbuffers/go v1.36.11-20230414000709-087bc8072ce4.1 h1:t8f+WWZ5WNrZaP5zrpWD8f1tKU7eelJMbIAT9FRX558= buf.build/gen/go/gnostic/gnostic/protocolbuffers/go v1.36.11-20230414000709-087bc8072ce4.1/go.mod h1:/t9AeRQQp2iNkiGHDLfHLW3SzNpYpNPGRZ+Ih8+SOUs= -buf.build/gen/go/openstatus/api/connectrpc/gosimple v1.19.1-20260323160652-4be687fa490b.2 h1:AHDJQUgykCBDBE8qx4ewHxk4q29BYin4I5k2VZBJoSI= -buf.build/gen/go/openstatus/api/connectrpc/gosimple v1.19.1-20260323160652-4be687fa490b.2/go.mod h1:M+ceX35kH8wh3cgoFD8o2fNVedIx8NB9LWV7Z0ObO6Y= -buf.build/gen/go/openstatus/api/protocolbuffers/go v1.36.11-20260323160652-4be687fa490b.1 h1:sTK/97BsCzbBtqydnKAyRduWLbm1U7LbOk+hAzOikhs= -buf.build/gen/go/openstatus/api/protocolbuffers/go v1.36.11-20260323160652-4be687fa490b.1/go.mod h1:GRsD/In1AV3/RC92zWVAjBpj57hJIQm7Rph0rLOYYPg= -connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14= -connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= +buf.build/gen/go/openstatus/api/connectrpc/gosimple v1.19.2-20260505152507-ee6c0b5379e7.1 h1:d8BfHZWqseh5LI5Or77MOExFOQB6PvJqWQYrZ6E2stY= +buf.build/gen/go/openstatus/api/connectrpc/gosimple v1.19.2-20260505152507-ee6c0b5379e7.1/go.mod h1:ENvll+poGWfEPVEr6u0EWr+zKbVk+P2JjfoJ1KUvFoU= +buf.build/gen/go/openstatus/api/protocolbuffers/go v1.36.11-20260505152507-ee6c0b5379e7.1 h1:SA8oRs3V26timaNILiVJ7jydtg0lA7WjvkgdkWDrsO4= +buf.build/gen/go/openstatus/api/protocolbuffers/go v1.36.11-20260505152507-ee6c0b5379e7.1/go.mod h1:GRsD/In1AV3/RC92zWVAjBpj57hJIQm7Rph0rLOYYPg= +connectrpc.com/connect v1.19.2 h1:McQ83FGdzL+t60peksi0gXC7MQ/iLKgLduAnThbM0mo= +connectrpc.com/connect v1.19.2/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/agext/levenshtein v1.2.1 h1:QmvMAjj2aEICytGiWzmxoE0x2KZvE0fvmqMOfy2tjT8= diff --git a/internal/monitors/monitor_log_info.go b/internal/monitors/monitor_log_info.go new file mode 100644 index 0000000..5b76185 --- /dev/null +++ b/internal/monitors/monitor_log_info.go @@ -0,0 +1,276 @@ +package monitors + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "os" + "sort" + "strings" + + monitorv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/monitor/v1" + "buf.build/gen/go/openstatus/api/connectrpc/gosimple/openstatus/monitor/v1/monitorv1connect" + "github.com/fatih/color" + "github.com/logrusorgru/aurora/v4" + "github.com/openstatusHQ/cli/internal/auth" + output "github.com/openstatusHQ/cli/internal/cli" + "github.com/urfave/cli/v3" +) + +type responseLogDetailOutput struct { + ID string `json:"id"` + MonitorID string `json:"monitor_id"` + URL string `json:"url"` + StatusCode int32 `json:"status_code,omitempty"` + Latency int32 `json:"latency_ms"` + Region string `json:"region"` + RequestStatus string `json:"request_status"` + Trigger string `json:"trigger"` + Timestamp string `json:"timestamp"` + Error bool `json:"error"` + Message string `json:"message,omitempty"` + Headers map[string]string `json:"headers,omitempty"` + Assertions string `json:"assertions,omitempty"` + Timing *timingOutput `json:"timing,omitempty"` +} + +type timingOutput struct { + DNS int32 `json:"dns_ms"` + Connect int32 `json:"connect_ms"` + TLS int32 `json:"tls_ms"` + TTFB int32 `json:"ttfb_ms"` + Transfer int32 `json:"transfer_ms"` +} + +func GetMonitorResponseLogInfo( + ctx context.Context, + client monitorv1connect.MonitorServiceClient, + monitorId string, + logId string, + s *output.Spinner, +) error { + if monitorId == "" { + output.StopSpinner(s) + fmt.Fprintln(os.Stderr, "Usage: openstatus monitors log-info ") + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, "Example: openstatus monitors log-info 12345 abc-def") + return fmt.Errorf("monitor ID is required") + } + if logId == "" { + output.StopSpinner(s) + fmt.Fprintln(os.Stderr, "Usage: openstatus monitors log-info ") + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, "Example: openstatus monitors log-info 12345 abc-def") + return fmt.Errorf("log ID is required") + } + + resp, err := client.GetMonitorHTTPResponseLog(ctx, &monitorv1.GetMonitorHTTPResponseLogRequest{ + Id: monitorId, + LogId: logId, + }) + output.StopSpinner(s) + if err != nil { + return output.FormatError(err, "response log", logId) + } + + detail := resp.GetLog() + logItem := detail.GetLog() + + var timing *timingOutput + if logItem.HasTiming() { + t := logItem.GetTiming() + timing = &timingOutput{ + DNS: t.GetDns(), + Connect: t.GetConnect(), + TLS: t.GetTls(), + TTFB: t.GetTtfb(), + Transfer: t.GetTransfer(), + } + } + + detailOut := responseLogDetailOutput{ + ID: logItem.GetId(), + MonitorID: logItem.GetMonitorId(), + URL: detail.GetUrl(), + StatusCode: logItem.GetStatusCode(), + Latency: logItem.GetLatency(), + Region: regionToString(logItem.GetRegion()), + RequestStatus: requestStatusToString(logItem.GetRequestStatus()), + Trigger: triggerToString(logItem.GetTrigger()), + Timestamp: formatUnixMillis(logItem.GetTimestamp()), + Error: detail.GetError(), + Message: detail.GetMessage(), + Headers: detail.GetHeaders(), + Assertions: detail.GetAssertions(), + Timing: timing, + } + + if output.IsJSONOutput() { + return output.PrintJSON(detailOut) + } + + // Section 1: Response Log + fmt.Println(aurora.Bold("Response Log:")) + tbl := newBlueprintTable() + data := [][]string{ + {"ID", detailOut.ID}, + {"Monitor ID", detailOut.MonitorID}, + {"URL", detailOut.URL}, + {"Status", colorizeRequestStatus(detailOut.RequestStatus)}, + {"Status Code", fmt.Sprintf("%d", detailOut.StatusCode)}, + {"Latency", fmt.Sprintf("%d ms", detailOut.Latency)}, + {"Region", detailOut.Region}, + {"Trigger", detailOut.Trigger}, + {"Timestamp", detailOut.Timestamp}, + } + if detailOut.Error { + msg := detailOut.Message + if msg == "" { + msg = "yes" + } + data = append(data, []string{"Error", color.RedString(msg)}) + } + tbl.Bulk(data) + tbl.Render() + + // Section 2: Timing waterfall + if timing != nil { + fmt.Println() + fmt.Println(aurora.Bold("Timing:")) + renderTimingWaterfall(timing) + } + + // Section 3: Response Headers + if len(detailOut.Headers) > 0 { + fmt.Println() + fmt.Println(aurora.Bold("Response Headers:")) + headerTable := newBlueprintTable() + keys := make([]string, 0, len(detailOut.Headers)) + for k := range detailOut.Headers { + keys = append(keys, k) + } + sort.Strings(keys) + var headerData [][]string + for _, k := range keys { + headerData = append(headerData, []string{k, detailOut.Headers[k]}) + } + headerTable.Bulk(headerData) + headerTable.Render() + } + + // Section 4: Assertions + if detailOut.Assertions != "" { + fmt.Println() + fmt.Println(aurora.Bold("Assertions:")) + renderAssertions(detailOut.Assertions) + } + + return nil +} + +func renderTimingWaterfall(t *timingOutput) { + phases := []struct { + name string + ms int32 + }{ + {"DNS", t.DNS}, + {"Connect", t.Connect}, + {"TLS", t.TLS}, + {"TTFB", t.TTFB}, + {"Transfer", t.Transfer}, + } + + total := t.DNS + t.Connect + t.TLS + t.TTFB + t.Transfer + const maxBarWidth = 30 + + tbl := newBlueprintTable() + var data [][]string + for _, p := range phases { + bar := "" + if total > 0 && p.ms > 0 { + width := int(float64(p.ms) / float64(total) * maxBarWidth) + if width < 1 { + width = 1 + } + bar = color.CyanString(strings.Repeat("█", width)) + } + data = append(data, []string{p.name, fmt.Sprintf("%d ms", p.ms), bar}) + } + data = append(data, []string{"─────", "─────────", ""}) + data = append(data, []string{"Total", fmt.Sprintf("%d ms", total), ""}) + tbl.Bulk(data) + tbl.Render() +} + +func renderAssertions(raw string) { + var assertions []Assertion + if err := json.Unmarshal([]byte(raw), &assertions); err == nil && len(assertions) > 0 { + tbl := newBlueprintTable() + var data [][]string + for _, a := range assertions { + row := []string{a.Type, a.Compare, fmt.Sprintf("%v", a.Target)} + if a.Key != "" { + row = append(row, a.Key) + } + data = append(data, row) + } + tbl.Bulk(data) + tbl.Render() + return + } + + var buf json.RawMessage + if err := json.Unmarshal([]byte(raw), &buf); err == nil { + indented, err := json.MarshalIndent(buf, "", " ") + if err == nil { + fmt.Println(string(indented)) + return + } + } + + fmt.Println(raw) +} + +func GetMonitorResponseLogInfoWithHTTPClient(ctx context.Context, httpClient *http.Client, apiKey string, monitorId string, logId string) error { + client := NewMonitorClientWithHTTPClient(httpClient, apiKey) + return GetMonitorResponseLogInfo(ctx, client, monitorId, logId, nil) +} + +func GetMonitorLogInfoCmd() *cli.Command { + return &cli.Command{ + Name: "log-info", + Usage: "Get detailed HTTP response log for a monitor", + UsageText: `openstatus monitors log-info + openstatus monitors log-info 12345 abc-def-ghi`, + Description: "Fetch a single HTTP response log with full details including timing phases, response headers, and assertion results.", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "access-token", + Usage: "OpenStatus API Access Token", + Aliases: []string{"t"}, + Sources: cli.EnvVars("OPENSTATUS_API_TOKEN"), + }, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + apiKey, err := auth.ResolveAccessToken(cmd) + if err != nil { + return cli.Exit(err.Error(), 1) + } + monitorId := cmd.Args().Get(0) + logId := cmd.Args().Get(1) + s := output.StartSpinner("Fetching response log details...") + err = GetMonitorResponseLogInfo( + ctx, + NewMonitorClient(apiKey), + monitorId, + logId, + s, + ) + if err != nil { + return cli.Exit(err.Error(), 1) + } + return nil + }, + } +} diff --git a/internal/monitors/monitor_log_info_test.go b/internal/monitors/monitor_log_info_test.go new file mode 100644 index 0000000..fd08a35 --- /dev/null +++ b/internal/monitors/monitor_log_info_test.go @@ -0,0 +1,97 @@ +package monitors_test + +import ( + "bytes" + "context" + "io" + "log" + "net/http" + "os" + "testing" + + "github.com/openstatusHQ/cli/internal/monitors" +) + +func Test_getMonitorResponseLogInfo(t *testing.T) { + t.Parallel() + + t.Run("Successfully returns log detail", func(t *testing.T) { + body := `{"log":{"log":{"id":"log-1","latency":150,"statusCode":200,"monitorId":"1","requestStatus":"HTTP_RESPONSE_LOG_REQUEST_STATUS_SUCCESS","region":"REGION_FLY_IAD","cronTimestamp":"1715000000000","trigger":"HTTP_RESPONSE_LOG_TRIGGER_CRON","timestamp":"1715000000000","timing":{"dns":5,"connect":10,"tls":20,"ttfb":100,"transfer":15}},"url":"https://example.com","error":false,"headers":{"content-type":"application/json","x-request-id":"abc123"}}}` + r := io.NopCloser(bytes.NewReader([]byte(body))) + + interceptor := &interceptorHTTPClient{ + f: func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: r, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + }, + }, nil + }, + } + + var bf bytes.Buffer + log.SetOutput(&bf) + t.Cleanup(func() { + log.SetOutput(os.Stdout) + }) + err := monitors.GetMonitorResponseLogInfoWithHTTPClient(context.Background(), interceptor.GetHTTPClient(), "test-token", "1", "log-1") + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + + t.Run("API error returns error", func(t *testing.T) { + body := `{"code":"internal","message":"internal error"}` + r := io.NopCloser(bytes.NewReader([]byte(body))) + + interceptor := &interceptorHTTPClient{ + f: func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusInternalServerError, + Body: r, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + }, + }, nil + }, + } + + var bf bytes.Buffer + log.SetOutput(&bf) + t.Cleanup(func() { + log.SetOutput(os.Stdout) + }) + err := monitors.GetMonitorResponseLogInfoWithHTTPClient(context.Background(), interceptor.GetHTTPClient(), "test-token", "1", "log-1") + if err == nil { + t.Error("Expected error, got nil") + } + }) + + t.Run("Empty monitor ID returns error", func(t *testing.T) { + interceptor := &interceptorHTTPClient{ + f: func(req *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader([]byte(`{}`)))}, nil + }, + } + + err := monitors.GetMonitorResponseLogInfoWithHTTPClient(context.Background(), interceptor.GetHTTPClient(), "test-token", "", "log-1") + if err == nil { + t.Error("Expected error for empty monitor ID, got nil") + } + }) + + t.Run("Empty log ID returns error", func(t *testing.T) { + interceptor := &interceptorHTTPClient{ + f: func(req *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader([]byte(`{}`)))}, nil + }, + } + + err := monitors.GetMonitorResponseLogInfoWithHTTPClient(context.Background(), interceptor.GetHTTPClient(), "test-token", "1", "") + if err == nil { + t.Error("Expected error for empty log ID, got nil") + } + }) +} diff --git a/internal/monitors/monitor_logs.go b/internal/monitors/monitor_logs.go new file mode 100644 index 0000000..e21dc98 --- /dev/null +++ b/internal/monitors/monitor_logs.go @@ -0,0 +1,270 @@ +package monitors + +import ( + "context" + "fmt" + "net/http" + "os" + "time" + + monitorv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/monitor/v1" + "buf.build/gen/go/openstatus/api/connectrpc/gosimple/openstatus/monitor/v1/monitorv1connect" + "github.com/fatih/color" + "github.com/openstatusHQ/cli/internal/auth" + output "github.com/openstatusHQ/cli/internal/cli" + "github.com/rodaine/table" + "github.com/urfave/cli/v3" +) + +type responseLogEntry struct { + ID string `json:"id"` + MonitorID string `json:"monitor_id"` + StatusCode int32 `json:"status_code,omitempty"` + Latency int32 `json:"latency_ms"` + Region string `json:"region"` + RequestStatus string `json:"request_status"` + Trigger string `json:"trigger"` + Timestamp string `json:"timestamp"` +} + +type responseLogListOutput struct { + Logs []responseLogEntry `json:"logs"` + Pagination *paginationOutput `json:"pagination"` +} + +type paginationOutput struct { + Limit int32 `json:"limit"` + Offset int32 `json:"offset"` + HasMore bool `json:"has_more"` + NextOffset int32 `json:"next_offset,omitempty"` +} + +func requestStatusToString(s monitorv1.HTTPResponseLogRequestStatus) string { + switch s { + case monitorv1.HTTPResponseLogRequestStatus_HTTP_RESPONSE_LOG_REQUEST_STATUS_SUCCESS: + return "success" + case monitorv1.HTTPResponseLogRequestStatus_HTTP_RESPONSE_LOG_REQUEST_STATUS_ERROR: + return "error" + case monitorv1.HTTPResponseLogRequestStatus_HTTP_RESPONSE_LOG_REQUEST_STATUS_DEGRADED: + return "degraded" + default: + return "unknown" + } +} + +func triggerToString(t monitorv1.HTTPResponseLogTrigger) string { + switch t { + case monitorv1.HTTPResponseLogTrigger_HTTP_RESPONSE_LOG_TRIGGER_CRON: + return "cron" + case monitorv1.HTTPResponseLogTrigger_HTTP_RESPONSE_LOG_TRIGGER_API: + return "api" + default: + return "unknown" + } +} + +func colorizeRequestStatus(status string) string { + switch status { + case "success": + return color.GreenString("● success") + case "error": + return color.RedString("● error") + case "degraded": + return color.YellowString("● degraded") + default: + return "● " + status + } +} + +func formatUnixMillis(ms int64) string { + return time.UnixMilli(ms).UTC().Format(time.RFC3339) +} + +func parseRFC3339ToUnixMillis(s string) (int64, error) { + t, err := time.Parse(time.RFC3339, s) + if err != nil { + return 0, fmt.Errorf("invalid RFC 3339 timestamp %q: %w", s, err) + } + return t.UnixMilli(), nil +} + +func ListMonitorResponseLogs( + ctx context.Context, + client monitorv1connect.MonitorServiceClient, + monitorId string, + limit int32, + offset int32, + from string, + to string, + s *output.Spinner, +) error { + if monitorId == "" { + output.StopSpinner(s) + fmt.Fprintln(os.Stderr, "Usage: openstatus monitors logs ") + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, "Example: openstatus monitors logs 12345") + return fmt.Errorf("monitor ID is required") + } + + req := &monitorv1.ListMonitorHTTPResponseLogsRequest{ + Id: monitorId, + } + + if limit > 0 { + req.SetLimit(limit) + } + if offset > 0 { + req.SetOffset(offset) + } + + var fromMs, toMs int64 + if from != "" { + var err error + fromMs, err = parseRFC3339ToUnixMillis(from) + if err != nil { + output.StopSpinner(s) + return err + } + req.SetFromTimestamp(fromMs) + } + if to != "" { + var err error + toMs, err = parseRFC3339ToUnixMillis(to) + if err != nil { + output.StopSpinner(s) + return err + } + req.SetToTimestamp(toMs) + } + if from != "" && to != "" && fromMs >= toMs { + output.StopSpinner(s) + return fmt.Errorf("--from must be before --to") + } + + resp, err := client.ListMonitorHTTPResponseLogs(ctx, req) + output.StopSpinner(s) + if err != nil { + return output.FormatError(err, "response logs", monitorId) + } + + logs := resp.GetLogs() + entries := make([]responseLogEntry, 0, len(logs)) + for _, l := range logs { + entries = append(entries, responseLogEntry{ + ID: l.GetId(), + MonitorID: l.GetMonitorId(), + StatusCode: l.GetStatusCode(), + Latency: l.GetLatency(), + Region: regionToString(l.GetRegion()), + RequestStatus: requestStatusToString(l.GetRequestStatus()), + Trigger: triggerToString(l.GetTrigger()), + Timestamp: formatUnixMillis(l.GetTimestamp()), + }) + } + + var pagination *paginationOutput + if p := resp.GetPagination(); p != nil { + pagination = &paginationOutput{ + Limit: p.GetLimit(), + Offset: p.GetOffset(), + HasMore: p.GetHasMore(), + NextOffset: p.GetNextOffset(), + } + } + + if output.IsJSONOutput() { + return output.PrintJSON(responseLogListOutput{ + Logs: entries, + Pagination: pagination, + }) + } + + if len(entries) == 0 { + if !output.IsQuiet() { + fmt.Println("No response logs found") + } + return nil + } + + headerFmt := color.New(color.FgGreen, color.Underline).SprintfFunc() + columnFmt := color.New(color.FgYellow).SprintfFunc() + + tbl := table.New("ID", "Status", "Code", "Latency (ms)", "Region", "Timestamp") + tbl.WithHeaderFormatter(headerFmt).WithFirstColumnFormatter(columnFmt) + + for _, e := range entries { + tbl.AddRow(e.ID, colorizeRequestStatus(e.RequestStatus), e.StatusCode, e.Latency, e.Region, e.Timestamp) + } + + tbl.Print() + + if pagination != nil && pagination.HasMore { + fmt.Fprintf(os.Stderr, "Showing %d results. Use --offset %d to see the next page.\n", len(entries), pagination.NextOffset) + } + + return nil +} + +func ListMonitorResponseLogsWithHTTPClient(ctx context.Context, httpClient *http.Client, apiKey string, monitorId string, limit int32, offset int32, from string, to string) error { + client := NewMonitorClientWithHTTPClient(httpClient, apiKey) + return ListMonitorResponseLogs(ctx, client, monitorId, limit, offset, from, to, nil) +} + +func GetMonitorLogsCmd() *cli.Command { + return &cli.Command{ + Name: "logs", + Usage: "List HTTP response logs for a monitor", + UsageText: `openstatus monitors logs + openstatus monitors logs 12345 + openstatus monitors logs 12345 --limit 10 + openstatus monitors logs 12345 --limit 5 --offset 5 + openstatus monitors logs 12345 --from 2026-05-06T00:00:00Z --to 2026-05-07T00:00:00Z`, + Description: "List HTTP response logs for a monitor from the 14-day retention window. Supports pagination and time filtering.", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "access-token", + Usage: "OpenStatus API Access Token", + Aliases: []string{"t"}, + Sources: cli.EnvVars("OPENSTATUS_API_TOKEN"), + }, + &cli.IntFlag{ + Name: "limit", + Usage: "Maximum number of logs to return (1-100)", + }, + &cli.IntFlag{ + Name: "offset", + Usage: "Number of logs to skip for pagination", + }, + &cli.StringFlag{ + Name: "from", + Usage: "Start of time window (RFC 3339 format)", + }, + &cli.StringFlag{ + Name: "to", + Usage: "End of time window (RFC 3339 format)", + }, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + apiKey, err := auth.ResolveAccessToken(cmd) + if err != nil { + return cli.Exit(err.Error(), 1) + } + monitorId := cmd.Args().Get(0) + s := output.StartSpinner("Fetching response logs...") + err = ListMonitorResponseLogs( + ctx, + NewMonitorClient(apiKey), + monitorId, + int32(cmd.Int("limit")), + int32(cmd.Int("offset")), + cmd.String("from"), + cmd.String("to"), + s, + ) + if err != nil { + return cli.Exit(err.Error(), 1) + } + return nil + }, + } +} diff --git a/internal/monitors/monitor_logs_test.go b/internal/monitors/monitor_logs_test.go new file mode 100644 index 0000000..8311430 --- /dev/null +++ b/internal/monitors/monitor_logs_test.go @@ -0,0 +1,111 @@ +package monitors_test + +import ( + "bytes" + "context" + "io" + "log" + "net/http" + "os" + "testing" + + "github.com/openstatusHQ/cli/internal/monitors" +) + +func Test_listMonitorResponseLogs(t *testing.T) { + t.Parallel() + + t.Run("Successfully returns logs", func(t *testing.T) { + body := `{"logs":[{"id":"log-1","latency":123,"statusCode":200,"monitorId":"1","requestStatus":"HTTP_RESPONSE_LOG_REQUEST_STATUS_SUCCESS","region":"REGION_FLY_IAD","cronTimestamp":"1715000000000","trigger":"HTTP_RESPONSE_LOG_TRIGGER_CRON","timestamp":"1715000000000"}],"pagination":{"limit":25,"offset":0,"hasMore":false}}` + r := io.NopCloser(bytes.NewReader([]byte(body))) + + interceptor := &interceptorHTTPClient{ + f: func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: r, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + }, + }, nil + }, + } + + var bf bytes.Buffer + log.SetOutput(&bf) + t.Cleanup(func() { + log.SetOutput(os.Stdout) + }) + err := monitors.ListMonitorResponseLogsWithHTTPClient(context.Background(), interceptor.GetHTTPClient(), "test-token", "1", 0, 0, "", "") + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + + t.Run("Returns empty list", func(t *testing.T) { + body := `{"logs":[],"pagination":{"limit":25,"offset":0,"hasMore":false}}` + r := io.NopCloser(bytes.NewReader([]byte(body))) + + interceptor := &interceptorHTTPClient{ + f: func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: r, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + }, + }, nil + }, + } + + var bf bytes.Buffer + log.SetOutput(&bf) + t.Cleanup(func() { + log.SetOutput(os.Stdout) + }) + err := monitors.ListMonitorResponseLogsWithHTTPClient(context.Background(), interceptor.GetHTTPClient(), "test-token", "1", 0, 0, "", "") + if err != nil { + t.Errorf("Expected no error, got %v", err) + } + }) + + t.Run("API error returns error", func(t *testing.T) { + body := `{"code":"internal","message":"internal error"}` + r := io.NopCloser(bytes.NewReader([]byte(body))) + + interceptor := &interceptorHTTPClient{ + f: func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusInternalServerError, + Body: r, + Header: http.Header{ + "Content-Type": []string{"application/json"}, + }, + }, nil + }, + } + + var bf bytes.Buffer + log.SetOutput(&bf) + t.Cleanup(func() { + log.SetOutput(os.Stdout) + }) + err := monitors.ListMonitorResponseLogsWithHTTPClient(context.Background(), interceptor.GetHTTPClient(), "test-token", "1", 0, 0, "", "") + if err == nil { + t.Error("Expected error, got nil") + } + }) + + t.Run("Empty monitor ID returns error", func(t *testing.T) { + interceptor := &interceptorHTTPClient{ + f: func(req *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader([]byte(`{}`)))}, nil + }, + } + + err := monitors.ListMonitorResponseLogsWithHTTPClient(context.Background(), interceptor.GetHTTPClient(), "test-token", "", 0, 0, "", "") + if err == nil { + t.Error("Expected error for empty monitor ID, got nil") + } + }) +} diff --git a/internal/monitors/monitors.go b/internal/monitors/monitors.go index 8449f65..db678b1 100644 --- a/internal/monitors/monitors.go +++ b/internal/monitors/monitors.go @@ -543,6 +543,8 @@ func MonitorsCmd() *cli.Command { GetMonitorImportCmd(), GetMonitorInfoCmd(), GetMonitorsListCmd(), + GetMonitorLogsCmd(), + GetMonitorLogInfoCmd(), GetMonitorsTriggerCmd(), }, } diff --git a/internal/monitors/monitors_test.go b/internal/monitors/monitors_test.go index 1c43ddf..1d13721 100644 --- a/internal/monitors/monitors_test.go +++ b/internal/monitors/monitors_test.go @@ -43,18 +43,20 @@ func Test_MonitorsCmd(t *testing.T) { t.Run("Has expected subcommands", func(t *testing.T) { cmd := monitors.MonitorsCmd() - if len(cmd.Commands) != 7 { - t.Errorf("Expected 7 subcommands, got %d", len(cmd.Commands)) + if len(cmd.Commands) != 9 { + t.Errorf("Expected 9 subcommands, got %d", len(cmd.Commands)) } expectedSubcommands := map[string]bool{ - "apply": false, - "create": false, - "delete": false, - "import": false, - "info": false, - "list": false, - "trigger": false, + "apply": false, + "create": false, + "delete": false, + "import": false, + "info": false, + "list": false, + "logs": false, + "log-info": false, + "trigger": false, } for _, subcmd := range cmd.Commands {