Skip to content

Commit 3803b9d

Browse files
feat: add monitors logs and log-info commands for HTTP response logs (#27)
1 parent 311cea6 commit 3803b9d

9 files changed

Lines changed: 825 additions & 18 deletions

File tree

docs/openstatus-docs.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,55 @@ The following flags are supported:
166166
| `--all` | List all monitors including inactive ones | `false` | *none* |
167167
| `--access-token="…"` (`-t`) | OpenStatus API Access Token | | `OPENSTATUS_API_TOKEN` |
168168

169+
### `monitors logs` subcommand
170+
171+
List HTTP response logs for a monitor.
172+
173+
> openstatus monitors logs <MonitorID>
174+
> openstatus monitors logs 12345
175+
> openstatus monitors logs 12345 --limit 10
176+
> openstatus monitors logs 12345 --limit 5 --offset 5
177+
> openstatus monitors logs 12345 --from 2026-05-06T00:00:00Z --to 2026-05-07T00:00:00Z
178+
179+
List HTTP response logs for a monitor from the 14-day retention window. Supports pagination and time filtering.
180+
181+
Usage:
182+
183+
```bash
184+
$ openstatus [GLOBAL FLAGS] monitors logs [COMMAND FLAGS] [ARGUMENTS...]
185+
```
186+
187+
The following flags are supported:
188+
189+
| Name | Description | Default value | Environment variables |
190+
|-----------------------------|------------------------------------------|:-------------:|:----------------------:|
191+
| `--access-token="…"` (`-t`) | OpenStatus API Access Token | | `OPENSTATUS_API_TOKEN` |
192+
| `--limit="…"` | Maximum number of logs to return (1-100) | `0` | *none* |
193+
| `--offset="…"` | Number of logs to skip for pagination | `0` | *none* |
194+
| `--from="…"` | Start of time window (RFC 3339 format) | | *none* |
195+
| `--to="…"` | End of time window (RFC 3339 format) | | *none* |
196+
197+
### `monitors log-info` subcommand
198+
199+
Get detailed HTTP response log for a monitor.
200+
201+
> openstatus monitors log-info <MonitorID> <LogID>
202+
> openstatus monitors log-info 12345 abc-def-ghi
203+
204+
Fetch a single HTTP response log with full details including timing phases, response headers, and assertion results.
205+
206+
Usage:
207+
208+
```bash
209+
$ openstatus [GLOBAL FLAGS] monitors log-info [COMMAND FLAGS] [ARGUMENTS...]
210+
```
211+
212+
The following flags are supported:
213+
214+
| Name | Description | Default value | Environment variables |
215+
|-----------------------------|-----------------------------|:-------------:|:----------------------:|
216+
| `--access-token="…"` (`-t`) | OpenStatus API Access Token | | `OPENSTATUS_API_TOKEN` |
217+
169218
### `monitors trigger` subcommand
170219

171220
Trigger a monitor execution.

go.mod

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ go 1.25
55
require github.com/urfave/cli/v3 v3.0.0-alpha9.2 // direct
66

77
require (
8-
buf.build/gen/go/openstatus/api/connectrpc/gosimple v1.19.1-20260323160652-4be687fa490b.2
9-
buf.build/gen/go/openstatus/api/protocolbuffers/go v1.36.11-20260323160652-4be687fa490b.1
10-
connectrpc.com/connect v1.19.1
8+
buf.build/gen/go/openstatus/api/connectrpc/gosimple v1.19.2-20260505152507-ee6c0b5379e7.1
9+
buf.build/gen/go/openstatus/api/protocolbuffers/go v1.36.11-20260505152507-ee6c0b5379e7.1
10+
connectrpc.com/connect v1.19.2
1111
github.com/briandowns/spinner v1.23.2
1212
github.com/charmbracelet/huh v1.0.0
1313
github.com/fatih/color v1.18.0

go.sum

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@ buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-202512091757
22
buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20251209175733-2a1774d88802.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM=
33
buf.build/gen/go/gnostic/gnostic/protocolbuffers/go v1.36.11-20230414000709-087bc8072ce4.1 h1:t8f+WWZ5WNrZaP5zrpWD8f1tKU7eelJMbIAT9FRX558=
44
buf.build/gen/go/gnostic/gnostic/protocolbuffers/go v1.36.11-20230414000709-087bc8072ce4.1/go.mod h1:/t9AeRQQp2iNkiGHDLfHLW3SzNpYpNPGRZ+Ih8+SOUs=
5-
buf.build/gen/go/openstatus/api/connectrpc/gosimple v1.19.1-20260323160652-4be687fa490b.2 h1:AHDJQUgykCBDBE8qx4ewHxk4q29BYin4I5k2VZBJoSI=
6-
buf.build/gen/go/openstatus/api/connectrpc/gosimple v1.19.1-20260323160652-4be687fa490b.2/go.mod h1:M+ceX35kH8wh3cgoFD8o2fNVedIx8NB9LWV7Z0ObO6Y=
7-
buf.build/gen/go/openstatus/api/protocolbuffers/go v1.36.11-20260323160652-4be687fa490b.1 h1:sTK/97BsCzbBtqydnKAyRduWLbm1U7LbOk+hAzOikhs=
8-
buf.build/gen/go/openstatus/api/protocolbuffers/go v1.36.11-20260323160652-4be687fa490b.1/go.mod h1:GRsD/In1AV3/RC92zWVAjBpj57hJIQm7Rph0rLOYYPg=
9-
connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14=
10-
connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w=
5+
buf.build/gen/go/openstatus/api/connectrpc/gosimple v1.19.2-20260505152507-ee6c0b5379e7.1 h1:d8BfHZWqseh5LI5Or77MOExFOQB6PvJqWQYrZ6E2stY=
6+
buf.build/gen/go/openstatus/api/connectrpc/gosimple v1.19.2-20260505152507-ee6c0b5379e7.1/go.mod h1:ENvll+poGWfEPVEr6u0EWr+zKbVk+P2JjfoJ1KUvFoU=
7+
buf.build/gen/go/openstatus/api/protocolbuffers/go v1.36.11-20260505152507-ee6c0b5379e7.1 h1:SA8oRs3V26timaNILiVJ7jydtg0lA7WjvkgdkWDrsO4=
8+
buf.build/gen/go/openstatus/api/protocolbuffers/go v1.36.11-20260505152507-ee6c0b5379e7.1/go.mod h1:GRsD/In1AV3/RC92zWVAjBpj57hJIQm7Rph0rLOYYPg=
9+
connectrpc.com/connect v1.19.2 h1:McQ83FGdzL+t60peksi0gXC7MQ/iLKgLduAnThbM0mo=
10+
connectrpc.com/connect v1.19.2/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w=
1111
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
1212
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
1313
github.com/agext/levenshtein v1.2.1 h1:QmvMAjj2aEICytGiWzmxoE0x2KZvE0fvmqMOfy2tjT8=
Lines changed: 276 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,276 @@
1+
package monitors
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"net/http"
8+
"os"
9+
"sort"
10+
"strings"
11+
12+
monitorv1 "buf.build/gen/go/openstatus/api/protocolbuffers/go/openstatus/monitor/v1"
13+
"buf.build/gen/go/openstatus/api/connectrpc/gosimple/openstatus/monitor/v1/monitorv1connect"
14+
"github.com/fatih/color"
15+
"github.com/logrusorgru/aurora/v4"
16+
"github.com/openstatusHQ/cli/internal/auth"
17+
output "github.com/openstatusHQ/cli/internal/cli"
18+
"github.com/urfave/cli/v3"
19+
)
20+
21+
type responseLogDetailOutput struct {
22+
ID string `json:"id"`
23+
MonitorID string `json:"monitor_id"`
24+
URL string `json:"url"`
25+
StatusCode int32 `json:"status_code,omitempty"`
26+
Latency int32 `json:"latency_ms"`
27+
Region string `json:"region"`
28+
RequestStatus string `json:"request_status"`
29+
Trigger string `json:"trigger"`
30+
Timestamp string `json:"timestamp"`
31+
Error bool `json:"error"`
32+
Message string `json:"message,omitempty"`
33+
Headers map[string]string `json:"headers,omitempty"`
34+
Assertions string `json:"assertions,omitempty"`
35+
Timing *timingOutput `json:"timing,omitempty"`
36+
}
37+
38+
type timingOutput struct {
39+
DNS int32 `json:"dns_ms"`
40+
Connect int32 `json:"connect_ms"`
41+
TLS int32 `json:"tls_ms"`
42+
TTFB int32 `json:"ttfb_ms"`
43+
Transfer int32 `json:"transfer_ms"`
44+
}
45+
46+
func GetMonitorResponseLogInfo(
47+
ctx context.Context,
48+
client monitorv1connect.MonitorServiceClient,
49+
monitorId string,
50+
logId string,
51+
s *output.Spinner,
52+
) error {
53+
if monitorId == "" {
54+
output.StopSpinner(s)
55+
fmt.Fprintln(os.Stderr, "Usage: openstatus monitors log-info <monitor-id> <log-id>")
56+
fmt.Fprintln(os.Stderr, "")
57+
fmt.Fprintln(os.Stderr, "Example: openstatus monitors log-info 12345 abc-def")
58+
return fmt.Errorf("monitor ID is required")
59+
}
60+
if logId == "" {
61+
output.StopSpinner(s)
62+
fmt.Fprintln(os.Stderr, "Usage: openstatus monitors log-info <monitor-id> <log-id>")
63+
fmt.Fprintln(os.Stderr, "")
64+
fmt.Fprintln(os.Stderr, "Example: openstatus monitors log-info 12345 abc-def")
65+
return fmt.Errorf("log ID is required")
66+
}
67+
68+
resp, err := client.GetMonitorHTTPResponseLog(ctx, &monitorv1.GetMonitorHTTPResponseLogRequest{
69+
Id: monitorId,
70+
LogId: logId,
71+
})
72+
output.StopSpinner(s)
73+
if err != nil {
74+
return output.FormatError(err, "response log", logId)
75+
}
76+
77+
detail := resp.GetLog()
78+
logItem := detail.GetLog()
79+
80+
var timing *timingOutput
81+
if logItem.HasTiming() {
82+
t := logItem.GetTiming()
83+
timing = &timingOutput{
84+
DNS: t.GetDns(),
85+
Connect: t.GetConnect(),
86+
TLS: t.GetTls(),
87+
TTFB: t.GetTtfb(),
88+
Transfer: t.GetTransfer(),
89+
}
90+
}
91+
92+
detailOut := responseLogDetailOutput{
93+
ID: logItem.GetId(),
94+
MonitorID: logItem.GetMonitorId(),
95+
URL: detail.GetUrl(),
96+
StatusCode: logItem.GetStatusCode(),
97+
Latency: logItem.GetLatency(),
98+
Region: regionToString(logItem.GetRegion()),
99+
RequestStatus: requestStatusToString(logItem.GetRequestStatus()),
100+
Trigger: triggerToString(logItem.GetTrigger()),
101+
Timestamp: formatUnixMillis(logItem.GetTimestamp()),
102+
Error: detail.GetError(),
103+
Message: detail.GetMessage(),
104+
Headers: detail.GetHeaders(),
105+
Assertions: detail.GetAssertions(),
106+
Timing: timing,
107+
}
108+
109+
if output.IsJSONOutput() {
110+
return output.PrintJSON(detailOut)
111+
}
112+
113+
// Section 1: Response Log
114+
fmt.Println(aurora.Bold("Response Log:"))
115+
tbl := newBlueprintTable()
116+
data := [][]string{
117+
{"ID", detailOut.ID},
118+
{"Monitor ID", detailOut.MonitorID},
119+
{"URL", detailOut.URL},
120+
{"Status", colorizeRequestStatus(detailOut.RequestStatus)},
121+
{"Status Code", fmt.Sprintf("%d", detailOut.StatusCode)},
122+
{"Latency", fmt.Sprintf("%d ms", detailOut.Latency)},
123+
{"Region", detailOut.Region},
124+
{"Trigger", detailOut.Trigger},
125+
{"Timestamp", detailOut.Timestamp},
126+
}
127+
if detailOut.Error {
128+
msg := detailOut.Message
129+
if msg == "" {
130+
msg = "yes"
131+
}
132+
data = append(data, []string{"Error", color.RedString(msg)})
133+
}
134+
tbl.Bulk(data)
135+
tbl.Render()
136+
137+
// Section 2: Timing waterfall
138+
if timing != nil {
139+
fmt.Println()
140+
fmt.Println(aurora.Bold("Timing:"))
141+
renderTimingWaterfall(timing)
142+
}
143+
144+
// Section 3: Response Headers
145+
if len(detailOut.Headers) > 0 {
146+
fmt.Println()
147+
fmt.Println(aurora.Bold("Response Headers:"))
148+
headerTable := newBlueprintTable()
149+
keys := make([]string, 0, len(detailOut.Headers))
150+
for k := range detailOut.Headers {
151+
keys = append(keys, k)
152+
}
153+
sort.Strings(keys)
154+
var headerData [][]string
155+
for _, k := range keys {
156+
headerData = append(headerData, []string{k, detailOut.Headers[k]})
157+
}
158+
headerTable.Bulk(headerData)
159+
headerTable.Render()
160+
}
161+
162+
// Section 4: Assertions
163+
if detailOut.Assertions != "" {
164+
fmt.Println()
165+
fmt.Println(aurora.Bold("Assertions:"))
166+
renderAssertions(detailOut.Assertions)
167+
}
168+
169+
return nil
170+
}
171+
172+
func renderTimingWaterfall(t *timingOutput) {
173+
phases := []struct {
174+
name string
175+
ms int32
176+
}{
177+
{"DNS", t.DNS},
178+
{"Connect", t.Connect},
179+
{"TLS", t.TLS},
180+
{"TTFB", t.TTFB},
181+
{"Transfer", t.Transfer},
182+
}
183+
184+
total := t.DNS + t.Connect + t.TLS + t.TTFB + t.Transfer
185+
const maxBarWidth = 30
186+
187+
tbl := newBlueprintTable()
188+
var data [][]string
189+
for _, p := range phases {
190+
bar := ""
191+
if total > 0 && p.ms > 0 {
192+
width := int(float64(p.ms) / float64(total) * maxBarWidth)
193+
if width < 1 {
194+
width = 1
195+
}
196+
bar = color.CyanString(strings.Repeat("█", width))
197+
}
198+
data = append(data, []string{p.name, fmt.Sprintf("%d ms", p.ms), bar})
199+
}
200+
data = append(data, []string{"─────", "─────────", ""})
201+
data = append(data, []string{"Total", fmt.Sprintf("%d ms", total), ""})
202+
tbl.Bulk(data)
203+
tbl.Render()
204+
}
205+
206+
func renderAssertions(raw string) {
207+
var assertions []Assertion
208+
if err := json.Unmarshal([]byte(raw), &assertions); err == nil && len(assertions) > 0 {
209+
tbl := newBlueprintTable()
210+
var data [][]string
211+
for _, a := range assertions {
212+
row := []string{a.Type, a.Compare, fmt.Sprintf("%v", a.Target)}
213+
if a.Key != "" {
214+
row = append(row, a.Key)
215+
}
216+
data = append(data, row)
217+
}
218+
tbl.Bulk(data)
219+
tbl.Render()
220+
return
221+
}
222+
223+
var buf json.RawMessage
224+
if err := json.Unmarshal([]byte(raw), &buf); err == nil {
225+
indented, err := json.MarshalIndent(buf, "", " ")
226+
if err == nil {
227+
fmt.Println(string(indented))
228+
return
229+
}
230+
}
231+
232+
fmt.Println(raw)
233+
}
234+
235+
func GetMonitorResponseLogInfoWithHTTPClient(ctx context.Context, httpClient *http.Client, apiKey string, monitorId string, logId string) error {
236+
client := NewMonitorClientWithHTTPClient(httpClient, apiKey)
237+
return GetMonitorResponseLogInfo(ctx, client, monitorId, logId, nil)
238+
}
239+
240+
func GetMonitorLogInfoCmd() *cli.Command {
241+
return &cli.Command{
242+
Name: "log-info",
243+
Usage: "Get detailed HTTP response log for a monitor",
244+
UsageText: `openstatus monitors log-info <MonitorID> <LogID>
245+
openstatus monitors log-info 12345 abc-def-ghi`,
246+
Description: "Fetch a single HTTP response log with full details including timing phases, response headers, and assertion results.",
247+
Flags: []cli.Flag{
248+
&cli.StringFlag{
249+
Name: "access-token",
250+
Usage: "OpenStatus API Access Token",
251+
Aliases: []string{"t"},
252+
Sources: cli.EnvVars("OPENSTATUS_API_TOKEN"),
253+
},
254+
},
255+
Action: func(ctx context.Context, cmd *cli.Command) error {
256+
apiKey, err := auth.ResolveAccessToken(cmd)
257+
if err != nil {
258+
return cli.Exit(err.Error(), 1)
259+
}
260+
monitorId := cmd.Args().Get(0)
261+
logId := cmd.Args().Get(1)
262+
s := output.StartSpinner("Fetching response log details...")
263+
err = GetMonitorResponseLogInfo(
264+
ctx,
265+
NewMonitorClient(apiKey),
266+
monitorId,
267+
logId,
268+
s,
269+
)
270+
if err != nil {
271+
return cli.Exit(err.Error(), 1)
272+
}
273+
return nil
274+
},
275+
}
276+
}

0 commit comments

Comments
 (0)