Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions docs/openstatus-docs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <MonitorID>
> 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 <MonitorID> <LogID>
> 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.
Expand Down
6 changes: 3 additions & 3 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 6 additions & 6 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
276 changes: 276 additions & 0 deletions internal/monitors/monitor_log_info.go
Original file line number Diff line number Diff line change
@@ -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 <monitor-id> <log-id>")
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 <monitor-id> <log-id>")
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 <MonitorID> <LogID>
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
},
}
}
Loading
Loading