diff --git a/apps/checker/checker/icmp.go b/apps/checker/checker/icmp.go new file mode 100644 index 000000000..f13393777 --- /dev/null +++ b/apps/checker/checker/icmp.go @@ -0,0 +1,291 @@ +package checker + +import ( + "fmt" + "net" + "os" + "sync/atomic" + "time" + + "golang.org/x/net/icmp" + "golang.org/x/net/ipv4" + "golang.org/x/net/ipv6" +) + +// icmpEchoCounter hands each probe its own echo identifier. A raw socket +// receives every ICMP packet delivered to the host, so probes are told apart by +// the echo id alone; a per-process value (the pid) makes two concurrent probes +// to the same target indistinguishable. Seeded from the pid so a restart does +// not immediately reuse the ids of packets still in flight. +var icmpEchoCounter = func() *atomic.Uint32 { + var c atomic.Uint32 + c.Store(uint32(os.Getpid())) + return &c +}() + +func nextEchoID() int { + return int(icmpEchoCounter.Add(1) & 0xffff) +} + +const ( + icmpPacketCount = 3 + icmpPacketInterval = 100 * time.Millisecond + // Applied when a caller omits the timeout. Without it the deadline below + // lands in the past, the send loop breaks before the first packet, and the + // check reports "no reply" having probed nothing. + icmpDefaultTimeout = 45_000 +) + +type ICMPResponseTiming struct { + // RTTs holds one entry per sent packet in send order; -1 marks a lost packet. + RTTs []int64 `json:"rtts"` +} + +type ICMPResult struct { + Timing ICMPResponseTiming + Latency int64 + LatencyMin int64 + LatencyMax int64 + PacketsSent uint8 + PacketsReceived uint8 +} + +type ICMPResponse struct { + Region string `json:"region"` + ErrorMessage string `json:"errorMessage"` + JobType string `json:"jobType"` + RequestId int64 `json:"requestId,omitempty"` + WorkspaceID int64 `json:"workspaceId"` + MonitorID int64 `json:"monitorId"` + Timestamp int64 `json:"timestamp"` + Latency int64 `json:"latency"` + LatencyMin int64 `json:"latencyMin"` + LatencyMax int64 `json:"latencyMax"` + PacketsSent uint8 `json:"packetsSent"` + PacketsReceived uint8 `json:"packetsReceived"` + Timing ICMPResponseTiming `json:"timing"` + Error uint8 `json:"error,omitempty"` +} + +func PingICMP(timeoutMs int64, hostname string) (ICMPResult, error) { + if timeoutMs <= 0 { + timeoutMs = icmpDefaultTimeout + } + + dst, err := net.ResolveIPAddr("ip", hostname) + if err != nil { + return ICMPResult{}, fmt.Errorf("resolve error: %w", err) + } + + var ( + udpNetwork string + rawNetwork string + proto int + echoType icmp.Type + ) + if dst.IP.To4() != nil { + udpNetwork, rawNetwork, proto, echoType = "udp4", "ip4:icmp", ipv4.ICMPTypeEcho.Protocol(), ipv4.ICMPTypeEcho + } else { + udpNetwork, rawNetwork, proto, echoType = "udp6", "ip6:ipv6-icmp", ipv6.ICMPTypeEchoRequest.Protocol(), ipv6.ICMPTypeEchoRequest + } + + conn, isRaw, err := listenICMP(udpNetwork, rawNetwork) + if err != nil { + return ICMPResult{}, fmt.Errorf("icmp socket error: %w", err) + } + defer conn.Close() + + deadline := time.Now().Add(time.Duration(timeoutMs) * time.Millisecond) + id := nextEchoID() + + timing := ICMPResponseTiming{RTTs: make([]int64, 0, icmpPacketCount)} + received := make([]int64, 0, icmpPacketCount) + var packetsSent uint8 + var lastErr error + + for seq := 0; seq < icmpPacketCount; seq++ { + if seq > 0 { + time.Sleep(icmpPacketInterval) + } + + remaining := time.Until(deadline) + if remaining <= 0 { + break + } + perPacket := remaining / time.Duration(icmpPacketCount-seq) + packetsSent++ + + rtt, err := sendEcho(conn, isRaw, proto, echoType, dst, id, seq, time.Now().Add(perPacket)) + if err != nil { + lastErr = err + timing.RTTs = append(timing.RTTs, -1) + continue + } + timing.RTTs = append(timing.RTTs, rtt) + received = append(received, rtt) + } + + if len(received) == 0 { + if lastErr != nil { + return ICMPResult{}, lastErr + } + return ICMPResult{}, fmt.Errorf("no reply from %s", hostname) + } + + var sum, min, max int64 + for i, rtt := range received { + sum += rtt + if i == 0 || rtt < min { + min = rtt + } + if i == 0 || rtt > max { + max = rtt + } + } + + return ICMPResult{ + Timing: timing, + Latency: sum / int64(len(received)), + LatencyMin: min, + LatencyMax: max, + PacketsSent: packetsSent, + PacketsReceived: uint8(len(received)), + }, nil +} + +// listenICMP prefers an unprivileged datagram socket and falls back to a raw +// socket when the runtime lacks ping_group_range but holds CAP_NET_RAW. +func listenICMP(udpNetwork, rawNetwork string) (*icmp.PacketConn, bool, error) { + udpBind, rawBind := "0.0.0.0", "0.0.0.0" + if udpNetwork == "udp6" { + udpBind, rawBind = "::", "::" + } + + conn, err := icmp.ListenPacket(udpNetwork, udpBind) + if err == nil { + return conn, false, nil + } + + rawConn, rawErr := icmp.ListenPacket(rawNetwork, rawBind) + if rawErr != nil { + return nil, false, fmt.Errorf("udp: %v, raw: %w", err, rawErr) + } + return rawConn, true, nil +} + +func sendEcho(conn *icmp.PacketConn, isRaw bool, proto int, echoType icmp.Type, dst *net.IPAddr, id, seq int, deadline time.Time) (int64, error) { + var writeAddr net.Addr = &net.UDPAddr{IP: dst.IP, Zone: dst.Zone} + if isRaw { + writeAddr = &net.IPAddr{IP: dst.IP, Zone: dst.Zone} + } + + msg := icmp.Message{ + Type: echoType, + Code: 0, + Body: &icmp.Echo{ID: id, Seq: seq, Data: []byte("openstatus")}, + } + wb, err := msg.Marshal(nil) + if err != nil { + return 0, fmt.Errorf("marshal error: %w", err) + } + + if err := conn.SetDeadline(deadline); err != nil { + return 0, err + } + + start := time.Now() + if _, err := conn.WriteTo(wb, writeAddr); err != nil { + return 0, fmt.Errorf("write error: %w", err) + } + + rb := make([]byte, 1500) + for { + n, peer, err := conn.ReadFrom(rb) + if err != nil { + if ne, ok := err.(net.Error); ok && ne.Timeout() { + return 0, fmt.Errorf("timeout") + } + return 0, fmt.Errorf("read error: %w", err) + } + + rm, err := icmp.ParseMessage(proto, rb[:n]) + if err != nil { + continue + } + + switch body := rm.Body.(type) { + case *icmp.Echo: + // A reply to our probe can only come from the target. A raw socket + // is not demultiplexed the way the datagram path is, so without + // this it also sees replies belonging to other probes. + if !addrIP(peer).Equal(dst.IP) { + continue + } + // The kernel rewrites the Echo ID on datagram sockets, so only raw + // sockets can trust it; datagram replies are matched on Seq alone. + if body.Seq != seq || (isRaw && body.ID != id) { + continue + } + return time.Since(start).Milliseconds(), nil + case *icmp.DstUnreach: + // Errors come from whichever hop rejected the packet, not from the + // target, so the peer says nothing about ownership — the quoted + // datagram does. + if !provokedByProbe(body.Data, proto, id, seq, isRaw) { + continue + } + return 0, fmt.Errorf("destination unreachable") + case *icmp.TimeExceeded: + if !provokedByProbe(body.Data, proto, id, seq, isRaw) { + continue + } + return 0, fmt.Errorf("time exceeded") + } + } +} + +// addrIP pulls the IP out of the address shape each socket type reports: +// *net.IPAddr for raw, *net.UDPAddr for the unprivileged datagram path. +func addrIP(addr net.Addr) net.IP { + switch a := addr.(type) { + case *net.IPAddr: + return a.IP + case *net.UDPAddr: + return a.IP + } + return nil +} + +// provokedByProbe reports whether an ICMP error quotes the packet we sent. The +// error carries the original datagram — IP header plus at least its first eight +// bytes, which is the whole echo header — so the quoted id and sequence +// identify the sender. Without this a raw socket would treat an unrelated +// flow's "destination unreachable" as its own probe failing. +func provokedByProbe(data []byte, proto, id, seq int, isRaw bool) bool { + var quoted []byte + switch proto { + case ipv4.ICMPTypeEcho.Protocol(): + h, err := icmp.ParseIPv4Header(data) + if err != nil || len(data) < h.Len { + return false + } + quoted = data[h.Len:] + default: + if len(data) < ipv6.HeaderLen { + return false + } + quoted = data[ipv6.HeaderLen:] + } + + msg, err := icmp.ParseMessage(proto, quoted) + if err != nil { + return false + } + echo, ok := msg.Body.(*icmp.Echo) + if !ok { + return false + } + // Same asymmetry as the echo path: the kernel owns the id on datagram + // sockets, so only raw probes can match on it. + return echo.Seq == seq && (!isRaw || echo.ID == id) +} diff --git a/apps/checker/checker/icmp_internal_test.go b/apps/checker/checker/icmp_internal_test.go new file mode 100644 index 000000000..2f5a110a4 --- /dev/null +++ b/apps/checker/checker/icmp_internal_test.go @@ -0,0 +1,149 @@ +package checker + +import ( + "net" + "sync" + "testing" + + "golang.org/x/net/icmp" + "golang.org/x/net/ipv4" + "golang.org/x/net/ipv6" +) + +func TestNextEchoID_DistinguishesConcurrentProbes(t *testing.T) { + // A raw socket tells probes apart by echo id alone, so two probes running + // at once must never share one — a per-process id (the pid) did. + const n = 512 + + var wg sync.WaitGroup + ids := make([]int, n) + for i := range n { + wg.Add(1) + go func() { + defer wg.Done() + ids[i] = nextEchoID() + }() + } + wg.Wait() + + seen := make(map[int]struct{}, n) + for _, id := range ids { + if _, dup := seen[id]; dup { + t.Fatalf("nextEchoID() handed out %d twice across %d concurrent probes", id, n) + } + seen[id] = struct{}{} + if id < 0 || id > 0xffff { + t.Errorf("nextEchoID() = %d, outside the 16-bit echo id field", id) + } + } +} + +func TestAddrIP(t *testing.T) { + ip := net.ParseIP("192.0.2.7") + + for _, tc := range []struct { + name string + addr net.Addr + want net.IP + }{ + {name: "raw socket", addr: &net.IPAddr{IP: ip}, want: ip}, + {name: "datagram socket", addr: &net.UDPAddr{IP: ip}, want: ip}, + {name: "unknown shape", addr: &net.TCPAddr{IP: ip}, want: nil}, + {name: "nil", addr: nil, want: nil}, + } { + t.Run(tc.name, func(t *testing.T) { + got := addrIP(tc.addr) + if !got.Equal(tc.want) { + t.Errorf("addrIP() = %v, want %v", got, tc.want) + } + // An unrecognised address must never be mistaken for the target. + if tc.want == nil && got.Equal(ip) { + t.Error("addrIP() matched the target for an address it cannot read") + } + }) + } +} + +// quotedError builds the payload an ICMP error carries: the original IP header +// followed by the datagram that provoked it. +func quotedError(t *testing.T, v4 bool, id, seq int) []byte { + t.Helper() + + echoType := icmp.Type(ipv4.ICMPTypeEcho) + if !v4 { + echoType = ipv6.ICMPTypeEchoRequest + } + echo, err := (&icmp.Message{ + Type: echoType, + Body: &icmp.Echo{ID: id, Seq: seq, Data: []byte("openstatus")}, + }).Marshal(nil) + if err != nil { + t.Fatalf("marshal echo: %v", err) + } + + if v4 { + // Minimal 20-byte IPv4 header; only the length nibble is read back. + header := make([]byte, 20) + header[0] = 0x45 + return append(header, echo...) + } + return append(make([]byte, ipv6.HeaderLen), echo...) +} + +func TestProvokedByProbe(t *testing.T) { + const ( + v4proto = 1 // ipv4.ICMPTypeEcho.Protocol() + id = 4242 + seq = 1 + ) + + t.Run("accepts the error quoting our own probe", func(t *testing.T) { + data := quotedError(t, true, id, seq) + if !provokedByProbe(data, v4proto, id, seq, true) { + t.Error("provokedByProbe() rejected an error quoting this probe") + } + }) + + t.Run("rejects another probe's error on the raw path", func(t *testing.T) { + // The cross-talk case: same sequence (0/1/2 are always reused), a + // different probe's id. A raw socket sees this packet too. + data := quotedError(t, true, id+1, seq) + if provokedByProbe(data, v4proto, id, seq, true) { + t.Error("provokedByProbe() accepted an error belonging to another probe") + } + }) + + t.Run("rejects a different sequence", func(t *testing.T) { + data := quotedError(t, true, id, seq+1) + if provokedByProbe(data, v4proto, id, seq, true) { + t.Error("provokedByProbe() accepted an error for a different packet") + } + }) + + t.Run("ignores the id on the datagram path", func(t *testing.T) { + // The kernel rewrites the id on datagram sockets, so the quoted value + // is not ours — but that socket is demultiplexed, so seq is enough. + data := quotedError(t, true, id+1, seq) + if !provokedByProbe(data, v4proto, id, seq, false) { + t.Error("provokedByProbe() matched on an id the kernel owns") + } + }) + + t.Run("rejects a truncated or unparsable quote", func(t *testing.T) { + for _, data := range [][]byte{nil, {}, {0x45}, make([]byte, 20)} { + if provokedByProbe(data, v4proto, id, seq, true) { + t.Errorf("provokedByProbe() accepted an unusable quote %v", data) + } + } + }) + + t.Run("handles ipv6", func(t *testing.T) { + const v6proto = 58 // ipv6.ICMPTypeEchoRequest.Protocol() + if !provokedByProbe(quotedError(t, false, id, seq), v6proto, id, seq, true) { + t.Error("provokedByProbe() rejected a v6 error quoting this probe") + } + if provokedByProbe(quotedError(t, false, id+1, seq), v6proto, id, seq, true) { + t.Error("provokedByProbe() accepted another v6 probe's error") + } + }) +} diff --git a/apps/checker/checker/icmp_test.go b/apps/checker/checker/icmp_test.go new file mode 100644 index 000000000..86b4f8540 --- /dev/null +++ b/apps/checker/checker/icmp_test.go @@ -0,0 +1,70 @@ +package checker_test + +import ( + "strings" + "testing" + + "github.com/openstatushq/openstatus/apps/checker/checker" +) + +// skipIfNoICMP skips when the runtime cannot open an ICMP socket at all +// (unprivileged Linux CI without ping_group_range). macOS grants udp4 natively. +func skipIfNoICMP(t *testing.T, err error) { + t.Helper() + if err != nil && strings.Contains(err.Error(), "icmp socket error") { + t.Skipf("icmp sockets unavailable in this environment: %v", err) + } +} + +func TestPingICMP_Loopback(t *testing.T) { + res, err := checker.PingICMP(2000, "127.0.0.1") + skipIfNoICMP(t, err) + if err != nil { + t.Fatalf("PingICMP() error = %v", err) + } + if res.PacketsReceived == 0 { + t.Fatalf("PingICMP() received no packets: %+v", res) + } + if res.PacketsSent < res.PacketsReceived { + t.Fatalf("PingICMP() received more than sent: %+v", res) + } + if res.LatencyMin > res.LatencyMax { + t.Fatalf("PingICMP() min > max: %+v", res) + } + if len(res.Timing.RTTs) == 0 { + t.Fatalf("PingICMP() empty timing: %+v", res) + } +} + +func TestPingICMP_ResolveError(t *testing.T) { + _, err := checker.PingICMP(1000, "nonexistent-host-openstatus-test.invalid") + if err == nil { + t.Fatal("PingICMP() expected a resolve error, got nil") + } +} + +func TestPingICMP_Timeout(t *testing.T) { + // 192.0.2.1 is TEST-NET-1 (RFC 5737): routable-looking but never answers. + res, err := checker.PingICMP(1000, "192.0.2.1") + skipIfNoICMP(t, err) + if err == nil { + t.Fatalf("PingICMP() expected timeout error, got %+v", res) + } +} + +// A zero timeout used to make the deadline land in the past, so the send loop +// broke before the first packet and every check reported "no reply" without +// probing. Callers that omit the field must still get a real check. +func TestPingICMP_ZeroTimeoutStillProbes(t *testing.T) { + res, err := checker.PingICMP(0, "127.0.0.1") + skipIfNoICMP(t, err) + if err != nil { + t.Fatalf("PingICMP() with a zero timeout must still probe, got %v", err) + } + if res.PacketsSent == 0 { + t.Error("PingICMP() sent no packets with a zero timeout") + } + if res.PacketsReceived == 0 { + t.Errorf("PingICMP() received no replies from loopback: %+v", res) + } +} diff --git a/apps/checker/cmd/server/main.go b/apps/checker/cmd/server/main.go index 542486eb9..ed17e4ac1 100644 --- a/apps/checker/cmd/server/main.go +++ b/apps/checker/cmd/server/main.go @@ -159,6 +159,12 @@ func main() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() + // Best-effort: allow unprivileged ICMP datagram sockets across all GIDs so + // PingICMP can avoid the raw-socket fallback. Ignored if not writable. + if err := os.WriteFile("/proc/sys/net/ipv4/ping_group_range", []byte("0 2147483647"), 0644); err != nil { + log.Warn().Err(err).Msg("could not widen ping_group_range; icmp will use the raw-socket fallback") + } + done := make(chan os.Signal, 1) signal.Notify(done, os.Interrupt, syscall.SIGINT, syscall.SIGTERM) @@ -243,9 +249,11 @@ func main() { router.POST("/checker/http", h.HTTPCheckerHandler) router.POST("/checker/tcp", h.TCPHandler) router.POST("/checker/dns", h.DNSHandler) + router.POST("/checker/icmp", h.ICMPHandler) router.POST("/ping/:region", h.PingRegionHandler) router.POST("/tcp/:region", h.TCPHandlerRegion) router.POST("/dns/:region", h.DNSHandlerRegion) + router.POST("/icmp/:region", h.ICMPHandlerRegion) router.GET("/health", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "pong", "region": region, "provider": cloudProvider}) diff --git a/apps/checker/go.mod b/apps/checker/go.mod index e458e57b4..46ca43051 100644 --- a/apps/checker/go.mod +++ b/apps/checker/go.mod @@ -22,6 +22,7 @@ require ( go.opentelemetry.io/otel/sdk v1.41.0 go.opentelemetry.io/otel/sdk/log v0.17.0 go.opentelemetry.io/otel/sdk/metric v1.41.0 + golang.org/x/net v0.51.0 google.golang.org/api v0.269.0 google.golang.org/protobuf v1.36.11 ) @@ -72,7 +73,6 @@ require ( go.opentelemetry.io/proto/otlp v1.9.0 // indirect golang.org/x/arch v0.24.0 // indirect golang.org/x/crypto v0.48.0 // indirect - golang.org/x/net v0.51.0 // indirect golang.org/x/oauth2 v0.35.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.41.0 // indirect diff --git a/apps/checker/handlers/icmp.go b/apps/checker/handlers/icmp.go new file mode 100644 index 000000000..c50b90daf --- /dev/null +++ b/apps/checker/handlers/icmp.go @@ -0,0 +1,371 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + "time" + + "github.com/cenkalti/backoff/v4" + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "github.com/openstatushq/openstatus/apps/checker/checker" + otelOS "github.com/openstatushq/openstatus/apps/checker/pkg/otel" + "github.com/openstatushq/openstatus/apps/checker/request" + "github.com/rs/zerolog/log" +) + +// Only used for Tinybird. +type ICMPData struct { + ID string `json:"id"` + Timing string `json:"timing"` + ErrorMessage string `json:"errorMessage"` + Region string `json:"region"` + Trigger string `json:"trigger"` + URI string `json:"uri"` + RequestStatus string `json:"requestStatus,omitempty"` + + RequestId int64 `json:"requestId,omitempty"` + WorkspaceID int64 `json:"workspaceId"` + MonitorID int64 `json:"monitorId"` + Timestamp int64 `json:"timestamp"` + Latency int64 `json:"latency"` + LatencyMin int64 `json:"latencyMin"` + LatencyMax int64 `json:"latencyMax"` + CronTimestamp int64 `json:"cronTimestamp"` + + PacketsSent uint8 `json:"packetsSent"` + PacketsReceived uint8 `json:"packetsReceived"` + + Error uint8 `json:"error"` +} + +func (h Handler) ICMPHandler(c *gin.Context) { + ctx := c.Request.Context() + dataSourceName := "icmp_response__v0" + + if c.GetHeader("Authorization") != fmt.Sprintf("Basic %s", h.Secret) { + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + + return + } + + if h.CloudProvider == "fly" { + // if the request has been routed to a wrong region, we forward it to the correct one. + region := c.GetHeader("fly-prefer-region") + if region != "" && region != h.Region { + c.Header("fly-replay", fmt.Sprintf("region=%s", region)) + c.String(http.StatusAccepted, "Forwarding request to %s", region) + + return + } + } + + var req request.ICMPCheckerRequest + if err := c.ShouldBindJSON(&req); err != nil { + log.Ctx(ctx).Error().Err(err).Msg("failed to decode checker request") + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + + return + } + + workspaceId, err := strconv.ParseInt(req.WorkspaceID, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + + return + } + + monitorId, err := strconv.ParseInt(req.MonitorID, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + + return + } + + var trigger = "cron" + if req.Trigger != "" { + trigger = req.Trigger + } + + e, f := c.Get("event") + if f { + t := e.(map[string]any) + t["checker"] = map[string]string{ + "uri": req.URI, + "workspace_id": req.WorkspaceID, + "monitor_id": req.MonitorID, + "trigger": trigger, + "type": "icmp", + } + c.Set("event", t) + } + + var response checker.ICMPResponse + + var retry int + if req.Retry != 0 { + retry = int(req.Retry) + } else { + retry = 3 + } + + op := func() error { + res, err := checker.PingICMP(req.Timeout, req.URI) + if err != nil { + return fmt.Errorf("unable to check icmp %s", err) + } + + timingAsString, err := json.Marshal(res.Timing) + if err != nil { + return fmt.Errorf("error while parsing timing data %s: %w", req.URI, err) + } + + latency := res.Latency + + var requestStatus = "" + switch req.Status { + case "active": + requestStatus = "success" + case "error": + requestStatus = "error" + case "degraded": + requestStatus = "degraded" + } + + id, err := uuid.NewV7() + if err != nil { + return fmt.Errorf("error while generating uuid %w", err) + } + + timestamp := time.Now().UTC().UnixMilli() + + data := ICMPData{ + ID: id.String(), + WorkspaceID: workspaceId, + Timestamp: timestamp, + Error: 0, + ErrorMessage: "", + Region: h.Region, + MonitorID: monitorId, + Timing: string(timingAsString), + Latency: latency, + LatencyMin: res.LatencyMin, + LatencyMax: res.LatencyMax, + PacketsSent: res.PacketsSent, + PacketsReceived: res.PacketsReceived, + CronTimestamp: req.CronTimestamp, + Trigger: trigger, + URI: req.URI, + RequestStatus: requestStatus, + } + + response = checker.ICMPResponse{ + Timestamp: timestamp, + Timing: res.Timing, + Latency: latency, + LatencyMin: res.LatencyMin, + LatencyMax: res.LatencyMax, + PacketsSent: res.PacketsSent, + PacketsReceived: res.PacketsReceived, + Region: h.Region, + JobType: "icmp", + } + + if req.DegradedAfter == 0 && req.Status != "active" { + checker.UpdateStatus(ctx, checker.UpdateData{ + MonitorId: req.MonitorID, + Status: "active", + Region: h.Region, + CronTimestamp: req.CronTimestamp, + Latency: latency, + }) + data.RequestStatus = "success" + } + + if (req.DegradedAfter > 0 && latency < req.DegradedAfter) && req.Status != "active" { + checker.UpdateStatus(ctx, checker.UpdateData{ + MonitorId: req.MonitorID, + Status: "active", + Region: h.Region, + CronTimestamp: req.CronTimestamp, + Latency: latency, + }) + data.RequestStatus = "success" + } + + if req.DegradedAfter > 0 && latency > req.DegradedAfter && req.Status != "degraded" { + checker.UpdateStatus(ctx, checker.UpdateData{ + MonitorId: req.MonitorID, + Status: "degraded", + Region: h.Region, + CronTimestamp: req.CronTimestamp, + Latency: latency, + }) + data.RequestStatus = "degraded" + } + + if err := h.TbClient.SendEvent(ctx, data, dataSourceName); err != nil { + log.Ctx(ctx).Error().Err(err).Msg("failed to send event to tinybird") + } + + return nil + } + + if err := backoff.Retry(op, backoff.WithMaxRetries(backoff.NewExponentialBackOff(), uint64(retry))); err != nil { + id, e := uuid.NewV7() + if e != nil { + log.Ctx(ctx).Error().Err(e).Msg("failed to send event to tinybird") + return + } + data := ICMPData{ + ID: id.String(), + WorkspaceID: workspaceId, + CronTimestamp: req.CronTimestamp, + ErrorMessage: err.Error(), + Region: h.Region, + MonitorID: monitorId, + Error: 1, + Trigger: trigger, + URI: req.URI, + RequestStatus: "error", + } + if err := h.TbClient.SendEvent(ctx, data, dataSourceName); err != nil { + log.Ctx(ctx).Error().Err(err).Msg("failed to send event to tinybird") + } + checker.UpdateStatus(ctx, checker.UpdateData{ + MonitorId: req.MonitorID, + Status: "error", + Message: err.Error(), + Region: h.Region, + CronTimestamp: req.CronTimestamp, + }) + + // Only the success path inside op() fills these in, so a check that + // exhausted its retries would otherwise be returned under `?data=true` + // with an empty jobType and region — a shape no caller can parse. + response.JobType = "icmp" + response.Region = h.Region + response.Error = 1 + } + + if req.OtelConfig.Endpoint != "" { + otelOS.RecordICMPMetrics(ctx, req, response, h.Region) + } + + returnData := c.Query("data") + if returnData == "true" { + c.JSON(http.StatusOK, response) + + return + } + + c.JSON(http.StatusOK, nil) +} + +func (h Handler) ICMPHandlerRegion(c *gin.Context) { + ctx := c.Request.Context() + dataSourceName := "check_icmp_response__v0" + + region := c.Param("region") + if region == "" { + c.String(http.StatusBadRequest, "region is required") + + return + } + + if c.GetHeader("Authorization") != fmt.Sprintf("Basic %s", h.Secret) { + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + + return + } + + if h.CloudProvider == "fly" { + // if the request has been routed to a wrong region, we forward it to the correct one. + region := c.GetHeader("fly-prefer-region") + if region != "" && region != h.Region { + c.Header("fly-replay", fmt.Sprintf("region=%s", region)) + c.String(http.StatusAccepted, "Forwarding request to %s", region) + + return + } + } + + var req request.ICMPCheckerRequest + if err := c.ShouldBindJSON(&req); err != nil { + log.Ctx(ctx).Error().Err(err).Msg("failed to decode checker request") + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"}) + + return + } + + var response checker.ICMPResponse + + op := func() error { + timestamp := time.Now().UTC().UnixMilli() + res, err := checker.PingICMP(req.Timeout, req.URI) + if err != nil { + return fmt.Errorf("unable to check icmp %s", err) + } + + response = checker.ICMPResponse{ + Timestamp: timestamp, + Timing: res.Timing, + Latency: res.Latency, + LatencyMin: res.LatencyMin, + LatencyMax: res.LatencyMax, + PacketsSent: res.PacketsSent, + PacketsReceived: res.PacketsReceived, + Region: h.Region, + JobType: "icmp", + } + + timingAsString, err := json.Marshal(res.Timing) + if err != nil { + return fmt.Errorf("error while parsing timing data %s: %w", req.URI, err) + } + + data := ICMPData{ + CronTimestamp: req.CronTimestamp, + Timestamp: timestamp, + Error: 0, + ErrorMessage: "", + Region: h.Region, + Timing: string(timingAsString), + Latency: res.Latency, + LatencyMin: res.LatencyMin, + LatencyMax: res.LatencyMax, + PacketsSent: res.PacketsSent, + PacketsReceived: res.PacketsReceived, + RequestId: req.RequestId, + Trigger: "api", + URI: req.URI, + } + + if req.RequestId != 0 { + if err := h.TbClient.SendEvent(ctx, data, dataSourceName); err != nil { + log.Ctx(ctx).Error().Err(err).Msg("failed to send event to tinybird") + } + } + + return nil + } + + err := backoff.Retry(op, backoff.WithMaxRetries(backoff.NewExponentialBackOff(), 3)) + if err != nil { + response.Error = 1 + } + + if req.OtelConfig.Endpoint != "" { + otelOS.RecordICMPMetrics(ctx, req, response, region) + } + + if err != nil { + c.JSON(http.StatusOK, gin.H{"message": "uri not reachable"}) + + return + } + + c.JSON(http.StatusOK, response) +} diff --git a/apps/checker/handlers/icmp_test.go b/apps/checker/handlers/icmp_test.go new file mode 100644 index 000000000..7ba944f85 --- /dev/null +++ b/apps/checker/handlers/icmp_test.go @@ -0,0 +1,79 @@ +package handlers_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/openstatushq/openstatus/apps/checker/handlers" + "github.com/stretchr/testify/assert" +) + +func TestICMPHandler_RejectsUnauthorized(t *testing.T) { + h := handlers.Handler{TbClient: testTinybird(t), Secret: "test", Region: "local"} + router := gin.New() + router.POST("/checker/icmp", h.ICMPHandler) + + w := httptest.NewRecorder() + r, _ := http.NewRequest(http.MethodPost, "/checker/icmp", strings.NewReader(`{"uri":"1.1.1.1"}`)) + r.Header.Set("Authorization", "Basic wrong") + router.ServeHTTP(w, r) + + assert.Equal(t, http.StatusUnauthorized, w.Code) +} + +func TestICMPHandler_RejectsBadPayload(t *testing.T) { + h := handlers.Handler{TbClient: testTinybird(t), Secret: "test", Region: "local"} + router := gin.New() + router.POST("/checker/icmp", h.ICMPHandler) + + w := httptest.NewRecorder() + r, _ := http.NewRequest(http.MethodPost, "/checker/icmp", strings.NewReader(`{not json`)) + r.Header.Set("Authorization", "Basic test") + router.ServeHTTP(w, r) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestICMPHandlerRegion_RequiresRegion(t *testing.T) { + h := handlers.Handler{TbClient: testTinybird(t), Secret: "test", Region: "local"} + router := gin.New() + router.POST("/icmp/:region", h.ICMPHandlerRegion) + + w := httptest.NewRecorder() + r, _ := http.NewRequest(http.MethodPost, "/icmp/local", strings.NewReader(`{"uri":"1.1.1.1"}`)) + r.Header.Set("Authorization", "Basic wrong") + router.ServeHTTP(w, r) + + assert.Equal(t, http.StatusUnauthorized, w.Code) +} + +// A check that exhausts its retries is still returned under `?data=true`, so it +// has to carry the same identifying fields as a successful one — the success +// path sets them inside op(), which never runs when every attempt fails. +func TestICMPHandler_FailureResponseKeepsJobTypeAndRegion(t *testing.T) { + h := handlers.Handler{TbClient: testTinybird(t), Secret: "test", Region: "local"} + router := gin.New() + router.POST("/checker/icmp", h.ICMPHandler) + + w := httptest.NewRecorder() + // 192.0.2.1 is TEST-NET-1 (RFC 5737): never answers, so every retry fails. + body := `{"uri":"192.0.2.1","timeout":200,"retry":1,"workspaceId":"1","monitorId":"1"}` + r, _ := http.NewRequest(http.MethodPost, "/checker/icmp?data=true", strings.NewReader(body)) + r.Header.Set("Authorization", "Basic test") + router.ServeHTTP(w, r) + + assert.Equal(t, http.StatusOK, w.Code) + + var res map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &res); err != nil { + t.Fatalf("failed to decode response %q: %v", w.Body.String(), err) + } + + assert.Equal(t, "icmp", res["jobType"], "jobType is the discriminator callers match on") + assert.Equal(t, "local", res["region"]) + assert.Equal(t, float64(1), res["error"]) +} diff --git a/apps/checker/handlers/otel_wiring_test.go b/apps/checker/handlers/otel_wiring_test.go index 1b913e424..4bbbf03e9 100644 --- a/apps/checker/handlers/otel_wiring_test.go +++ b/apps/checker/handlers/otel_wiring_test.go @@ -108,6 +108,35 @@ func TestTCPHandlerRegion_ExportsOTLPOnFailure(t *testing.T) { "expected an OTLP export on TCP failure") } +func TestICMPHandlerRegion_ExportsOTLPOnFailure(t *testing.T) { + otlp, count := countingOTLPServer(t) + + h := handlers.Handler{ + TbClient: testTinybird(t), + Secret: "test", + Region: "local", + } + router := gin.New() + router.POST("/icmp/:region", h.ICMPHandlerRegion) + + req := request.ICMPCheckerRequest{ + URI: "nonexistent-host-openstatus-test.invalid", // resolve failure, no socket needed + Status: "active", + Timeout: 1000, + } + req.OtelConfig.Endpoint = otlp.URL + body, _ := json.Marshal(req) + + w := httptest.NewRecorder() + r, _ := http.NewRequest(http.MethodPost, "/icmp/local", strings.NewReader(string(body))) + r.Header.Set("Authorization", "Basic test") + router.ServeHTTP(w, r) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Eventually(t, func() bool { return atomic.LoadInt64(count) > 0 }, 10*time.Second, 50*time.Millisecond, + "expected an OTLP export on ICMP failure") +} + func TestDNSHandler_ExportsOTLPOnFailure(t *testing.T) { otlp, count := countingOTLPServer(t) diff --git a/apps/checker/pkg/job/icmp_job.go b/apps/checker/pkg/job/icmp_job.go new file mode 100644 index 000000000..0cbce60fb --- /dev/null +++ b/apps/checker/pkg/job/icmp_job.go @@ -0,0 +1,163 @@ +package job + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/cenkalti/backoff/v5" + "github.com/google/uuid" + "github.com/openstatushq/openstatus/apps/checker/checker" + "github.com/openstatushq/openstatus/apps/checker/pkg/otel" + v1 "github.com/openstatushq/openstatus/apps/checker/proto/private_location/v1" + "github.com/openstatushq/openstatus/apps/checker/request" +) + +// ICMPPrivateRegionData represents the result of an ICMP monitor check +type ICMPPrivateRegionData struct { + ID string `json:"id"` + URI string `json:"uri"` + RequestStatus string `json:"request_status"` + Message string `json:"message"` + Latency int64 `json:"latency"` + LatencyMin int64 `json:"latency_min"` + LatencyMax int64 `json:"latency_max"` + PacketsSent int64 `json:"packets_sent"` + PacketsReceived int64 `json:"packets_received"` + Timestamp int64 `json:"timestamp"` + CronTimestamp int64 `json:"cron_timestamp"` + Error int `json:"error"` + Timing string `json:"timing"` +} + +func (jobRunner) ICMPJob(ctx context.Context, monitor *v1.ICMPMonitor, region string) (*ICMPPrivateRegionData, error) { + retry := monitor.Retry + if retry == 0 { + retry = 3 + } + + var degradedAfter int64 + if monitor.DegradedAt != nil { + degradedAfter = *monitor.DegradedAt + } + + req := icmpCheckerRequest(monitor) + + var called int + var lastResult checker.ICMPResponse + + op := func() (*ICMPPrivateRegionData, error) { + called++ + start := time.Now().UTC().UnixMilli() + res, err := checker.PingICMP(monitor.Timeout, monitor.Uri) + if err != nil { + if called < int(retry) { + return nil, fmt.Errorf("ICMP check failed: %w", err) + } + + data, dataErr := newICMPData(monitor.Uri, start) + if dataErr != nil { + return nil, dataErr + } + + lastResult = checker.ICMPResponse{Error: 1} + + data.RequestStatus = "error" + data.Error = 1 + data.Message = err.Error() + + return data, nil + } + + lastResult = checker.ICMPResponse{ + Latency: res.Latency, + LatencyMin: res.LatencyMin, + LatencyMax: res.LatencyMax, + PacketsSent: res.PacketsSent, + PacketsReceived: res.PacketsReceived, + Timing: res.Timing, + } + + // "success", not "active": the Tinybird ICMP status and uptime pipes + // count `requestStatus = 'success'`, and the HTTP/TCP/DNS jobs all + // report it that way. + var requestStatus = "success" + if degradedAfter > 0 && res.Latency > degradedAfter { + requestStatus = "degraded" + } + + data, err := newICMPData(monitor.Uri, start) + if err != nil { + return nil, err + } + + timingAsString, err := json.Marshal(res.Timing) + if err != nil { + return nil, fmt.Errorf("error while parsing timing data %s: %w", monitor.Uri, err) + } + + data.Latency = res.Latency + data.LatencyMin = res.LatencyMin + data.LatencyMax = res.LatencyMax + data.PacketsSent = int64(res.PacketsSent) + data.PacketsReceived = int64(res.PacketsReceived) + data.RequestStatus = requestStatus + data.Error = 0 + data.Message = fmt.Sprintf("Successfully pinged %s", monitor.Uri) + data.Timing = string(timingAsString) + + return data, nil + } + + resp, err := backoff.Retry(ctx, op, + backoff.WithMaxTries(uint(retry)), + backoff.WithBackOff(backoff.NewExponentialBackOff()), + ) + + recordICMPOtel(ctx, req, lastResult, region, err != nil) + + if err != nil { + return nil, fmt.Errorf("ICMP job failed after %d retries: %w", retry, err) + } + return resp, nil +} + +// newICMPData stamps the fields every result must carry regardless of outcome. +// `Timestamp`/`CronTimestamp` are required: ValidateIngestICMPRequest rejects a +// non-positive timestamp, so a result missing them is dropped at ingest. +func newICMPData(uri string, start int64) (*ICMPPrivateRegionData, error) { + id, err := uuid.NewV7() + if err != nil { + return nil, fmt.Errorf("failed to generate UUID: %w", err) + } + + return &ICMPPrivateRegionData{ + ID: id.String(), + URI: uri, + Timestamp: start, + CronTimestamp: start, + }, nil +} + +func icmpCheckerRequest(monitor *v1.ICMPMonitor) request.ICMPCheckerRequest { + req := request.ICMPCheckerRequest{URI: monitor.Uri} + if otelCfg := monitor.GetOtelConfig(); otelCfg.GetEndpoint() != "" { + req.OtelConfig.Endpoint = otelCfg.GetEndpoint() + req.OtelConfig.Headers = headersToMap(otelCfg.GetHeaders()) + } + + return req +} + +func recordICMPOtel(ctx context.Context, req request.ICMPCheckerRequest, result checker.ICMPResponse, region string, failed bool) { + if req.OtelConfig.Endpoint == "" { + return + } + + if failed { + result.Error = 1 + } + + otel.RecordICMPMetrics(ctx, req, result, region) +} diff --git a/apps/checker/pkg/job/icmp_job_test.go b/apps/checker/pkg/job/icmp_job_test.go new file mode 100644 index 000000000..114a0dac2 --- /dev/null +++ b/apps/checker/pkg/job/icmp_job_test.go @@ -0,0 +1,126 @@ +package job_test + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/openstatushq/openstatus/apps/checker/pkg/job" + v1 "github.com/openstatushq/openstatus/apps/checker/proto/private_location/v1" +) + +func TestICMPJob_Success(t *testing.T) { + monitor := &v1.ICMPMonitor{ + Uri: "127.0.0.1", + Timeout: 2000, + Retry: 1, + } + data, err := job.NewJobRunner().ICMPJob(context.Background(), monitor, "test-region") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + // unprivileged Linux CI (no ping_group_range) can't open the socket + if data.RequestStatus == "error" && strings.Contains(data.Message, "icmp socket error") { + t.Skipf("icmp sockets unavailable in this environment: %s", data.Message) + } + if data.RequestStatus != "success" { + t.Errorf("expected RequestStatus 'success', got '%s'", data.RequestStatus) + } + if data.Error != 0 { + t.Errorf("expected Error 0, got %d", data.Error) + } + if data.PacketsReceived == 0 { + t.Errorf("expected at least one packet received, got %d", data.PacketsReceived) + } +} + +func TestICMPJob_Failure(t *testing.T) { + // 192.0.2.1 is TEST-NET-1 (RFC 5737): never answers. + monitor := &v1.ICMPMonitor{ + Uri: "192.0.2.1", + Timeout: 1000, + Retry: 1, + } + data, err := job.NewJobRunner().ICMPJob(context.Background(), monitor, "test-region") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if data.RequestStatus != "error" { + t.Errorf("expected RequestStatus 'error', got '%s'", data.RequestStatus) + } + if data.Error != 1 { + t.Errorf("expected Error 1, got %d", data.Error) + } +} + +// The private-location server rejects an ingest whose timestamp is not strictly +// positive (ValidateIngestICMPRequest), and the scheduler does not retry a +// rejected ingest — so a result without timestamps is silently dropped. +func TestICMPJob_StampsTimestamps(t *testing.T) { + before := time.Now().UTC().UnixMilli() + + for _, tc := range []struct { + name string + uri string + }{ + {name: "success", uri: "127.0.0.1"}, + // 192.0.2.1 is TEST-NET-1 (RFC 5737): never answers. + {name: "failure", uri: "192.0.2.1"}, + } { + t.Run(tc.name, func(t *testing.T) { + monitor := &v1.ICMPMonitor{Uri: tc.uri, Timeout: 1000, Retry: 1} + data, err := job.NewJobRunner().ICMPJob(context.Background(), monitor, "test-region") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if data.RequestStatus == "error" && strings.Contains(data.Message, "icmp socket error") { + t.Skipf("icmp sockets unavailable in this environment: %s", data.Message) + } + + after := time.Now().UTC().UnixMilli() + + if data.Timestamp <= 0 { + t.Errorf("Timestamp must be positive or the ingest is rejected, got %d", data.Timestamp) + } + if data.CronTimestamp <= 0 { + t.Errorf("CronTimestamp must be positive, got %d", data.CronTimestamp) + } + if data.Timestamp < before || data.Timestamp > after { + t.Errorf("Timestamp %d outside the run window [%d, %d]", data.Timestamp, before, after) + } + if data.ID == "" { + t.Error("ID must be set") + } + if data.URI != tc.uri { + t.Errorf("URI = %q, want %q", data.URI, tc.uri) + } + }) + } +} + +// The Tinybird ICMP aggregations bucket on the literal string — see +// aggregate__icmp_status_45d__v0.pipe and the icmp_uptime_* endpoints, which +// count `requestStatus = 'success'`. A healthy check reported under any other +// label is ingested but invisible to uptime and status. +func TestICMPJob_SuccessUsesTinybirdStatusVocabulary(t *testing.T) { + monitor := &v1.ICMPMonitor{Uri: "127.0.0.1", Timeout: 2000, Retry: 1} + data, err := job.NewJobRunner().ICMPJob(context.Background(), monitor, "test-region") + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if data.RequestStatus == "error" && strings.Contains(data.Message, "icmp socket error") { + t.Skipf("icmp sockets unavailable in this environment: %s", data.Message) + } + + // The full vocabulary the pipes recognise; "active" is not part of it. + switch data.RequestStatus { + case "success", "degraded", "error": + default: + t.Errorf("RequestStatus %q is not counted by the Tinybird ICMP pipes", data.RequestStatus) + } + + if data.RequestStatus != "success" { + t.Errorf("a healthy ping must report \"success\", got %q", data.RequestStatus) + } +} diff --git a/apps/checker/pkg/job/job.go b/apps/checker/pkg/job/job.go index 944e4de0b..74518d410 100644 --- a/apps/checker/pkg/job/job.go +++ b/apps/checker/pkg/job/job.go @@ -31,6 +31,7 @@ type JobRunner interface { TCPJob(ctx context.Context, monitor *v1.TCPMonitor, region string) (*TCPPrivateRegionData, error) HTTPJob(ctx context.Context, monitor *v1.HTTPMonitor, region string) (*HttpPrivateRegionData, error) DNSJob(ctx context.Context, monitor *v1.DNSMonitor) (*DNSPrivateRegionData, error) + ICMPJob(ctx context.Context, monitor *v1.ICMPMonitor, region string) (*ICMPPrivateRegionData, error) } type jobRunner struct{} diff --git a/apps/checker/pkg/otel/otel.go b/apps/checker/pkg/otel/otel.go index 51f83a0b1..c4938d364 100644 --- a/apps/checker/pkg/otel/otel.go +++ b/apps/checker/pkg/otel/otel.go @@ -80,10 +80,25 @@ func withMeter(ctx context.Context, endpoint string, headers map[string]string, fn(otel.Meter("OpenStatus")) } -// recordGauge creates a Float64Gauge and records a value. +// UCUM unit codes, as OpenTelemetry expects them. +const ( + unitMilliseconds = "ms" + unitPercent = "%" +) + +// recordGauge creates a Float64Gauge for a duration in milliseconds and records +// a value. Use recordGaugeWithUnit for anything that is not a duration — +// backends render a gauge according to its unit, so a mislabelled one is read +// as a time span. func recordGauge(ctx context.Context, meter metric.Meter, name, description string, value float64, att metric.MeasurementOption) error { + return recordGaugeWithUnit(ctx, meter, name, description, unitMilliseconds, value, att) +} + +// recordGaugeWithUnit creates a Float64Gauge carrying an explicit unit and +// records a value. +func recordGaugeWithUnit(ctx context.Context, meter metric.Meter, name, description, unit string, value float64, att metric.MeasurementOption) error { gauge, err := meter.Float64Gauge(name, - metric.WithDescription(description), metric.WithUnit("ms")) + metric.WithDescription(description), metric.WithUnit(unit)) if err != nil { return err } @@ -169,6 +184,37 @@ func RecordDNSMetrics(ctx context.Context, req request.DNSCheckerRequest, latenc }) } +func RecordICMPMetrics(ctx context.Context, req request.ICMPCheckerRequest, result checker.ICMPResponse, region string) { + withMeter(ctx, req.OtelConfig.Endpoint, req.OtelConfig.Headers, func(meter metric.Meter) { + att := metric.WithAttributes( + attribute.String("openstatus.probes", region), + attribute.String("openstatus.target", req.URI), + ) + recordICMPInstruments(ctx, meter, result, att) + }) +} + +// recordICMPInstruments is split out of RecordICMPMetrics so tests can supply a +// collectable meter: withMeter installs a real OTLP provider globally, which +// leaves nothing to assert against. +func recordICMPInstruments(ctx context.Context, meter metric.Meter, result checker.ICMPResponse, att metric.MeasurementOption) { + if result.Error == 1 { + recordErrorCounter(ctx, meter, att) + return + } + + recordStatusCounter(ctx, meter, att) + + if err := recordGauge(ctx, meter, "openstatus.icmp.request.duration", "Duration of the check", float64(result.Latency), att); err != nil { + log.Ctx(ctx).Error().Err(err).Str("metric", "openstatus.icmp.request.duration").Msg("Error creating gauge") + } + + packetLoss := float64(result.PacketsSent-result.PacketsReceived) / float64(result.PacketsSent) * 100 + if err := recordGaugeWithUnit(ctx, meter, "openstatus.icmp.packet.loss", "Packet loss percentage", unitPercent, packetLoss, att); err != nil { + log.Ctx(ctx).Error().Err(err).Str("metric", "openstatus.icmp.packet.loss").Msg("Error creating gauge") + } +} + func RecordTCPMetrics(ctx context.Context, req request.TCPCheckerRequest, result checker.TCPResponse, region string) { withMeter(ctx, req.OtelConfig.Endpoint, req.OtelConfig.Headers, func(meter metric.Meter) { att := metric.WithAttributes( diff --git a/apps/checker/pkg/otel/otel_test.go b/apps/checker/pkg/otel/otel_test.go index 628e46f57..c34281194 100644 --- a/apps/checker/pkg/otel/otel_test.go +++ b/apps/checker/pkg/otel/otel_test.go @@ -74,6 +74,70 @@ func TestRecordGauge(t *testing.T) { assert.Equal(t, "test-value", val.AsString()) } +func TestRecordGaugeWithUnit(t *testing.T) { + for _, tc := range []struct { + name string + unit string + }{ + {name: "percentage", unit: unitPercent}, + {name: "duration", unit: unitMilliseconds}, + {name: "unitless", unit: ""}, + } { + t.Run(tc.name, func(t *testing.T) { + meter, reader := newTestMeter(t) + att := metric.WithAttributes(attribute.String("region", "ams")) + + err := recordGaugeWithUnit(context.Background(), meter, "test.gauge", "A test gauge", tc.unit, 12.5, att) + require.NoError(t, err) + + rm := collectMetrics(t, reader) + require.Len(t, rm.ScopeMetrics, 1) + require.Len(t, rm.ScopeMetrics[0].Metrics, 1) + + m := rm.ScopeMetrics[0].Metrics[0] + assert.Equal(t, tc.unit, m.Unit) + + gauge, ok := m.Data.(metricdata.Gauge[float64]) + require.True(t, ok) + require.Len(t, gauge.DataPoints, 1) + assert.Equal(t, 12.5, gauge.DataPoints[0].Value) + }) + } +} + +// The two ICMP gauges carry different units, so they cannot share the +// ms-defaulting helper: a percentage exported as "ms" is rendered as a duration +// by the backend. +func TestICMPGaugeUnits(t *testing.T) { + meter, reader := newTestMeter(t) + att := metric.WithAttributes(attribute.String("openstatus.probes", "ams")) + + recordICMPInstruments(context.Background(), meter, checker.ICMPResponse{ + Latency: 42, + PacketsSent: 3, + PacketsReceived: 2, + }, att) + + units := map[string]string{} + values := map[string]float64{} + for _, sm := range collectMetrics(t, reader).ScopeMetrics { + for _, m := range sm.Metrics { + units[m.Name] = m.Unit + if gauge, ok := m.Data.(metricdata.Gauge[float64]); ok && len(gauge.DataPoints) == 1 { + values[m.Name] = gauge.DataPoints[0].Value + } + } + } + + assert.Equal(t, "ms", units["openstatus.icmp.request.duration"]) + assert.Equal(t, "%", units["openstatus.icmp.packet.loss"], + "packet loss is a percentage, not a duration") + + assert.Equal(t, float64(42), values["openstatus.icmp.request.duration"]) + assert.InDelta(t, 33.33, values["openstatus.icmp.packet.loss"], 0.01, + "1 of 3 packets lost is ~33%, confirming the value really is a percentage") +} + func TestRecordGauge_MultipleMetrics(t *testing.T) { meter, reader := newTestMeter(t) ctx := context.Background() diff --git a/apps/checker/pkg/scheduler/scheduler.go b/apps/checker/pkg/scheduler/scheduler.go index 6a12834c8..87cab525f 100644 --- a/apps/checker/pkg/scheduler/scheduler.go +++ b/apps/checker/pkg/scheduler/scheduler.go @@ -243,6 +243,61 @@ func (mm *MonitorManager) UpdateMonitors(ctx context.Context) { log.Printf("Started DNS monitoring job for %s (%s)", m.Id, m.Uri) } + for _, m := range res.Msg.IcmpMonitors { + currentIDs[m.Id] = struct{}{} + if mm.shouldSchedule(m.Id, m) { + + interval := time.Duration(intervalToSecond(m.Periodicity)) * time.Second + task := tasks.Task{ + Interval: interval, + RunOnce: false, + RunSingleInstance: true, + FuncWithTaskContext: func(ctx tasks.TaskContext) error { + + monitor := m + c := context.Background() + log.Printf("Starting ICMP job for monitor %s (%s)", monitor.Id, monitor.Uri) + data, err := mm.JobRunner.ICMPJob(c, monitor, res.Msg.Region) + if err != nil { + log.Printf("ICMP monitor check failed for %s (%s): %v", monitor.Id, monitor.Uri, err) + return err + } + resp, ingestErr := mm.Client.IngestICMP(c, &connect.Request[v1.IngestICMPRequest]{ + Msg: &v1.IngestICMPRequest{ + MonitorId: monitor.Id, + Id: data.ID, + Uri: monitor.Uri, + Message: data.Message, + Latency: data.Latency, + LatencyMin: data.LatencyMin, + LatencyMax: data.LatencyMax, + PacketsSent: data.PacketsSent, + PacketsReceived: data.PacketsReceived, + RequestStatus: data.RequestStatus, + Error: int64(data.Error), + CronTimestamp: data.CronTimestamp, + Timestamp: data.Timestamp, + Timing: data.Timing, + }, + }) + if ingestErr != nil { + log.Printf("Failed to ingest ICMP result for %s (%s): %v", monitor.Id, monitor.Uri, ingestErr) + return ingestErr + } + log.Printf("ICMP monitor check succeeded for %s (%s), ingest response: %v", monitor.Id, monitor.Uri, resp) + + return nil + }, + } + err := mm.Scheduler.AddWithID(m.Id, &task) + if err != nil { + log.Printf("Failed to add ICMP monitor job for %s (%s): %v", m.Id, m.Uri, err) + continue + } + log.Printf("Started ICMP monitoring job for %s (%s)", m.Id, m.Uri) + } + } + mm.mu.Lock() for id := range mm.Scheduler.Tasks() { if _, stillExists := currentIDs[id]; !stillExists { diff --git a/apps/checker/pkg/scheduler/scheduler_test.go b/apps/checker/pkg/scheduler/scheduler_test.go index 455144563..ba1013a57 100644 --- a/apps/checker/pkg/scheduler/scheduler_test.go +++ b/apps/checker/pkg/scheduler/scheduler_test.go @@ -74,12 +74,17 @@ func (m *mockJobRunner) DNSJob(ctx context.Context, monitor *v1.DNSMonitor) (*jo }, nil } +func (m *mockJobRunner) ICMPJob(ctx context.Context, monitor *v1.ICMPMonitor, region string) (*job.ICMPPrivateRegionData, error) { + return &job.ICMPPrivateRegionData{}, nil +} + // mockClient implements v1.PrivateLocationServiceClient for testing type mockClient struct { MonitorsFunc func(ctx context.Context, req *connect.Request[v1.MonitorsRequest]) (*connect.Response[v1.MonitorsResponse], error) IngestHTTPFunc func(ctx context.Context, req *connect.Request[v1.IngestHTTPRequest]) (*connect.Response[v1.IngestHTTPResponse], error) IngestTCPFunc func(ctx context.Context, req *connect.Request[v1.IngestTCPRequest]) (*connect.Response[v1.IngestTCPResponse], error) IngestDNSFunc func(ctx context.Context, req *connect.Request[v1.IngestDNSRequest]) (*connect.Response[v1.IngestDNSResponse], error) + IngestICMPFunc func(ctx context.Context, req *connect.Request[v1.IngestICMPRequest]) (*connect.Response[v1.IngestICMPResponse], error) } func (m *mockClient) Monitors(ctx context.Context, req *connect.Request[v1.MonitorsRequest]) (*connect.Response[v1.MonitorsResponse], error) { @@ -94,6 +99,9 @@ func (m *mockClient) IngestTCP(ctx context.Context, req *connect.Request[v1.Inge func (m *mockClient) IngestDNS(ctx context.Context, req *connect.Request[v1.IngestDNSRequest]) (*connect.Response[v1.IngestDNSResponse], error) { return m.IngestDNSFunc(ctx, req) } +func (m *mockClient) IngestICMP(ctx context.Context, req *connect.Request[v1.IngestICMPRequest]) (*connect.Response[v1.IngestICMPResponse], error) { + return m.IngestICMPFunc(ctx, req) +} func TestMonitorManager_StartAndStopJobs_WithJobRunner(t *testing.T) { ctx := t.Context() diff --git a/apps/checker/proto/private_location/v1/icmp_monitor.pb.go b/apps/checker/proto/private_location/v1/icmp_monitor.pb.go new file mode 100644 index 000000000..d842fba5e --- /dev/null +++ b/apps/checker/proto/private_location/v1/icmp_monitor.pb.go @@ -0,0 +1,183 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: private_location/v1/icmp_monitor.proto + +package v1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type ICMPMonitor struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Uri string `protobuf:"bytes,2,opt,name=uri,proto3" json:"uri,omitempty"` + Timeout int64 `protobuf:"varint,3,opt,name=timeout,proto3" json:"timeout,omitempty"` + DegradedAt *int64 `protobuf:"varint,4,opt,name=degraded_at,json=degradedAt,proto3,oneof" json:"degraded_at,omitempty"` + Periodicity string `protobuf:"bytes,5,opt,name=periodicity,proto3" json:"periodicity,omitempty"` + Retry int64 `protobuf:"varint,6,opt,name=retry,proto3" json:"retry,omitempty"` + OtelConfig *OtelConfig `protobuf:"bytes,20,opt,name=otel_config,json=otelConfig,proto3" json:"otel_config,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ICMPMonitor) Reset() { + *x = ICMPMonitor{} + mi := &file_private_location_v1_icmp_monitor_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ICMPMonitor) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ICMPMonitor) ProtoMessage() {} + +func (x *ICMPMonitor) ProtoReflect() protoreflect.Message { + mi := &file_private_location_v1_icmp_monitor_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ICMPMonitor.ProtoReflect.Descriptor instead. +func (*ICMPMonitor) Descriptor() ([]byte, []int) { + return file_private_location_v1_icmp_monitor_proto_rawDescGZIP(), []int{0} +} + +func (x *ICMPMonitor) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *ICMPMonitor) GetUri() string { + if x != nil { + return x.Uri + } + return "" +} + +func (x *ICMPMonitor) GetTimeout() int64 { + if x != nil { + return x.Timeout + } + return 0 +} + +func (x *ICMPMonitor) GetDegradedAt() int64 { + if x != nil && x.DegradedAt != nil { + return *x.DegradedAt + } + return 0 +} + +func (x *ICMPMonitor) GetPeriodicity() string { + if x != nil { + return x.Periodicity + } + return "" +} + +func (x *ICMPMonitor) GetRetry() int64 { + if x != nil { + return x.Retry + } + return 0 +} + +func (x *ICMPMonitor) GetOtelConfig() *OtelConfig { + if x != nil { + return x.OtelConfig + } + return nil +} + +var File_private_location_v1_icmp_monitor_proto protoreflect.FileDescriptor + +const file_private_location_v1_icmp_monitor_proto_rawDesc = "" + + "\n" + + "&private_location/v1/icmp_monitor.proto\x12\x13private_location.v1\x1a\x1eprivate_location/v1/otel.proto\"\xf9\x01\n" + + "\vICMPMonitor\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x10\n" + + "\x03uri\x18\x02 \x01(\tR\x03uri\x12\x18\n" + + "\atimeout\x18\x03 \x01(\x03R\atimeout\x12$\n" + + "\vdegraded_at\x18\x04 \x01(\x03H\x00R\n" + + "degradedAt\x88\x01\x01\x12 \n" + + "\vperiodicity\x18\x05 \x01(\tR\vperiodicity\x12\x14\n" + + "\x05retry\x18\x06 \x01(\x03R\x05retry\x12@\n" + + "\votel_config\x18\x14 \x01(\v2\x1f.private_location.v1.OtelConfigR\n" + + "otelConfigB\x0e\n" + + "\f_degraded_atBJZHgithub.com/openstatushq/openstatus/packages/proto/private_location/v1;v1b\x06proto3" + +var ( + file_private_location_v1_icmp_monitor_proto_rawDescOnce sync.Once + file_private_location_v1_icmp_monitor_proto_rawDescData []byte +) + +func file_private_location_v1_icmp_monitor_proto_rawDescGZIP() []byte { + file_private_location_v1_icmp_monitor_proto_rawDescOnce.Do(func() { + file_private_location_v1_icmp_monitor_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_private_location_v1_icmp_monitor_proto_rawDesc), len(file_private_location_v1_icmp_monitor_proto_rawDesc))) + }) + return file_private_location_v1_icmp_monitor_proto_rawDescData +} + +var file_private_location_v1_icmp_monitor_proto_msgTypes = make([]protoimpl.MessageInfo, 1) +var file_private_location_v1_icmp_monitor_proto_goTypes = []any{ + (*ICMPMonitor)(nil), // 0: private_location.v1.ICMPMonitor + (*OtelConfig)(nil), // 1: private_location.v1.OtelConfig +} +var file_private_location_v1_icmp_monitor_proto_depIdxs = []int32{ + 1, // 0: private_location.v1.ICMPMonitor.otel_config:type_name -> private_location.v1.OtelConfig + 1, // [1:1] is the sub-list for method output_type + 1, // [1:1] is the sub-list for method input_type + 1, // [1:1] is the sub-list for extension type_name + 1, // [1:1] is the sub-list for extension extendee + 0, // [0:1] is the sub-list for field type_name +} + +func init() { file_private_location_v1_icmp_monitor_proto_init() } +func file_private_location_v1_icmp_monitor_proto_init() { + if File_private_location_v1_icmp_monitor_proto != nil { + return + } + file_private_location_v1_otel_proto_init() + file_private_location_v1_icmp_monitor_proto_msgTypes[0].OneofWrappers = []any{} + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_private_location_v1_icmp_monitor_proto_rawDesc), len(file_private_location_v1_icmp_monitor_proto_rawDesc)), + NumEnums: 0, + NumMessages: 1, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_private_location_v1_icmp_monitor_proto_goTypes, + DependencyIndexes: file_private_location_v1_icmp_monitor_proto_depIdxs, + MessageInfos: file_private_location_v1_icmp_monitor_proto_msgTypes, + }.Build() + File_private_location_v1_icmp_monitor_proto = out.File + file_private_location_v1_icmp_monitor_proto_goTypes = nil + file_private_location_v1_icmp_monitor_proto_depIdxs = nil +} diff --git a/apps/checker/proto/private_location/v1/private_location.connect.go b/apps/checker/proto/private_location/v1/private_location.connect.go index 40235485f..8138ab7c5 100644 --- a/apps/checker/proto/private_location/v1/private_location.connect.go +++ b/apps/checker/proto/private_location/v1/private_location.connect.go @@ -44,6 +44,9 @@ const ( // PrivateLocationServiceIngestDNSProcedure is the fully-qualified name of the // PrivateLocationService's IngestDNS RPC. PrivateLocationServiceIngestDNSProcedure = "/private_location.v1.PrivateLocationService/IngestDNS" + // PrivateLocationServiceIngestICMPProcedure is the fully-qualified name of the + // PrivateLocationService's IngestICMP RPC. + PrivateLocationServiceIngestICMPProcedure = "/private_location.v1.PrivateLocationService/IngestICMP" ) // PrivateLocationServiceClient is a client for the private_location.v1.PrivateLocationService @@ -53,6 +56,7 @@ type PrivateLocationServiceClient interface { IngestTCP(context.Context, *connect.Request[IngestTCPRequest]) (*connect.Response[IngestTCPResponse], error) IngestHTTP(context.Context, *connect.Request[IngestHTTPRequest]) (*connect.Response[IngestHTTPResponse], error) IngestDNS(context.Context, *connect.Request[IngestDNSRequest]) (*connect.Response[IngestDNSResponse], error) + IngestICMP(context.Context, *connect.Request[IngestICMPRequest]) (*connect.Response[IngestICMPResponse], error) } // NewPrivateLocationServiceClient constructs a client for the @@ -90,6 +94,12 @@ func NewPrivateLocationServiceClient(httpClient connect.HTTPClient, baseURL stri connect.WithSchema(privateLocationServiceMethods.ByName("IngestDNS")), connect.WithClientOptions(opts...), ), + ingestICMP: connect.NewClient[IngestICMPRequest, IngestICMPResponse]( + httpClient, + baseURL+PrivateLocationServiceIngestICMPProcedure, + connect.WithSchema(privateLocationServiceMethods.ByName("IngestICMP")), + connect.WithClientOptions(opts...), + ), } } @@ -99,6 +109,7 @@ type privateLocationServiceClient struct { ingestTCP *connect.Client[IngestTCPRequest, IngestTCPResponse] ingestHTTP *connect.Client[IngestHTTPRequest, IngestHTTPResponse] ingestDNS *connect.Client[IngestDNSRequest, IngestDNSResponse] + ingestICMP *connect.Client[IngestICMPRequest, IngestICMPResponse] } // Monitors calls private_location.v1.PrivateLocationService.Monitors. @@ -121,6 +132,11 @@ func (c *privateLocationServiceClient) IngestDNS(ctx context.Context, req *conne return c.ingestDNS.CallUnary(ctx, req) } +// IngestICMP calls private_location.v1.PrivateLocationService.IngestICMP. +func (c *privateLocationServiceClient) IngestICMP(ctx context.Context, req *connect.Request[IngestICMPRequest]) (*connect.Response[IngestICMPResponse], error) { + return c.ingestICMP.CallUnary(ctx, req) +} + // PrivateLocationServiceHandler is an implementation of the // private_location.v1.PrivateLocationService service. type PrivateLocationServiceHandler interface { @@ -128,6 +144,7 @@ type PrivateLocationServiceHandler interface { IngestTCP(context.Context, *connect.Request[IngestTCPRequest]) (*connect.Response[IngestTCPResponse], error) IngestHTTP(context.Context, *connect.Request[IngestHTTPRequest]) (*connect.Response[IngestHTTPResponse], error) IngestDNS(context.Context, *connect.Request[IngestDNSRequest]) (*connect.Response[IngestDNSResponse], error) + IngestICMP(context.Context, *connect.Request[IngestICMPRequest]) (*connect.Response[IngestICMPResponse], error) } // NewPrivateLocationServiceHandler builds an HTTP handler from the service implementation. It @@ -161,6 +178,12 @@ func NewPrivateLocationServiceHandler(svc PrivateLocationServiceHandler, opts .. connect.WithSchema(privateLocationServiceMethods.ByName("IngestDNS")), connect.WithHandlerOptions(opts...), ) + privateLocationServiceIngestICMPHandler := connect.NewUnaryHandler( + PrivateLocationServiceIngestICMPProcedure, + svc.IngestICMP, + connect.WithSchema(privateLocationServiceMethods.ByName("IngestICMP")), + connect.WithHandlerOptions(opts...), + ) return "/private_location.v1.PrivateLocationService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case PrivateLocationServiceMonitorsProcedure: @@ -171,6 +194,8 @@ func NewPrivateLocationServiceHandler(svc PrivateLocationServiceHandler, opts .. privateLocationServiceIngestHTTPHandler.ServeHTTP(w, r) case PrivateLocationServiceIngestDNSProcedure: privateLocationServiceIngestDNSHandler.ServeHTTP(w, r) + case PrivateLocationServiceIngestICMPProcedure: + privateLocationServiceIngestICMPHandler.ServeHTTP(w, r) default: http.NotFound(w, r) } @@ -195,3 +220,7 @@ func (UnimplementedPrivateLocationServiceHandler) IngestHTTP(context.Context, *c func (UnimplementedPrivateLocationServiceHandler) IngestDNS(context.Context, *connect.Request[IngestDNSRequest]) (*connect.Response[IngestDNSResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("private_location.v1.PrivateLocationService.IngestDNS is not implemented")) } + +func (UnimplementedPrivateLocationServiceHandler) IngestICMP(context.Context, *connect.Request[IngestICMPRequest]) (*connect.Response[IngestICMPResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("private_location.v1.PrivateLocationService.IngestICMP is not implemented")) +} diff --git a/apps/checker/proto/private_location/v1/private_location.pb.go b/apps/checker/proto/private_location/v1/private_location.pb.go index e31eb986c..a28112bc7 100644 --- a/apps/checker/proto/private_location/v1/private_location.pb.go +++ b/apps/checker/proto/private_location/v1/private_location.pb.go @@ -62,6 +62,7 @@ type MonitorsResponse struct { HttpMonitors []*HTTPMonitor `protobuf:"bytes,1,rep,name=http_monitors,json=httpMonitors,proto3" json:"http_monitors,omitempty"` TcpMonitors []*TCPMonitor `protobuf:"bytes,2,rep,name=tcp_monitors,json=tcpMonitors,proto3" json:"tcp_monitors,omitempty"` DnsMonitors []*DNSMonitor `protobuf:"bytes,3,rep,name=dns_monitors,json=dnsMonitors,proto3" json:"dns_monitors,omitempty"` + IcmpMonitors []*ICMPMonitor `protobuf:"bytes,5,rep,name=icmp_monitors,json=icmpMonitors,proto3" json:"icmp_monitors,omitempty"` Region string `protobuf:"bytes,4,opt,name=region,proto3" json:"region,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -118,6 +119,13 @@ func (x *MonitorsResponse) GetDnsMonitors() []*DNSMonitor { return nil } +func (x *MonitorsResponse) GetIcmpMonitors() []*ICMPMonitor { + if x != nil { + return x.IcmpMonitors + } + return nil +} + func (x *MonitorsResponse) GetRegion() string { if x != nil { return x.Region @@ -657,16 +665,201 @@ func (*IngestDNSResponse) Descriptor() ([]byte, []int) { return file_private_location_v1_private_location_proto_rawDescGZIP(), []int{8} } +type IngestICMPRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + MonitorId string `protobuf:"bytes,2,opt,name=monitorId,proto3" json:"monitorId,omitempty"` + Latency int64 `protobuf:"varint,3,opt,name=latency,proto3" json:"latency,omitempty"` + LatencyMin int64 `protobuf:"varint,4,opt,name=latencyMin,proto3" json:"latencyMin,omitempty"` + LatencyMax int64 `protobuf:"varint,5,opt,name=latencyMax,proto3" json:"latencyMax,omitempty"` + PacketsSent int64 `protobuf:"varint,6,opt,name=packetsSent,proto3" json:"packetsSent,omitempty"` + PacketsReceived int64 `protobuf:"varint,7,opt,name=packetsReceived,proto3" json:"packetsReceived,omitempty"` + Timestamp int64 `protobuf:"varint,8,opt,name=timestamp,proto3" json:"timestamp,omitempty"` + CronTimestamp int64 `protobuf:"varint,9,opt,name=cronTimestamp,proto3" json:"cronTimestamp,omitempty"` + Uri string `protobuf:"bytes,10,opt,name=uri,proto3" json:"uri,omitempty"` + Message string `protobuf:"bytes,11,opt,name=message,proto3" json:"message,omitempty"` + RequestStatus string `protobuf:"bytes,12,opt,name=requestStatus,proto3" json:"requestStatus,omitempty"` + Error int64 `protobuf:"varint,13,opt,name=error,proto3" json:"error,omitempty"` + Timing string `protobuf:"bytes,14,opt,name=timing,proto3" json:"timing,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *IngestICMPRequest) Reset() { + *x = IngestICMPRequest{} + mi := &file_private_location_v1_private_location_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *IngestICMPRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*IngestICMPRequest) ProtoMessage() {} + +func (x *IngestICMPRequest) ProtoReflect() protoreflect.Message { + mi := &file_private_location_v1_private_location_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use IngestICMPRequest.ProtoReflect.Descriptor instead. +func (*IngestICMPRequest) Descriptor() ([]byte, []int) { + return file_private_location_v1_private_location_proto_rawDescGZIP(), []int{9} +} + +func (x *IngestICMPRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *IngestICMPRequest) GetMonitorId() string { + if x != nil { + return x.MonitorId + } + return "" +} + +func (x *IngestICMPRequest) GetLatency() int64 { + if x != nil { + return x.Latency + } + return 0 +} + +func (x *IngestICMPRequest) GetLatencyMin() int64 { + if x != nil { + return x.LatencyMin + } + return 0 +} + +func (x *IngestICMPRequest) GetLatencyMax() int64 { + if x != nil { + return x.LatencyMax + } + return 0 +} + +func (x *IngestICMPRequest) GetPacketsSent() int64 { + if x != nil { + return x.PacketsSent + } + return 0 +} + +func (x *IngestICMPRequest) GetPacketsReceived() int64 { + if x != nil { + return x.PacketsReceived + } + return 0 +} + +func (x *IngestICMPRequest) GetTimestamp() int64 { + if x != nil { + return x.Timestamp + } + return 0 +} + +func (x *IngestICMPRequest) GetCronTimestamp() int64 { + if x != nil { + return x.CronTimestamp + } + return 0 +} + +func (x *IngestICMPRequest) GetUri() string { + if x != nil { + return x.Uri + } + return "" +} + +func (x *IngestICMPRequest) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *IngestICMPRequest) GetRequestStatus() string { + if x != nil { + return x.RequestStatus + } + return "" +} + +func (x *IngestICMPRequest) GetError() int64 { + if x != nil { + return x.Error + } + return 0 +} + +func (x *IngestICMPRequest) GetTiming() string { + if x != nil { + return x.Timing + } + return "" +} + +type IngestICMPResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *IngestICMPResponse) Reset() { + *x = IngestICMPResponse{} + mi := &file_private_location_v1_private_location_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *IngestICMPResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*IngestICMPResponse) ProtoMessage() {} + +func (x *IngestICMPResponse) ProtoReflect() protoreflect.Message { + mi := &file_private_location_v1_private_location_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use IngestICMPResponse.ProtoReflect.Descriptor instead. +func (*IngestICMPResponse) Descriptor() ([]byte, []int) { + return file_private_location_v1_private_location_proto_rawDescGZIP(), []int{10} +} + var File_private_location_v1_private_location_proto protoreflect.FileDescriptor const file_private_location_v1_private_location_proto_rawDesc = "" + "\n" + - "*private_location/v1/private_location.proto\x12\x13private_location.v1\x1a%private_location/v1/dns_monitor.proto\x1a&private_location/v1/http_monitor.proto\x1a%private_location/v1/tcp_monitor.proto\"\x11\n" + - "\x0fMonitorsRequest\"\xf9\x01\n" + + "*private_location/v1/private_location.proto\x12\x13private_location.v1\x1a%private_location/v1/dns_monitor.proto\x1a&private_location/v1/http_monitor.proto\x1a&private_location/v1/icmp_monitor.proto\x1a%private_location/v1/tcp_monitor.proto\"\x11\n" + + "\x0fMonitorsRequest\"\xc0\x02\n" + "\x10MonitorsResponse\x12E\n" + "\rhttp_monitors\x18\x01 \x03(\v2 .private_location.v1.HTTPMonitorR\fhttpMonitors\x12B\n" + "\ftcp_monitors\x18\x02 \x03(\v2\x1f.private_location.v1.TCPMonitorR\vtcpMonitors\x12B\n" + - "\fdns_monitors\x18\x03 \x03(\v2\x1f.private_location.v1.DNSMonitorR\vdnsMonitors\x12\x16\n" + + "\fdns_monitors\x18\x03 \x03(\v2\x1f.private_location.v1.DNSMonitorR\vdnsMonitors\x12E\n" + + "\ricmp_monitors\x18\x05 \x03(\v2 .private_location.v1.ICMPMonitorR\ficmpMonitors\x12\x16\n" + "\x06region\x18\x04 \x01(\tR\x06region\"\x9e\x02\n" + "\x10IngestTCPRequest\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x1c\n" + @@ -717,13 +910,36 @@ const file_private_location_v1_private_location_proto_rawDesc = "" + "\fRecordsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x122\n" + "\x05value\x18\x02 \x01(\v2\x1c.private_location.v1.RecordsR\x05value:\x028\x01\"\x13\n" + - "\x11IngestDNSResponse2\x90\x03\n" + + "\x11IngestDNSResponse\"\xab\x03\n" + + "\x11IngestICMPRequest\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x1c\n" + + "\tmonitorId\x18\x02 \x01(\tR\tmonitorId\x12\x18\n" + + "\alatency\x18\x03 \x01(\x03R\alatency\x12\x1e\n" + + "\n" + + "latencyMin\x18\x04 \x01(\x03R\n" + + "latencyMin\x12\x1e\n" + + "\n" + + "latencyMax\x18\x05 \x01(\x03R\n" + + "latencyMax\x12 \n" + + "\vpacketsSent\x18\x06 \x01(\x03R\vpacketsSent\x12(\n" + + "\x0fpacketsReceived\x18\a \x01(\x03R\x0fpacketsReceived\x12\x1c\n" + + "\ttimestamp\x18\b \x01(\x03R\ttimestamp\x12$\n" + + "\rcronTimestamp\x18\t \x01(\x03R\rcronTimestamp\x12\x10\n" + + "\x03uri\x18\n" + + " \x01(\tR\x03uri\x12\x18\n" + + "\amessage\x18\v \x01(\tR\amessage\x12$\n" + + "\rrequestStatus\x18\f \x01(\tR\rrequestStatus\x12\x14\n" + + "\x05error\x18\r \x01(\x03R\x05error\x12\x16\n" + + "\x06timing\x18\x0e \x01(\tR\x06timing\"\x14\n" + + "\x12IngestICMPResponse2\xf1\x03\n" + "\x16PrivateLocationService\x12Y\n" + "\bMonitors\x12$.private_location.v1.MonitorsRequest\x1a%.private_location.v1.MonitorsResponse\"\x00\x12\\\n" + "\tIngestTCP\x12%.private_location.v1.IngestTCPRequest\x1a&.private_location.v1.IngestTCPResponse\"\x00\x12_\n" + "\n" + "IngestHTTP\x12&.private_location.v1.IngestHTTPRequest\x1a'.private_location.v1.IngestHTTPResponse\"\x00\x12\\\n" + - "\tIngestDNS\x12%.private_location.v1.IngestDNSRequest\x1a&.private_location.v1.IngestDNSResponse\"\x00BJZHgithub.com/openstatushq/openstatus/packages/proto/private_location/v1;v1b\x06proto3" + "\tIngestDNS\x12%.private_location.v1.IngestDNSRequest\x1a&.private_location.v1.IngestDNSResponse\"\x00\x12_\n" + + "\n" + + "IngestICMP\x12&.private_location.v1.IngestICMPRequest\x1a'.private_location.v1.IngestICMPResponse\"\x00BJZHgithub.com/openstatushq/openstatus/packages/proto/private_location/v1;v1b\x06proto3" var ( file_private_location_v1_private_location_proto_rawDescOnce sync.Once @@ -737,7 +953,7 @@ func file_private_location_v1_private_location_proto_rawDescGZIP() []byte { return file_private_location_v1_private_location_proto_rawDescData } -var file_private_location_v1_private_location_proto_msgTypes = make([]protoimpl.MessageInfo, 10) +var file_private_location_v1_private_location_proto_msgTypes = make([]protoimpl.MessageInfo, 12) var file_private_location_v1_private_location_proto_goTypes = []any{ (*MonitorsRequest)(nil), // 0: private_location.v1.MonitorsRequest (*MonitorsResponse)(nil), // 1: private_location.v1.MonitorsResponse @@ -748,30 +964,36 @@ var file_private_location_v1_private_location_proto_goTypes = []any{ (*Records)(nil), // 6: private_location.v1.Records (*IngestDNSRequest)(nil), // 7: private_location.v1.IngestDNSRequest (*IngestDNSResponse)(nil), // 8: private_location.v1.IngestDNSResponse - nil, // 9: private_location.v1.IngestDNSRequest.RecordsEntry - (*HTTPMonitor)(nil), // 10: private_location.v1.HTTPMonitor - (*TCPMonitor)(nil), // 11: private_location.v1.TCPMonitor - (*DNSMonitor)(nil), // 12: private_location.v1.DNSMonitor + (*IngestICMPRequest)(nil), // 9: private_location.v1.IngestICMPRequest + (*IngestICMPResponse)(nil), // 10: private_location.v1.IngestICMPResponse + nil, // 11: private_location.v1.IngestDNSRequest.RecordsEntry + (*HTTPMonitor)(nil), // 12: private_location.v1.HTTPMonitor + (*TCPMonitor)(nil), // 13: private_location.v1.TCPMonitor + (*DNSMonitor)(nil), // 14: private_location.v1.DNSMonitor + (*ICMPMonitor)(nil), // 15: private_location.v1.ICMPMonitor } var file_private_location_v1_private_location_proto_depIdxs = []int32{ - 10, // 0: private_location.v1.MonitorsResponse.http_monitors:type_name -> private_location.v1.HTTPMonitor - 11, // 1: private_location.v1.MonitorsResponse.tcp_monitors:type_name -> private_location.v1.TCPMonitor - 12, // 2: private_location.v1.MonitorsResponse.dns_monitors:type_name -> private_location.v1.DNSMonitor - 9, // 3: private_location.v1.IngestDNSRequest.records:type_name -> private_location.v1.IngestDNSRequest.RecordsEntry - 6, // 4: private_location.v1.IngestDNSRequest.RecordsEntry.value:type_name -> private_location.v1.Records - 0, // 5: private_location.v1.PrivateLocationService.Monitors:input_type -> private_location.v1.MonitorsRequest - 2, // 6: private_location.v1.PrivateLocationService.IngestTCP:input_type -> private_location.v1.IngestTCPRequest - 4, // 7: private_location.v1.PrivateLocationService.IngestHTTP:input_type -> private_location.v1.IngestHTTPRequest - 7, // 8: private_location.v1.PrivateLocationService.IngestDNS:input_type -> private_location.v1.IngestDNSRequest - 1, // 9: private_location.v1.PrivateLocationService.Monitors:output_type -> private_location.v1.MonitorsResponse - 3, // 10: private_location.v1.PrivateLocationService.IngestTCP:output_type -> private_location.v1.IngestTCPResponse - 5, // 11: private_location.v1.PrivateLocationService.IngestHTTP:output_type -> private_location.v1.IngestHTTPResponse - 8, // 12: private_location.v1.PrivateLocationService.IngestDNS:output_type -> private_location.v1.IngestDNSResponse - 9, // [9:13] is the sub-list for method output_type - 5, // [5:9] is the sub-list for method input_type - 5, // [5:5] is the sub-list for extension type_name - 5, // [5:5] is the sub-list for extension extendee - 0, // [0:5] is the sub-list for field type_name + 12, // 0: private_location.v1.MonitorsResponse.http_monitors:type_name -> private_location.v1.HTTPMonitor + 13, // 1: private_location.v1.MonitorsResponse.tcp_monitors:type_name -> private_location.v1.TCPMonitor + 14, // 2: private_location.v1.MonitorsResponse.dns_monitors:type_name -> private_location.v1.DNSMonitor + 15, // 3: private_location.v1.MonitorsResponse.icmp_monitors:type_name -> private_location.v1.ICMPMonitor + 11, // 4: private_location.v1.IngestDNSRequest.records:type_name -> private_location.v1.IngestDNSRequest.RecordsEntry + 6, // 5: private_location.v1.IngestDNSRequest.RecordsEntry.value:type_name -> private_location.v1.Records + 0, // 6: private_location.v1.PrivateLocationService.Monitors:input_type -> private_location.v1.MonitorsRequest + 2, // 7: private_location.v1.PrivateLocationService.IngestTCP:input_type -> private_location.v1.IngestTCPRequest + 4, // 8: private_location.v1.PrivateLocationService.IngestHTTP:input_type -> private_location.v1.IngestHTTPRequest + 7, // 9: private_location.v1.PrivateLocationService.IngestDNS:input_type -> private_location.v1.IngestDNSRequest + 9, // 10: private_location.v1.PrivateLocationService.IngestICMP:input_type -> private_location.v1.IngestICMPRequest + 1, // 11: private_location.v1.PrivateLocationService.Monitors:output_type -> private_location.v1.MonitorsResponse + 3, // 12: private_location.v1.PrivateLocationService.IngestTCP:output_type -> private_location.v1.IngestTCPResponse + 5, // 13: private_location.v1.PrivateLocationService.IngestHTTP:output_type -> private_location.v1.IngestHTTPResponse + 8, // 14: private_location.v1.PrivateLocationService.IngestDNS:output_type -> private_location.v1.IngestDNSResponse + 10, // 15: private_location.v1.PrivateLocationService.IngestICMP:output_type -> private_location.v1.IngestICMPResponse + 11, // [11:16] is the sub-list for method output_type + 6, // [6:11] is the sub-list for method input_type + 6, // [6:6] is the sub-list for extension type_name + 6, // [6:6] is the sub-list for extension extendee + 0, // [0:6] is the sub-list for field type_name } func init() { file_private_location_v1_private_location_proto_init() } @@ -781,6 +1003,7 @@ func file_private_location_v1_private_location_proto_init() { } file_private_location_v1_dns_monitor_proto_init() file_private_location_v1_http_monitor_proto_init() + file_private_location_v1_icmp_monitor_proto_init() file_private_location_v1_tcp_monitor_proto_init() type x struct{} out := protoimpl.TypeBuilder{ @@ -788,7 +1011,7 @@ func file_private_location_v1_private_location_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_private_location_v1_private_location_proto_rawDesc), len(file_private_location_v1_private_location_proto_rawDesc)), NumEnums: 0, - NumMessages: 10, + NumMessages: 12, NumExtensions: 0, NumServices: 1, }, diff --git a/apps/checker/request/request.go b/apps/checker/request/request.go index 2828e3342..ac5b9626c 100644 --- a/apps/checker/request/request.go +++ b/apps/checker/request/request.go @@ -108,6 +108,23 @@ type TCPCheckerRequest struct { } `json:"otelConfig"` } +type ICMPCheckerRequest struct { + Status string `json:"status"` + WorkspaceID string `json:"workspaceId"` + URI string `json:"uri"` + MonitorID string `json:"monitorId"` + Trigger string `json:"trigger,omitempty"` + RequestId int64 `json:"requestId,omitempty"` + CronTimestamp int64 `json:"cronTimestamp"` + Timeout int64 `json:"timeout"` + DegradedAfter int64 `json:"degradedAfter,omitempty"` + Retry int64 `json:"retry,omitempty"` + OtelConfig struct { + Endpoint string `json:"endpoint"` + Headers map[string]string `json:"headers,omitempty"` + } `json:"otelConfig"` +} + type TCPRequest struct { WorkspaceID string `json:"workspaceId"` URL string `json:"url"` diff --git a/apps/dashboard/src/app/(dashboard)/monitors/[id]/logs/client.tsx b/apps/dashboard/src/app/(dashboard)/monitors/[id]/logs/client.tsx index 5afeb6c15..26680f149 100644 --- a/apps/dashboard/src/app/(dashboard)/monitors/[id]/logs/client.tsx +++ b/apps/dashboard/src/app/(dashboard)/monitors/[id]/logs/client.tsx @@ -131,7 +131,9 @@ export function Client() { setPagination={setPagination} paginationComponent={DataTablePagination} defaultColumnVisibility={ - monitor.jobType === "tcp" || monitor.jobType === "dns" + monitor.jobType === "tcp" || + monitor.jobType === "dns" || + monitor.jobType === "icmp" ? { timing: false, statusCode: false } : {} } diff --git a/apps/dashboard/src/app/(dashboard)/monitors/[id]/nav-actions.tsx b/apps/dashboard/src/app/(dashboard)/monitors/[id]/nav-actions.tsx index 6a1a7517e..7197d2c87 100644 --- a/apps/dashboard/src/app/(dashboard)/monitors/[id]/nav-actions.tsx +++ b/apps/dashboard/src/app/(dashboard)/monitors/[id]/nav-actions.tsx @@ -26,10 +26,13 @@ import { useTRPC } from "@/lib/trpc/client"; type TestTCP = RouterOutputs["checker"]["testTcp"]; type TestHTTP = RouterOutputs["checker"]["testHttp"]; type TestDNS = RouterOutputs["checker"]["testDns"]; +type TestICMP = RouterOutputs["checker"]["testIcmp"]; export function NavActions() { const { id } = useParams<{ id: string }>(); - const [test, setTest] = useState(null); + const [test, setTest] = useState< + TestTCP | TestHTTP | TestDNS | TestICMP | null + >(null); const queryClient = useQueryClient(); const trpc = useTRPC(); const router = useRouter(); @@ -66,6 +69,7 @@ export function NavActions() { const testHttpMutation = useMutation(trpc.checker.testHttp.mutationOptions()); const testTcpMutation = useMutation(trpc.checker.testTcp.mutationOptions()); const testDnsMutation = useMutation(trpc.checker.testDns.mutationOptions()); + const testIcmpMutation = useMutation(trpc.checker.testIcmp.mutationOptions()); // curl only speaks HTTP — the action is hidden for tcp/dns monitors const curlCommand = @@ -160,6 +164,22 @@ export function NavActions() { return "DNS test failed"; }, }); + } else if (monitor?.jobType === "icmp") { + const promise = testIcmpMutation.mutateAsync({ url: monitor.url }); + + toast.promise(promise, { + loading: "Testing ICMP request...", + success: (data) => { + setTest(data); + return "ICMP test completed successfully"; + }, + error: (error) => { + if (isTRPCClientError(error)) { + return error.message; + } + return "ICMP test failed"; + }, + }); } } diff --git a/apps/dashboard/src/app/(dashboard)/monitors/[id]/overview/client.tsx b/apps/dashboard/src/app/(dashboard)/monitors/[id]/overview/client.tsx index 0b9f5167b..35e9c6add 100644 --- a/apps/dashboard/src/app/(dashboard)/monitors/[id]/overview/client.tsx +++ b/apps/dashboard/src/app/(dashboard)/monitors/[id]/overview/client.tsx @@ -83,7 +83,7 @@ export function Client() { ...trpc.tinybird.metricsRegions.queryOptions({ monitorId: id, period: effectivePeriod, - type: (monitor?.jobType ?? "http") as "http" | "tcp", + type: (monitor?.jobType ?? "http") as "http" | "tcp" | "dns" | "icmp", regions: selectedRegions, // bucket by period (daily at 30d/90d) to keep payload + chart readable interval: periodToInterval[effectivePeriod], @@ -144,7 +144,7 @@ export function Client() { @@ -158,7 +158,7 @@ export function Client() { @@ -197,7 +197,7 @@ export function Client() { monitorId={id} percentile={percentile} degradedAfter={monitor.degradedAfter} - type={monitor.jobType as "http" | "tcp"} + type={monitor.jobType as "http" | "tcp" | "dns" | "icmp"} period={effectivePeriod} regions={selectedRegions} /> diff --git a/apps/dashboard/src/components/chart/chart-area-latency.tsx b/apps/dashboard/src/components/chart/chart-area-latency.tsx index 98cb3d706..b1dfc8887 100644 --- a/apps/dashboard/src/components/chart/chart-area-latency.tsx +++ b/apps/dashboard/src/components/chart/chart-area-latency.tsx @@ -51,7 +51,7 @@ export function ChartAreaLatency({ degradedAfter: number | null; percentile: (typeof PERCENTILES)[number]; period: (typeof PERIODS)[number]; - type: "http" | "tcp"; + type: "http" | "tcp" | "dns" | "icmp"; regions: string[] | undefined; }) { const trpc = useTRPC(); diff --git a/apps/dashboard/src/components/chart/chart-bar-uptime-light.tsx b/apps/dashboard/src/components/chart/chart-bar-uptime-light.tsx index 7f8493fc5..ac4672c72 100644 --- a/apps/dashboard/src/components/chart/chart-bar-uptime-light.tsx +++ b/apps/dashboard/src/components/chart/chart-bar-uptime-light.tsx @@ -36,7 +36,7 @@ export function ChartBarUptimeLight({ regions, }: { monitorId: string; - type: "http" | "tcp"; + type: "http" | "tcp" | "dns" | "icmp"; regions?: Region[]; }) { const trpc = useTRPC(); diff --git a/apps/dashboard/src/components/chart/chart-bar-uptime.tsx b/apps/dashboard/src/components/chart/chart-bar-uptime.tsx index f408334ae..cfbb1f7eb 100644 --- a/apps/dashboard/src/components/chart/chart-bar-uptime.tsx +++ b/apps/dashboard/src/components/chart/chart-bar-uptime.tsx @@ -44,7 +44,7 @@ export function ChartBarUptime({ }: { monitorId: string; period: (typeof PERIODS)[number]; - type: "http" | "tcp"; + type: "http" | "tcp" | "dns" | "icmp"; regions: string[] | undefined; }) { const isMobile = useIsMobile(); diff --git a/apps/dashboard/src/components/data-table/response-logs/data-table-basics.tsx b/apps/dashboard/src/components/data-table/response-logs/data-table-basics.tsx index debc22e62..41f23c055 100644 --- a/apps/dashboard/src/components/data-table/response-logs/data-table-basics.tsx +++ b/apps/dashboard/src/components/data-table/response-logs/data-table-basics.tsx @@ -50,6 +50,11 @@ export function DataTableBasics({ ); } + if (data.type === "icmp") { + return ( + + ); + } return null; } @@ -467,6 +472,170 @@ export function DataTableBasicsTCP({ ); } +export function DataTableBasicsICMP({ + data, + privateLocations, +}: { + data: Extract & { + trigger?: "cron" | "api" | "test" | null; + }; + privateLocations?: PrivateLocation[]; +}) { + const privateLocataion = privateLocations?.find( + (location) => String(location.id) === String(data.region), + ); + const regionConfig = getRegionInfo(data.region, { + location: privateLocataion?.name, + }); + const packetLoss = + data.packetsSent > 0 + ? (data.packetsSent - data.packetsReceived) / data.packetsSent + : 0; + return ( + + + + + + + + Request + + + + Result + + +
+
+
+ {data?.requestStatus ?? "unknown"} +
+
+ + + {data.id ? ( + + + ID + + + {data.id} + + + ) : null} + + + Timestamp + + + + + + + + Host + + + {data.uri} + + + + + Latency (avg) + + + + + + + + Latency (min / max) + + + {formatMilliseconds(data.latencyMin)} /{" "} + {formatMilliseconds(data.latencyMax)} + + + + + Packets + + + {data.packetsReceived} / {data.packetsSent} received + + + + + Packet Loss + + + {formatPercentage(packetLoss)} + + + + + Region + + + {regionConfig?.flag} {regionConfig?.code}{" "} + + {regionConfig?.location} + + + + + + Cloud Provider + + + + + {regionConfig?.provider} + + + + {data.trigger ? ( + + + Trigger + + + {data?.trigger} + + + ) : null} + {data?.errorMessage ? ( + <> + + Error Message + + + +
+                  {data.errorMessage}
+                
+
+
+ + ) : null} + +
+ ); +} + export function DataTableBasicsDNS({ data, privateLocations, diff --git a/apps/dashboard/src/components/data-table/response-logs/data-table-sheet-test.tsx b/apps/dashboard/src/components/data-table/response-logs/data-table-sheet-test.tsx index b01dd7013..71ecdfddd 100644 --- a/apps/dashboard/src/components/data-table/response-logs/data-table-sheet-test.tsx +++ b/apps/dashboard/src/components/data-table/response-logs/data-table-sheet-test.tsx @@ -15,6 +15,7 @@ import { DataTableBasics } from "./data-table-basics"; type TestTCP = RouterOutputs["checker"]["testTcp"]; type TestHTTP = RouterOutputs["checker"]["testHttp"]; type TestDNS = RouterOutputs["checker"]["testDns"]; +type TestICMP = RouterOutputs["checker"]["testIcmp"]; type Monitor = NonNullable; export function DataTableSheetTest({ @@ -22,7 +23,7 @@ export function DataTableSheetTest({ monitor, onClose, }: { - data: TestTCP | TestHTTP | TestDNS | null; + data: TestTCP | TestHTTP | TestDNS | TestICMP | null; monitor: Monitor; onClose: () => void; }) { @@ -45,7 +46,10 @@ export function DataTableSheetTest({ ); } -function mapping(data: TestTCP | TestHTTP | TestDNS, monitor: Monitor) { +function mapping( + data: TestTCP | TestHTTP | TestDNS | TestICMP, + monitor: Monitor, +) { switch (data.type) { case "http": return { @@ -102,6 +106,26 @@ function mapping(data: TestTCP | TestHTTP | TestDNS, monitor: Monitor) { errorMessage: null, assertions: null, } as const; + case "icmp": + return { + id: null, + trigger: null, + timestamp: data.timestamp, + cronTimestamp: data.timestamp, + region: data.region, + type: data.type, + requestStatus: "success", + error: false, + latency: data.latency ?? 0, + latencyMin: data.latencyMin ?? 0, + latencyMax: data.latencyMax ?? 0, + packetsSent: data.packetsSent ?? 0, + packetsReceived: data.packetsReceived ?? 0, + uri: monitor.url, + monitorId: String(monitor.id), + errorMessage: null, + assertions: null, + } as const; default: return null; } diff --git a/apps/dashboard/src/components/forms/monitor/form-general.tsx b/apps/dashboard/src/components/forms/monitor/form-general.tsx index 70179e263..5233f2d60 100644 --- a/apps/dashboard/src/components/forms/monitor/form-general.tsx +++ b/apps/dashboard/src/components/forms/monitor/form-general.tsx @@ -13,7 +13,7 @@ import { textBodyAssertion, } from "@openstatus/assertions"; import { monitorMethods } from "@openstatus/db/src/schema/monitors/constants"; -import { Globe, Network, Add, Server, Close } from "@openstatus/icons"; +import { Globe, Network, Add, Speed, Server, Close } from "@openstatus/icons"; import { AlertDialog, AlertDialogAction, @@ -72,7 +72,7 @@ import { FormCardTitle, } from "@/components/forms/form-card"; -const TYPES = ["http", "tcp", "dns"] as const; +const TYPES = ["http", "tcp", "dns", "icmp"] as const; const HTTP_ASSERTION_TYPES = ["status", "header", "textBody"] as const; const DNS_ASSERTION_TYPES = dnsRecords; @@ -273,13 +273,14 @@ export function FormGeneral({ {[ { value: "http", icon: Globe, label: "HTTP" }, { value: "tcp", icon: Network, label: "TCP" }, { value: "dns", icon: Server, label: "DNS" }, + { value: "icmp", icon: Speed, label: "ICMP" }, ].map((type) => { return ( @@ -724,6 +725,48 @@ export function FormGeneral({ )} + {watchType === "icmp" && ( + + ( + + Host + + + + + + The host to ping. Supports a domain, IPv4, or IPv6 address + (no port). + + + )} + /> +
+ Examples: +
    +
  • + Domain:{" "} + + openstatus.dev + +
  • +
  • + IPv4:{" "} + 1.1.1.1 +
  • +
  • + IPv6:{" "} + + 2001:4860:4860::8888 + +
  • +
+
+
+ )} {watchType === "dns" && ( <> diff --git a/apps/dashboard/src/components/forms/monitor/update.tsx b/apps/dashboard/src/components/forms/monitor/update.tsx index 7a23f94e9..071607f47 100644 --- a/apps/dashboard/src/components/forms/monitor/update.tsx +++ b/apps/dashboard/src/components/forms/monitor/update.tsx @@ -119,7 +119,7 @@ export function FormMonitorUpdate() { `) + or run it as root. The agent falls back to this automatically when datagram + sockets are unavailable. + +Without either, ICMP checks fail to open a socket and are reported as errors. diff --git a/apps/private-location/internal/database/models.go b/apps/private-location/internal/database/models.go index 037a2b4f8..9334198c3 100644 --- a/apps/private-location/internal/database/models.go +++ b/apps/private-location/internal/database/models.go @@ -10,6 +10,7 @@ const ( JobTypeUDP JobType = "udp" JobTypeHTTP JobType = "http" JobTypeDNS JobType = "dns" + JobTypeICMP JobType = "icmp" ) type Monitor struct { diff --git a/apps/private-location/internal/server/ingest_icmp.go b/apps/private-location/internal/server/ingest_icmp.go new file mode 100644 index 000000000..1a66a70c9 --- /dev/null +++ b/apps/private-location/internal/server/ingest_icmp.go @@ -0,0 +1,91 @@ +package server + +import ( + "context" + "strconv" + + "connectrpc.com/connect" + "github.com/openstatushq/openstatus/apps/private-location/internal/tinybird" + private_locationv1 "github.com/openstatushq/openstatus/apps/private-location/proto/private_location/v1" +) + +type ICMPData struct { + ID string `json:"id"` + Timing string `json:"timing"` + ErrorMessage string `json:"errorMessage"` + Region string `json:"region"` + Trigger string `json:"trigger"` + URI string `json:"uri"` + RequestStatus string `json:"requestStatus,omitempty"` + + RequestId int64 `json:"requestId,omitempty"` + WorkspaceID int64 `json:"workspaceId"` + MonitorID int64 `json:"monitorId"` + Timestamp int64 `json:"timestamp"` + Latency int64 `json:"latency"` + LatencyMin int64 `json:"latencyMin"` + LatencyMax int64 `json:"latencyMax"` + CronTimestamp int64 `json:"cronTimestamp"` + + PacketsSent uint8 `json:"packetsSent"` + PacketsReceived uint8 `json:"packetsReceived"` + + Error uint8 `json:"error"` +} + +func (h *privateLocationHandler) IngestICMP(ctx context.Context, req *connect.Request[private_locationv1.IngestICMPRequest]) (*connect.Response[private_locationv1.IngestICMPResponse], error) { + token := req.Header().Get("openstatus-token") + if token == "" { + return nil, connect.NewError(connect.CodeUnauthenticated, ErrMissingToken) + } + + if err := ValidateIngestICMPRequest(req.Msg); err != nil { + return nil, NewValidationError(err) + } + + ic, err := h.getIngestContext(ctx, token, req.Msg.MonitorId) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + + // Enrich wide event with business context + if holder := GetEvent(ctx); holder != nil { + holder.Event["private_location"] = map[string]any{ + "monitor_id": req.Msg.MonitorId, + "workspace_id": ic.Monitor.WorkspaceID, + "region_id": ic.Region.ID, + "datasource": tinybird.DatasourceICMP, + } + } + + data := ICMPData{ + ID: req.Msg.Id, + WorkspaceID: int64(ic.Monitor.WorkspaceID), + Timestamp: req.Msg.Timestamp, + Error: uint8(req.Msg.Error), + Region: strconv.Itoa(ic.Region.ID), + MonitorID: int64(ic.Monitor.ID), + Timing: req.Msg.Timing, + Latency: req.Msg.Latency, + LatencyMin: req.Msg.LatencyMin, + LatencyMax: req.Msg.LatencyMax, + PacketsSent: uint8(req.Msg.PacketsSent), + PacketsReceived: uint8(req.Msg.PacketsReceived), + CronTimestamp: req.Msg.CronTimestamp, + Trigger: "cron", + URI: req.Msg.Uri, + RequestStatus: req.Msg.RequestStatus, + } + + h.sendEventAndUpdateLastSeen(ctx, data, tinybird.DatasourceICMP, ic.Region.ID) + + h.forwardStatusUpdate(ctx, ic, statusUpdateInput{ + RequestStatus: data.RequestStatus, + Message: data.ErrorMessage, + Latency: data.Latency, + CronTimestamp: data.CronTimestamp, + ErrorFlag: data.Error, + }) + + return connect.NewResponse(&private_locationv1.IngestICMPResponse{}), nil +} diff --git a/apps/private-location/internal/server/monitors.go b/apps/private-location/internal/server/monitors.go index e227e02c7..04cece34a 100644 --- a/apps/private-location/internal/server/monitors.go +++ b/apps/private-location/internal/server/monitors.go @@ -195,7 +195,7 @@ func (h *privateLocationHandler) Monitors(ctx context.Context, req *connect.Requ if err != nil { return nil, connect.NewError(connect.CodeInternal, err) } - httpMonitors, tcpMonitors, dnsMonitors, workspaceId := mapMonitors(ctx, monitors) + httpMonitors, tcpMonitors, dnsMonitors, icmpMonitors, workspaceId := mapMonitors(ctx, monitors) // Enrich wide event with monitor counts if holder := GetEvent(ctx); holder != nil { @@ -204,6 +204,7 @@ func (h *privateLocationHandler) Monitors(ctx context.Context, req *connect.Requ "http_monitors": len(httpMonitors), "tcp_monitors": len(tcpMonitors), "dns_monitors": len(dnsMonitors), + "icmp_monitors": len(icmpMonitors), "total_monitors": len(monitors), } } @@ -212,6 +213,7 @@ func (h *privateLocationHandler) Monitors(ctx context.Context, req *connect.Requ HttpMonitors: httpMonitors, TcpMonitors: tcpMonitors, DnsMonitors: dnsMonitors, + IcmpMonitors: icmpMonitors, Region: location.Name, }), nil } @@ -220,12 +222,14 @@ func mapMonitors(ctx context.Context, monitors []database.Monitor) ( []*private_locationv1.HTTPMonitor, []*private_locationv1.TCPMonitor, []*private_locationv1.DNSMonitor, + []*private_locationv1.ICMPMonitor, int, ) { var workspaceId int var httpMonitors []*private_locationv1.HTTPMonitor var tcpMonitors []*private_locationv1.TCPMonitor var dnsMonitors []*private_locationv1.DNSMonitor + var icmpMonitors []*private_locationv1.ICMPMonitor for _, monitor := range monitors { if workspaceId == 0 { workspaceId = monitor.WorkspaceID @@ -238,10 +242,12 @@ func mapMonitors(ctx context.Context, monitors []database.Monitor) ( tcpMonitors = append(tcpMonitors, toTCPMonitor(ctx, monitor)) case database.JobTypeDNS: dnsMonitors = append(dnsMonitors, toDNSMonitor(ctx, monitor)) + case database.JobTypeICMP: + icmpMonitors = append(icmpMonitors, toICMPMonitor(ctx, monitor)) } } - return httpMonitors, tcpMonitors, dnsMonitors, workspaceId + return httpMonitors, tcpMonitors, dnsMonitors, icmpMonitors, workspaceId } func toHTTPMonitor(ctx context.Context, monitor database.Monitor) *private_locationv1.HTTPMonitor { @@ -283,6 +289,18 @@ func toTCPMonitor(ctx context.Context, monitor database.Monitor) *private_locati } } +func toICMPMonitor(ctx context.Context, monitor database.Monitor) *private_locationv1.ICMPMonitor { + return &private_locationv1.ICMPMonitor{ + Id: strconv.Itoa(monitor.ID), + Uri: monitor.URL, + Timeout: monitor.Timeout, + DegradedAt: &monitor.DegradedAfter.Int64, + Periodicity: monitor.Periodicity, + Retry: int64(monitor.Retry), + OtelConfig: buildOtelConfig(ctx, monitor), + } +} + func toDNSMonitor(ctx context.Context, monitor database.Monitor) *private_locationv1.DNSMonitor { return &private_locationv1.DNSMonitor{ Id: strconv.Itoa(monitor.ID), diff --git a/apps/private-location/internal/server/validation.go b/apps/private-location/internal/server/validation.go index 43e42b6f4..cf9f11f2c 100644 --- a/apps/private-location/internal/server/validation.go +++ b/apps/private-location/internal/server/validation.go @@ -58,6 +58,20 @@ func ValidateIngestDNSRequest(req *private_locationv1.IngestDNSRequest) error { return nil } +// ValidateIngestICMPRequest validates an ICMP ingest request +func ValidateIngestICMPRequest(req *private_locationv1.IngestICMPRequest) error { + if req.MonitorId == "" { + return ErrEmptyMonitorID + } + if req.Latency < 0 { + return ErrInvalidLatency + } + if req.Timestamp <= 0 { + return ErrInvalidTimestamp + } + return nil +} + // NewValidationError creates a Connect error for validation failures func NewValidationError(err error) *connect.Error { return connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("validation error: %w", err)) diff --git a/apps/private-location/internal/tinybird/client.go b/apps/private-location/internal/tinybird/client.go index 98a30905d..b50fd5386 100644 --- a/apps/private-location/internal/tinybird/client.go +++ b/apps/private-location/internal/tinybird/client.go @@ -15,6 +15,7 @@ const ( DatasourceHTTP = "ping_response__v8" DatasourceTCP = "tcp_response__v0" DatasourceDNS = "dns_response__v0" + DatasourceICMP = "icmp_response__v0" ) func getBaseURL() string { diff --git a/apps/private-location/proto/private_location/v1/icmp_monitor.pb.go b/apps/private-location/proto/private_location/v1/icmp_monitor.pb.go new file mode 100644 index 000000000..d842fba5e --- /dev/null +++ b/apps/private-location/proto/private_location/v1/icmp_monitor.pb.go @@ -0,0 +1,183 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: private_location/v1/icmp_monitor.proto + +package v1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type ICMPMonitor struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Uri string `protobuf:"bytes,2,opt,name=uri,proto3" json:"uri,omitempty"` + Timeout int64 `protobuf:"varint,3,opt,name=timeout,proto3" json:"timeout,omitempty"` + DegradedAt *int64 `protobuf:"varint,4,opt,name=degraded_at,json=degradedAt,proto3,oneof" json:"degraded_at,omitempty"` + Periodicity string `protobuf:"bytes,5,opt,name=periodicity,proto3" json:"periodicity,omitempty"` + Retry int64 `protobuf:"varint,6,opt,name=retry,proto3" json:"retry,omitempty"` + OtelConfig *OtelConfig `protobuf:"bytes,20,opt,name=otel_config,json=otelConfig,proto3" json:"otel_config,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ICMPMonitor) Reset() { + *x = ICMPMonitor{} + mi := &file_private_location_v1_icmp_monitor_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ICMPMonitor) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ICMPMonitor) ProtoMessage() {} + +func (x *ICMPMonitor) ProtoReflect() protoreflect.Message { + mi := &file_private_location_v1_icmp_monitor_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ICMPMonitor.ProtoReflect.Descriptor instead. +func (*ICMPMonitor) Descriptor() ([]byte, []int) { + return file_private_location_v1_icmp_monitor_proto_rawDescGZIP(), []int{0} +} + +func (x *ICMPMonitor) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *ICMPMonitor) GetUri() string { + if x != nil { + return x.Uri + } + return "" +} + +func (x *ICMPMonitor) GetTimeout() int64 { + if x != nil { + return x.Timeout + } + return 0 +} + +func (x *ICMPMonitor) GetDegradedAt() int64 { + if x != nil && x.DegradedAt != nil { + return *x.DegradedAt + } + return 0 +} + +func (x *ICMPMonitor) GetPeriodicity() string { + if x != nil { + return x.Periodicity + } + return "" +} + +func (x *ICMPMonitor) GetRetry() int64 { + if x != nil { + return x.Retry + } + return 0 +} + +func (x *ICMPMonitor) GetOtelConfig() *OtelConfig { + if x != nil { + return x.OtelConfig + } + return nil +} + +var File_private_location_v1_icmp_monitor_proto protoreflect.FileDescriptor + +const file_private_location_v1_icmp_monitor_proto_rawDesc = "" + + "\n" + + "&private_location/v1/icmp_monitor.proto\x12\x13private_location.v1\x1a\x1eprivate_location/v1/otel.proto\"\xf9\x01\n" + + "\vICMPMonitor\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x10\n" + + "\x03uri\x18\x02 \x01(\tR\x03uri\x12\x18\n" + + "\atimeout\x18\x03 \x01(\x03R\atimeout\x12$\n" + + "\vdegraded_at\x18\x04 \x01(\x03H\x00R\n" + + "degradedAt\x88\x01\x01\x12 \n" + + "\vperiodicity\x18\x05 \x01(\tR\vperiodicity\x12\x14\n" + + "\x05retry\x18\x06 \x01(\x03R\x05retry\x12@\n" + + "\votel_config\x18\x14 \x01(\v2\x1f.private_location.v1.OtelConfigR\n" + + "otelConfigB\x0e\n" + + "\f_degraded_atBJZHgithub.com/openstatushq/openstatus/packages/proto/private_location/v1;v1b\x06proto3" + +var ( + file_private_location_v1_icmp_monitor_proto_rawDescOnce sync.Once + file_private_location_v1_icmp_monitor_proto_rawDescData []byte +) + +func file_private_location_v1_icmp_monitor_proto_rawDescGZIP() []byte { + file_private_location_v1_icmp_monitor_proto_rawDescOnce.Do(func() { + file_private_location_v1_icmp_monitor_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_private_location_v1_icmp_monitor_proto_rawDesc), len(file_private_location_v1_icmp_monitor_proto_rawDesc))) + }) + return file_private_location_v1_icmp_monitor_proto_rawDescData +} + +var file_private_location_v1_icmp_monitor_proto_msgTypes = make([]protoimpl.MessageInfo, 1) +var file_private_location_v1_icmp_monitor_proto_goTypes = []any{ + (*ICMPMonitor)(nil), // 0: private_location.v1.ICMPMonitor + (*OtelConfig)(nil), // 1: private_location.v1.OtelConfig +} +var file_private_location_v1_icmp_monitor_proto_depIdxs = []int32{ + 1, // 0: private_location.v1.ICMPMonitor.otel_config:type_name -> private_location.v1.OtelConfig + 1, // [1:1] is the sub-list for method output_type + 1, // [1:1] is the sub-list for method input_type + 1, // [1:1] is the sub-list for extension type_name + 1, // [1:1] is the sub-list for extension extendee + 0, // [0:1] is the sub-list for field type_name +} + +func init() { file_private_location_v1_icmp_monitor_proto_init() } +func file_private_location_v1_icmp_monitor_proto_init() { + if File_private_location_v1_icmp_monitor_proto != nil { + return + } + file_private_location_v1_otel_proto_init() + file_private_location_v1_icmp_monitor_proto_msgTypes[0].OneofWrappers = []any{} + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_private_location_v1_icmp_monitor_proto_rawDesc), len(file_private_location_v1_icmp_monitor_proto_rawDesc)), + NumEnums: 0, + NumMessages: 1, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_private_location_v1_icmp_monitor_proto_goTypes, + DependencyIndexes: file_private_location_v1_icmp_monitor_proto_depIdxs, + MessageInfos: file_private_location_v1_icmp_monitor_proto_msgTypes, + }.Build() + File_private_location_v1_icmp_monitor_proto = out.File + file_private_location_v1_icmp_monitor_proto_goTypes = nil + file_private_location_v1_icmp_monitor_proto_depIdxs = nil +} diff --git a/apps/private-location/proto/private_location/v1/private_location.connect.go b/apps/private-location/proto/private_location/v1/private_location.connect.go index 40235485f..8138ab7c5 100644 --- a/apps/private-location/proto/private_location/v1/private_location.connect.go +++ b/apps/private-location/proto/private_location/v1/private_location.connect.go @@ -44,6 +44,9 @@ const ( // PrivateLocationServiceIngestDNSProcedure is the fully-qualified name of the // PrivateLocationService's IngestDNS RPC. PrivateLocationServiceIngestDNSProcedure = "/private_location.v1.PrivateLocationService/IngestDNS" + // PrivateLocationServiceIngestICMPProcedure is the fully-qualified name of the + // PrivateLocationService's IngestICMP RPC. + PrivateLocationServiceIngestICMPProcedure = "/private_location.v1.PrivateLocationService/IngestICMP" ) // PrivateLocationServiceClient is a client for the private_location.v1.PrivateLocationService @@ -53,6 +56,7 @@ type PrivateLocationServiceClient interface { IngestTCP(context.Context, *connect.Request[IngestTCPRequest]) (*connect.Response[IngestTCPResponse], error) IngestHTTP(context.Context, *connect.Request[IngestHTTPRequest]) (*connect.Response[IngestHTTPResponse], error) IngestDNS(context.Context, *connect.Request[IngestDNSRequest]) (*connect.Response[IngestDNSResponse], error) + IngestICMP(context.Context, *connect.Request[IngestICMPRequest]) (*connect.Response[IngestICMPResponse], error) } // NewPrivateLocationServiceClient constructs a client for the @@ -90,6 +94,12 @@ func NewPrivateLocationServiceClient(httpClient connect.HTTPClient, baseURL stri connect.WithSchema(privateLocationServiceMethods.ByName("IngestDNS")), connect.WithClientOptions(opts...), ), + ingestICMP: connect.NewClient[IngestICMPRequest, IngestICMPResponse]( + httpClient, + baseURL+PrivateLocationServiceIngestICMPProcedure, + connect.WithSchema(privateLocationServiceMethods.ByName("IngestICMP")), + connect.WithClientOptions(opts...), + ), } } @@ -99,6 +109,7 @@ type privateLocationServiceClient struct { ingestTCP *connect.Client[IngestTCPRequest, IngestTCPResponse] ingestHTTP *connect.Client[IngestHTTPRequest, IngestHTTPResponse] ingestDNS *connect.Client[IngestDNSRequest, IngestDNSResponse] + ingestICMP *connect.Client[IngestICMPRequest, IngestICMPResponse] } // Monitors calls private_location.v1.PrivateLocationService.Monitors. @@ -121,6 +132,11 @@ func (c *privateLocationServiceClient) IngestDNS(ctx context.Context, req *conne return c.ingestDNS.CallUnary(ctx, req) } +// IngestICMP calls private_location.v1.PrivateLocationService.IngestICMP. +func (c *privateLocationServiceClient) IngestICMP(ctx context.Context, req *connect.Request[IngestICMPRequest]) (*connect.Response[IngestICMPResponse], error) { + return c.ingestICMP.CallUnary(ctx, req) +} + // PrivateLocationServiceHandler is an implementation of the // private_location.v1.PrivateLocationService service. type PrivateLocationServiceHandler interface { @@ -128,6 +144,7 @@ type PrivateLocationServiceHandler interface { IngestTCP(context.Context, *connect.Request[IngestTCPRequest]) (*connect.Response[IngestTCPResponse], error) IngestHTTP(context.Context, *connect.Request[IngestHTTPRequest]) (*connect.Response[IngestHTTPResponse], error) IngestDNS(context.Context, *connect.Request[IngestDNSRequest]) (*connect.Response[IngestDNSResponse], error) + IngestICMP(context.Context, *connect.Request[IngestICMPRequest]) (*connect.Response[IngestICMPResponse], error) } // NewPrivateLocationServiceHandler builds an HTTP handler from the service implementation. It @@ -161,6 +178,12 @@ func NewPrivateLocationServiceHandler(svc PrivateLocationServiceHandler, opts .. connect.WithSchema(privateLocationServiceMethods.ByName("IngestDNS")), connect.WithHandlerOptions(opts...), ) + privateLocationServiceIngestICMPHandler := connect.NewUnaryHandler( + PrivateLocationServiceIngestICMPProcedure, + svc.IngestICMP, + connect.WithSchema(privateLocationServiceMethods.ByName("IngestICMP")), + connect.WithHandlerOptions(opts...), + ) return "/private_location.v1.PrivateLocationService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case PrivateLocationServiceMonitorsProcedure: @@ -171,6 +194,8 @@ func NewPrivateLocationServiceHandler(svc PrivateLocationServiceHandler, opts .. privateLocationServiceIngestHTTPHandler.ServeHTTP(w, r) case PrivateLocationServiceIngestDNSProcedure: privateLocationServiceIngestDNSHandler.ServeHTTP(w, r) + case PrivateLocationServiceIngestICMPProcedure: + privateLocationServiceIngestICMPHandler.ServeHTTP(w, r) default: http.NotFound(w, r) } @@ -195,3 +220,7 @@ func (UnimplementedPrivateLocationServiceHandler) IngestHTTP(context.Context, *c func (UnimplementedPrivateLocationServiceHandler) IngestDNS(context.Context, *connect.Request[IngestDNSRequest]) (*connect.Response[IngestDNSResponse], error) { return nil, connect.NewError(connect.CodeUnimplemented, errors.New("private_location.v1.PrivateLocationService.IngestDNS is not implemented")) } + +func (UnimplementedPrivateLocationServiceHandler) IngestICMP(context.Context, *connect.Request[IngestICMPRequest]) (*connect.Response[IngestICMPResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("private_location.v1.PrivateLocationService.IngestICMP is not implemented")) +} diff --git a/apps/private-location/proto/private_location/v1/private_location.pb.go b/apps/private-location/proto/private_location/v1/private_location.pb.go index e31eb986c..a28112bc7 100644 --- a/apps/private-location/proto/private_location/v1/private_location.pb.go +++ b/apps/private-location/proto/private_location/v1/private_location.pb.go @@ -62,6 +62,7 @@ type MonitorsResponse struct { HttpMonitors []*HTTPMonitor `protobuf:"bytes,1,rep,name=http_monitors,json=httpMonitors,proto3" json:"http_monitors,omitempty"` TcpMonitors []*TCPMonitor `protobuf:"bytes,2,rep,name=tcp_monitors,json=tcpMonitors,proto3" json:"tcp_monitors,omitempty"` DnsMonitors []*DNSMonitor `protobuf:"bytes,3,rep,name=dns_monitors,json=dnsMonitors,proto3" json:"dns_monitors,omitempty"` + IcmpMonitors []*ICMPMonitor `protobuf:"bytes,5,rep,name=icmp_monitors,json=icmpMonitors,proto3" json:"icmp_monitors,omitempty"` Region string `protobuf:"bytes,4,opt,name=region,proto3" json:"region,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -118,6 +119,13 @@ func (x *MonitorsResponse) GetDnsMonitors() []*DNSMonitor { return nil } +func (x *MonitorsResponse) GetIcmpMonitors() []*ICMPMonitor { + if x != nil { + return x.IcmpMonitors + } + return nil +} + func (x *MonitorsResponse) GetRegion() string { if x != nil { return x.Region @@ -657,16 +665,201 @@ func (*IngestDNSResponse) Descriptor() ([]byte, []int) { return file_private_location_v1_private_location_proto_rawDescGZIP(), []int{8} } +type IngestICMPRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + MonitorId string `protobuf:"bytes,2,opt,name=monitorId,proto3" json:"monitorId,omitempty"` + Latency int64 `protobuf:"varint,3,opt,name=latency,proto3" json:"latency,omitempty"` + LatencyMin int64 `protobuf:"varint,4,opt,name=latencyMin,proto3" json:"latencyMin,omitempty"` + LatencyMax int64 `protobuf:"varint,5,opt,name=latencyMax,proto3" json:"latencyMax,omitempty"` + PacketsSent int64 `protobuf:"varint,6,opt,name=packetsSent,proto3" json:"packetsSent,omitempty"` + PacketsReceived int64 `protobuf:"varint,7,opt,name=packetsReceived,proto3" json:"packetsReceived,omitempty"` + Timestamp int64 `protobuf:"varint,8,opt,name=timestamp,proto3" json:"timestamp,omitempty"` + CronTimestamp int64 `protobuf:"varint,9,opt,name=cronTimestamp,proto3" json:"cronTimestamp,omitempty"` + Uri string `protobuf:"bytes,10,opt,name=uri,proto3" json:"uri,omitempty"` + Message string `protobuf:"bytes,11,opt,name=message,proto3" json:"message,omitempty"` + RequestStatus string `protobuf:"bytes,12,opt,name=requestStatus,proto3" json:"requestStatus,omitempty"` + Error int64 `protobuf:"varint,13,opt,name=error,proto3" json:"error,omitempty"` + Timing string `protobuf:"bytes,14,opt,name=timing,proto3" json:"timing,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *IngestICMPRequest) Reset() { + *x = IngestICMPRequest{} + mi := &file_private_location_v1_private_location_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *IngestICMPRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*IngestICMPRequest) ProtoMessage() {} + +func (x *IngestICMPRequest) ProtoReflect() protoreflect.Message { + mi := &file_private_location_v1_private_location_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use IngestICMPRequest.ProtoReflect.Descriptor instead. +func (*IngestICMPRequest) Descriptor() ([]byte, []int) { + return file_private_location_v1_private_location_proto_rawDescGZIP(), []int{9} +} + +func (x *IngestICMPRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *IngestICMPRequest) GetMonitorId() string { + if x != nil { + return x.MonitorId + } + return "" +} + +func (x *IngestICMPRequest) GetLatency() int64 { + if x != nil { + return x.Latency + } + return 0 +} + +func (x *IngestICMPRequest) GetLatencyMin() int64 { + if x != nil { + return x.LatencyMin + } + return 0 +} + +func (x *IngestICMPRequest) GetLatencyMax() int64 { + if x != nil { + return x.LatencyMax + } + return 0 +} + +func (x *IngestICMPRequest) GetPacketsSent() int64 { + if x != nil { + return x.PacketsSent + } + return 0 +} + +func (x *IngestICMPRequest) GetPacketsReceived() int64 { + if x != nil { + return x.PacketsReceived + } + return 0 +} + +func (x *IngestICMPRequest) GetTimestamp() int64 { + if x != nil { + return x.Timestamp + } + return 0 +} + +func (x *IngestICMPRequest) GetCronTimestamp() int64 { + if x != nil { + return x.CronTimestamp + } + return 0 +} + +func (x *IngestICMPRequest) GetUri() string { + if x != nil { + return x.Uri + } + return "" +} + +func (x *IngestICMPRequest) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *IngestICMPRequest) GetRequestStatus() string { + if x != nil { + return x.RequestStatus + } + return "" +} + +func (x *IngestICMPRequest) GetError() int64 { + if x != nil { + return x.Error + } + return 0 +} + +func (x *IngestICMPRequest) GetTiming() string { + if x != nil { + return x.Timing + } + return "" +} + +type IngestICMPResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *IngestICMPResponse) Reset() { + *x = IngestICMPResponse{} + mi := &file_private_location_v1_private_location_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *IngestICMPResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*IngestICMPResponse) ProtoMessage() {} + +func (x *IngestICMPResponse) ProtoReflect() protoreflect.Message { + mi := &file_private_location_v1_private_location_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use IngestICMPResponse.ProtoReflect.Descriptor instead. +func (*IngestICMPResponse) Descriptor() ([]byte, []int) { + return file_private_location_v1_private_location_proto_rawDescGZIP(), []int{10} +} + var File_private_location_v1_private_location_proto protoreflect.FileDescriptor const file_private_location_v1_private_location_proto_rawDesc = "" + "\n" + - "*private_location/v1/private_location.proto\x12\x13private_location.v1\x1a%private_location/v1/dns_monitor.proto\x1a&private_location/v1/http_monitor.proto\x1a%private_location/v1/tcp_monitor.proto\"\x11\n" + - "\x0fMonitorsRequest\"\xf9\x01\n" + + "*private_location/v1/private_location.proto\x12\x13private_location.v1\x1a%private_location/v1/dns_monitor.proto\x1a&private_location/v1/http_monitor.proto\x1a&private_location/v1/icmp_monitor.proto\x1a%private_location/v1/tcp_monitor.proto\"\x11\n" + + "\x0fMonitorsRequest\"\xc0\x02\n" + "\x10MonitorsResponse\x12E\n" + "\rhttp_monitors\x18\x01 \x03(\v2 .private_location.v1.HTTPMonitorR\fhttpMonitors\x12B\n" + "\ftcp_monitors\x18\x02 \x03(\v2\x1f.private_location.v1.TCPMonitorR\vtcpMonitors\x12B\n" + - "\fdns_monitors\x18\x03 \x03(\v2\x1f.private_location.v1.DNSMonitorR\vdnsMonitors\x12\x16\n" + + "\fdns_monitors\x18\x03 \x03(\v2\x1f.private_location.v1.DNSMonitorR\vdnsMonitors\x12E\n" + + "\ricmp_monitors\x18\x05 \x03(\v2 .private_location.v1.ICMPMonitorR\ficmpMonitors\x12\x16\n" + "\x06region\x18\x04 \x01(\tR\x06region\"\x9e\x02\n" + "\x10IngestTCPRequest\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x1c\n" + @@ -717,13 +910,36 @@ const file_private_location_v1_private_location_proto_rawDesc = "" + "\fRecordsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x122\n" + "\x05value\x18\x02 \x01(\v2\x1c.private_location.v1.RecordsR\x05value:\x028\x01\"\x13\n" + - "\x11IngestDNSResponse2\x90\x03\n" + + "\x11IngestDNSResponse\"\xab\x03\n" + + "\x11IngestICMPRequest\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x1c\n" + + "\tmonitorId\x18\x02 \x01(\tR\tmonitorId\x12\x18\n" + + "\alatency\x18\x03 \x01(\x03R\alatency\x12\x1e\n" + + "\n" + + "latencyMin\x18\x04 \x01(\x03R\n" + + "latencyMin\x12\x1e\n" + + "\n" + + "latencyMax\x18\x05 \x01(\x03R\n" + + "latencyMax\x12 \n" + + "\vpacketsSent\x18\x06 \x01(\x03R\vpacketsSent\x12(\n" + + "\x0fpacketsReceived\x18\a \x01(\x03R\x0fpacketsReceived\x12\x1c\n" + + "\ttimestamp\x18\b \x01(\x03R\ttimestamp\x12$\n" + + "\rcronTimestamp\x18\t \x01(\x03R\rcronTimestamp\x12\x10\n" + + "\x03uri\x18\n" + + " \x01(\tR\x03uri\x12\x18\n" + + "\amessage\x18\v \x01(\tR\amessage\x12$\n" + + "\rrequestStatus\x18\f \x01(\tR\rrequestStatus\x12\x14\n" + + "\x05error\x18\r \x01(\x03R\x05error\x12\x16\n" + + "\x06timing\x18\x0e \x01(\tR\x06timing\"\x14\n" + + "\x12IngestICMPResponse2\xf1\x03\n" + "\x16PrivateLocationService\x12Y\n" + "\bMonitors\x12$.private_location.v1.MonitorsRequest\x1a%.private_location.v1.MonitorsResponse\"\x00\x12\\\n" + "\tIngestTCP\x12%.private_location.v1.IngestTCPRequest\x1a&.private_location.v1.IngestTCPResponse\"\x00\x12_\n" + "\n" + "IngestHTTP\x12&.private_location.v1.IngestHTTPRequest\x1a'.private_location.v1.IngestHTTPResponse\"\x00\x12\\\n" + - "\tIngestDNS\x12%.private_location.v1.IngestDNSRequest\x1a&.private_location.v1.IngestDNSResponse\"\x00BJZHgithub.com/openstatushq/openstatus/packages/proto/private_location/v1;v1b\x06proto3" + "\tIngestDNS\x12%.private_location.v1.IngestDNSRequest\x1a&.private_location.v1.IngestDNSResponse\"\x00\x12_\n" + + "\n" + + "IngestICMP\x12&.private_location.v1.IngestICMPRequest\x1a'.private_location.v1.IngestICMPResponse\"\x00BJZHgithub.com/openstatushq/openstatus/packages/proto/private_location/v1;v1b\x06proto3" var ( file_private_location_v1_private_location_proto_rawDescOnce sync.Once @@ -737,7 +953,7 @@ func file_private_location_v1_private_location_proto_rawDescGZIP() []byte { return file_private_location_v1_private_location_proto_rawDescData } -var file_private_location_v1_private_location_proto_msgTypes = make([]protoimpl.MessageInfo, 10) +var file_private_location_v1_private_location_proto_msgTypes = make([]protoimpl.MessageInfo, 12) var file_private_location_v1_private_location_proto_goTypes = []any{ (*MonitorsRequest)(nil), // 0: private_location.v1.MonitorsRequest (*MonitorsResponse)(nil), // 1: private_location.v1.MonitorsResponse @@ -748,30 +964,36 @@ var file_private_location_v1_private_location_proto_goTypes = []any{ (*Records)(nil), // 6: private_location.v1.Records (*IngestDNSRequest)(nil), // 7: private_location.v1.IngestDNSRequest (*IngestDNSResponse)(nil), // 8: private_location.v1.IngestDNSResponse - nil, // 9: private_location.v1.IngestDNSRequest.RecordsEntry - (*HTTPMonitor)(nil), // 10: private_location.v1.HTTPMonitor - (*TCPMonitor)(nil), // 11: private_location.v1.TCPMonitor - (*DNSMonitor)(nil), // 12: private_location.v1.DNSMonitor + (*IngestICMPRequest)(nil), // 9: private_location.v1.IngestICMPRequest + (*IngestICMPResponse)(nil), // 10: private_location.v1.IngestICMPResponse + nil, // 11: private_location.v1.IngestDNSRequest.RecordsEntry + (*HTTPMonitor)(nil), // 12: private_location.v1.HTTPMonitor + (*TCPMonitor)(nil), // 13: private_location.v1.TCPMonitor + (*DNSMonitor)(nil), // 14: private_location.v1.DNSMonitor + (*ICMPMonitor)(nil), // 15: private_location.v1.ICMPMonitor } var file_private_location_v1_private_location_proto_depIdxs = []int32{ - 10, // 0: private_location.v1.MonitorsResponse.http_monitors:type_name -> private_location.v1.HTTPMonitor - 11, // 1: private_location.v1.MonitorsResponse.tcp_monitors:type_name -> private_location.v1.TCPMonitor - 12, // 2: private_location.v1.MonitorsResponse.dns_monitors:type_name -> private_location.v1.DNSMonitor - 9, // 3: private_location.v1.IngestDNSRequest.records:type_name -> private_location.v1.IngestDNSRequest.RecordsEntry - 6, // 4: private_location.v1.IngestDNSRequest.RecordsEntry.value:type_name -> private_location.v1.Records - 0, // 5: private_location.v1.PrivateLocationService.Monitors:input_type -> private_location.v1.MonitorsRequest - 2, // 6: private_location.v1.PrivateLocationService.IngestTCP:input_type -> private_location.v1.IngestTCPRequest - 4, // 7: private_location.v1.PrivateLocationService.IngestHTTP:input_type -> private_location.v1.IngestHTTPRequest - 7, // 8: private_location.v1.PrivateLocationService.IngestDNS:input_type -> private_location.v1.IngestDNSRequest - 1, // 9: private_location.v1.PrivateLocationService.Monitors:output_type -> private_location.v1.MonitorsResponse - 3, // 10: private_location.v1.PrivateLocationService.IngestTCP:output_type -> private_location.v1.IngestTCPResponse - 5, // 11: private_location.v1.PrivateLocationService.IngestHTTP:output_type -> private_location.v1.IngestHTTPResponse - 8, // 12: private_location.v1.PrivateLocationService.IngestDNS:output_type -> private_location.v1.IngestDNSResponse - 9, // [9:13] is the sub-list for method output_type - 5, // [5:9] is the sub-list for method input_type - 5, // [5:5] is the sub-list for extension type_name - 5, // [5:5] is the sub-list for extension extendee - 0, // [0:5] is the sub-list for field type_name + 12, // 0: private_location.v1.MonitorsResponse.http_monitors:type_name -> private_location.v1.HTTPMonitor + 13, // 1: private_location.v1.MonitorsResponse.tcp_monitors:type_name -> private_location.v1.TCPMonitor + 14, // 2: private_location.v1.MonitorsResponse.dns_monitors:type_name -> private_location.v1.DNSMonitor + 15, // 3: private_location.v1.MonitorsResponse.icmp_monitors:type_name -> private_location.v1.ICMPMonitor + 11, // 4: private_location.v1.IngestDNSRequest.records:type_name -> private_location.v1.IngestDNSRequest.RecordsEntry + 6, // 5: private_location.v1.IngestDNSRequest.RecordsEntry.value:type_name -> private_location.v1.Records + 0, // 6: private_location.v1.PrivateLocationService.Monitors:input_type -> private_location.v1.MonitorsRequest + 2, // 7: private_location.v1.PrivateLocationService.IngestTCP:input_type -> private_location.v1.IngestTCPRequest + 4, // 8: private_location.v1.PrivateLocationService.IngestHTTP:input_type -> private_location.v1.IngestHTTPRequest + 7, // 9: private_location.v1.PrivateLocationService.IngestDNS:input_type -> private_location.v1.IngestDNSRequest + 9, // 10: private_location.v1.PrivateLocationService.IngestICMP:input_type -> private_location.v1.IngestICMPRequest + 1, // 11: private_location.v1.PrivateLocationService.Monitors:output_type -> private_location.v1.MonitorsResponse + 3, // 12: private_location.v1.PrivateLocationService.IngestTCP:output_type -> private_location.v1.IngestTCPResponse + 5, // 13: private_location.v1.PrivateLocationService.IngestHTTP:output_type -> private_location.v1.IngestHTTPResponse + 8, // 14: private_location.v1.PrivateLocationService.IngestDNS:output_type -> private_location.v1.IngestDNSResponse + 10, // 15: private_location.v1.PrivateLocationService.IngestICMP:output_type -> private_location.v1.IngestICMPResponse + 11, // [11:16] is the sub-list for method output_type + 6, // [6:11] is the sub-list for method input_type + 6, // [6:6] is the sub-list for extension type_name + 6, // [6:6] is the sub-list for extension extendee + 0, // [0:6] is the sub-list for field type_name } func init() { file_private_location_v1_private_location_proto_init() } @@ -781,6 +1003,7 @@ func file_private_location_v1_private_location_proto_init() { } file_private_location_v1_dns_monitor_proto_init() file_private_location_v1_http_monitor_proto_init() + file_private_location_v1_icmp_monitor_proto_init() file_private_location_v1_tcp_monitor_proto_init() type x struct{} out := protoimpl.TypeBuilder{ @@ -788,7 +1011,7 @@ func file_private_location_v1_private_location_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_private_location_v1_private_location_proto_rawDesc), len(file_private_location_v1_private_location_proto_rawDesc)), NumEnums: 0, - NumMessages: 10, + NumMessages: 12, NumExtensions: 0, NumServices: 1, }, diff --git a/apps/server/src/libs/checker/utils.test.ts b/apps/server/src/libs/checker/utils.test.ts new file mode 100644 index 000000000..aa41d39e1 --- /dev/null +++ b/apps/server/src/libs/checker/utils.test.ts @@ -0,0 +1,119 @@ +import type { z } from "@hono/zod-openapi"; +import type { selectMonitorSchema } from "@openstatus/db/src/schema"; +import { expect, test } from "@openstatus/test-utils"; + +import { OpenStatusApiError } from "@/libs/errors"; + +import { getCheckerPayload, getCheckerUrl } from "./utils"; + +type Monitor = z.infer; + +function buildMonitor(overrides: Partial = {}): Monitor { + return { + id: 1, + workspaceId: 1, + jobType: "http", + active: true, + public: false, + name: "test", + description: "", + url: "https://example.openstatus.dev", + method: "GET", + body: "", + headers: [], + assertions: null, + periodicity: "10m", + regions: ["ams"], + timeout: 30_000, + degradedAfter: null, + retry: 3, + followRedirects: true, + otelEndpoint: null, + otelHeaders: null, + status: "active", + createdAt: null, + updatedAt: null, + deletedAt: null, + ...overrides, + } as Monitor; +} + +test("getCheckerUrl routes each job type to its own checker endpoint", () => { + for (const jobType of ["http", "tcp", "dns", "icmp"] as const) { + const url = getCheckerUrl(buildMonitor({ jobType })); + expect(url).toContain(`/checker/${jobType}?`); + expect(url).toContain("monitor_id=1"); + } +}); + +test("getCheckerUrl rejects an unsupported job type", () => { + expect(() => + getCheckerUrl(buildMonitor({ jobType: "unknown" as Monitor["jobType"] })), + ).toThrow(OpenStatusApiError); +}); + +test("getCheckerPayload builds an ICMP payload without assertions", () => { + const payload = getCheckerPayload( + buildMonitor({ + jobType: "icmp", + url: "1.1.1.1", + // Assertions are meaningless for ICMP and must not leak into the payload + // even when the row still carries some from an earlier job type. + assertions: + '[{"version":"v1","type":"status","compare":"eq","target":200}]', + }), + "active", + ); + + expect(payload).toMatchObject({ + uri: "1.1.1.1", + monitorId: "1", + workspaceId: "1", + status: "active", + trigger: "api", + timeout: 30_000, + }); + expect("assertions" in payload).toBe(false); + expect("url" in payload).toBe(false); +}); + +test("getCheckerPayload builds a DNS payload with assertions", () => { + const payload = getCheckerPayload( + buildMonitor({ + jobType: "dns", + url: "openstatus.dev", + assertions: + '[{"version":"v1","type":"dnsRecord","record":"A","compare":"eq","target":"1.2.3.4"}]', + }), + "active", + ); + + expect(payload).toMatchObject({ uri: "openstatus.dev", trigger: "api" }); + expect("assertions" in payload).toBe(true); +}); + +test("getCheckerPayload forwards the OTel config when configured", () => { + const payload = getCheckerPayload( + buildMonitor({ + jobType: "icmp", + url: "1.1.1.1", + otelEndpoint: "https://otel.example.com:4318", + otelHeaders: [{ key: "Authorization", value: "Basic dGVzdA==" }], + }), + "active", + ); + + expect(payload.otelConfig).toEqual({ + endpoint: "https://otel.example.com:4318", + headers: { Authorization: "Basic dGVzdA==" }, + }); +}); + +test("getCheckerPayload rejects an unsupported job type", () => { + expect(() => + getCheckerPayload( + buildMonitor({ jobType: "unknown" as Monitor["jobType"] }), + "active", + ), + ).toThrow(OpenStatusApiError); +}); diff --git a/apps/server/src/libs/checker/utils.ts b/apps/server/src/libs/checker/utils.ts index 01748d183..5e411a20f 100644 --- a/apps/server/src/libs/checker/utils.ts +++ b/apps/server/src/libs/checker/utils.ts @@ -1,7 +1,9 @@ import type { z } from "@hono/zod-openapi"; import type { selectMonitorSchema } from "@openstatus/db/src/schema"; import { + type DNSPayloadSchema, type httpPayloadSchema, + type icmpPayloadSchema, type tpcPayloadSchema, transformHeaders, } from "@openstatus/utils"; @@ -11,7 +13,11 @@ import { OpenStatusApiError } from "@/libs/errors"; export function getCheckerPayload( monitor: z.infer, status: z.infer["status"], -): z.infer | z.infer { +): + | z.infer + | z.infer + | z.infer + | z.infer { const timestamp = new Date().getTime(); switch (monitor.jobType) { case "http": @@ -57,11 +63,48 @@ export function getCheckerPayload( retry: monitor.retry ?? 0, followRedirects: monitor.followRedirects ?? false, }; + case "dns": + return { + workspaceId: String(monitor.workspaceId), + monitorId: String(monitor.id), + uri: monitor.url, + status: status, + assertions: monitor.assertions ? JSON.parse(monitor.assertions) : null, + cronTimestamp: timestamp, + degradedAfter: monitor.degradedAfter, + timeout: monitor.timeout, + trigger: "api", + otelConfig: monitor.otelEndpoint + ? { + endpoint: monitor.otelEndpoint, + headers: transformHeaders(monitor.otelHeaders), + } + : undefined, + retry: monitor.retry ?? 0, + }; + case "icmp": + // No assertions: an ICMP check only reports reachability and latency. + return { + workspaceId: String(monitor.workspaceId), + monitorId: String(monitor.id), + uri: monitor.url, + status: status, + cronTimestamp: timestamp, + degradedAfter: monitor.degradedAfter, + timeout: monitor.timeout, + trigger: "api", + otelConfig: monitor.otelEndpoint + ? { + endpoint: monitor.otelEndpoint, + headers: transformHeaders(monitor.otelHeaders), + } + : undefined, + retry: monitor.retry ?? 0, + }; default: throw new OpenStatusApiError({ code: "BAD_REQUEST", - message: - "Invalid jobType, currently only 'http' and 'tcp' are supported", + message: `Invalid jobType '${monitor.jobType}'`, }); } } @@ -83,14 +126,14 @@ export function getCheckerUrl( ): string { switch (monitor.jobType) { case "http": - return `https://openstatus-checker.fly.dev/checker/http?monitor_id=${monitor.id}&trigger=${opts.trigger}&data=${opts.data}`; case "tcp": - return `https://openstatus-checker.fly.dev/checker/tcp?monitor_id=${monitor.id}&trigger=${opts.trigger}&data=${opts.data}`; + case "dns": + case "icmp": + return `https://openstatus-checker.fly.dev/checker/${monitor.jobType}?monitor_id=${monitor.id}&trigger=${opts.trigger}&data=${opts.data}`; default: throw new OpenStatusApiError({ code: "BAD_REQUEST", - message: - "Invalid jobType, currently only 'http' and 'tcp' are supported", + message: `Invalid jobType '${monitor.jobType}'`, }); } } diff --git a/apps/server/src/routes/rpc/handlers/monitor/__tests__/monitor.test.ts b/apps/server/src/routes/rpc/handlers/monitor/__tests__/monitor.test.ts index 91b33ac27..833685d72 100644 --- a/apps/server/src/routes/rpc/handlers/monitor/__tests__/monitor.test.ts +++ b/apps/server/src/routes/rpc/handlers/monitor/__tests__/monitor.test.ts @@ -7,6 +7,7 @@ import { privateLocationToMonitors, workspace, } from "@openstatus/db/src/schema"; +import { monitorRun } from "@openstatus/db/src/schema/monitor_run/monitor_run"; import { monitorStatusTable } from "@openstatus/db/src/schema/monitor_status/monitor_status"; import { createTestWorkspace } from "@openstatus/db/src/test/factories"; import { @@ -48,6 +49,7 @@ let FREE_PLAN_KEY: string; let testHttpMonitorId: number; let testTcpMonitorId: number; let testDnsMonitorId: number; +let testIcmpMonitorId: number; let testMonitorToDeleteId: number; let testMonitorWithStatusId: number; @@ -119,6 +121,7 @@ beforeAll(async () => { await db.delete(monitor).where(eq(monitor.name, `${TEST_PREFIX}-http`)); await db.delete(monitor).where(eq(monitor.name, `${TEST_PREFIX}-tcp`)); await db.delete(monitor).where(eq(monitor.name, `${TEST_PREFIX}-dns`)); + await db.delete(monitor).where(eq(monitor.name, `${TEST_PREFIX}-icmp`)); await db.delete(monitor).where(eq(monitor.name, `${TEST_PREFIX}-to-delete`)); await db .delete(monitor) @@ -190,6 +193,23 @@ beforeAll(async () => { .get(); testDnsMonitorId = dnsMon.id; + // Create test ICMP monitor + const icmpMon = await db + .insert(monitor) + .values({ + workspaceId: 1, + name: `${TEST_PREFIX}-icmp`, + url: "1.1.1.1", + periodicity: "10m", + active: true, + regions: "ams", + jobType: "icmp", + timeout: 5000, + }) + .returning() + .get(); + testIcmpMonitorId = icmpMon.id; + // Create monitor to be deleted const deleteMon = await db .insert(monitor) @@ -241,6 +261,7 @@ afterAll(async () => { await db.delete(monitor).where(eq(monitor.name, `${TEST_PREFIX}-http`)); await db.delete(monitor).where(eq(monitor.name, `${TEST_PREFIX}-tcp`)); await db.delete(monitor).where(eq(monitor.name, `${TEST_PREFIX}-dns`)); + await db.delete(monitor).where(eq(monitor.name, `${TEST_PREFIX}-icmp`)); await db.delete(monitor).where(eq(monitor.name, `${TEST_PREFIX}-to-delete`)); await db .delete(monitor) @@ -343,6 +364,28 @@ describe("MonitorService.ListMonitors", () => { expect(dnsMon.recordAssertions).toBeDefined(); }); + test("returns ICMP monitors with correct structure", async () => { + const res = await connectRequest( + "ListMonitors", + { limit: 100 }, + { + "x-openstatus-key": "1", + }, + ); + + expect(res.status).toBe(200); + + const data = await res.json(); + const icmpMonitors = data.icmpMonitors || []; + const icmpMon = icmpMonitors.find( + (m: { id: string }) => m.id === String(testIcmpMonitorId), + ); + + expect(icmpMon).toBeDefined(); + expect(icmpMon.uri).toBe("1.1.1.1"); + expect(icmpMon.periodicity).toBe("PERIODICITY_10M"); + }); + test("returns 401 when no auth key provided", async () => { const res = await connectRequest("ListMonitors", {}); @@ -363,7 +406,8 @@ describe("MonitorService.ListMonitors", () => { const totalMonitors = (data.httpMonitors?.length || 0) + (data.tcpMonitors?.length || 0) + - (data.dnsMonitors?.length || 0); + (data.dnsMonitors?.length || 0) + + (data.icmpMonitors?.length || 0); // Should return at most 2 monitors total expect(totalMonitors).toBeLessThanOrEqual(2); @@ -842,6 +886,53 @@ describe("MonitorService.CreateDNSMonitor", () => { }); }); +describe("MonitorService.CreateICMPMonitor", () => { + test("successfully creates ICMP monitor", async () => { + const res = await connectRequest( + "CreateICMPMonitor", + { + monitor: { + name: "test-create-icmp", + uri: "8.8.8.8", + periodicity: "PERIODICITY_5M", + timeout: "5000", + }, + }, + { "x-openstatus-key": "1" }, + ); + + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.monitor).toBeDefined(); + expect(data.monitor.uri).toBe("8.8.8.8"); + expect(data.monitor.periodicity).toBe("PERIODICITY_5M"); + + // The row must carry the icmp job type so the cron dispatches a ping. + const row = await db + .select() + .from(monitor) + .where(eq(monitor.id, Number(data.monitor.id))) + .get(); + expect(row?.jobType).toBe("icmp"); + + // Clean up + if (data.monitor.id) { + await db.delete(monitor).where(eq(monitor.id, Number(data.monitor.id))); + } + }); + + test("returns error when monitor is missing", async () => { + const res = await connectRequest( + "CreateICMPMonitor", + {}, + { "x-openstatus-key": "1" }, + ); + + expect(res.status).toBe(400); + }); +}); + describe("MonitorService.UpdateHTTPMonitor", () => { test("successfully updates HTTP monitor with partial data", async () => { const res = await connectRequest( @@ -1343,6 +1434,212 @@ describe("MonitorService.UpdateDNSMonitor", () => { }); }); +describe("MonitorService.UpdateICMPMonitor", () => { + test("successfully updates ICMP monitor with partial data", async () => { + const res = await connectRequest( + "UpdateICMPMonitor", + { + id: String(testIcmpMonitorId), + monitor: { + name: "updated-icmp-name", + }, + }, + { "x-openstatus-key": "1" }, + ); + + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.monitor).toBeDefined(); + expect(data.monitor.name).toBe("updated-icmp-name"); + // Original URI should be preserved + expect(data.monitor.uri).toBe("1.1.1.1"); + + // Restore original name + await connectRequest( + "UpdateICMPMonitor", + { + id: String(testIcmpMonitorId), + monitor: { + name: `${TEST_PREFIX}-icmp`, + }, + }, + { "x-openstatus-key": "1" }, + ); + }); + + test("successfully updates ICMP monitor URI", async () => { + const res = await connectRequest( + "UpdateICMPMonitor", + { + id: String(testIcmpMonitorId), + monitor: { + uri: "9.9.9.9", + }, + }, + { "x-openstatus-key": "1" }, + ); + + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.monitor.uri).toBe("9.9.9.9"); + + // Restore original URI + await connectRequest( + "UpdateICMPMonitor", + { + id: String(testIcmpMonitorId), + monitor: { + uri: "1.1.1.1", + }, + }, + { "x-openstatus-key": "1" }, + ); + }); + + test("leaves active untouched when the patch omits it", async () => { + const res = await connectRequest( + "UpdateICMPMonitor", + { + id: String(testIcmpMonitorId), + monitor: { name: `${TEST_PREFIX}-icmp` }, + }, + { "x-openstatus-key": "1" }, + ); + + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.monitor.active).toBe(true); + }); + + test("returns current monitor when no monitor data provided", async () => { + const res = await connectRequest( + "UpdateICMPMonitor", + { + id: String(testIcmpMonitorId), + }, + { "x-openstatus-key": "1" }, + ); + + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.monitor).toBeDefined(); + expect(data.monitor.id).toBe(String(testIcmpMonitorId)); + }); + + test("returns 404 for non-existent monitor", async () => { + const res = await connectRequest( + "UpdateICMPMonitor", + { + id: "99999", + monitor: { name: "test" }, + }, + { "x-openstatus-key": "1" }, + ); + + expect(res.status).toBe(404); + }); + + test("returns error when trying to update HTTP monitor as ICMP", async () => { + const res = await connectRequest( + "UpdateICMPMonitor", + { + id: String(testHttpMonitorId), + monitor: { name: "test" }, + }, + { "x-openstatus-key": "1" }, + ); + + expect(res.status).toBe(400); + const data = await res.json(); + expect(data.message).toContain("type mismatch"); + }); + + test("returns 401 when no auth key provided", async () => { + const res = await connectRequest("UpdateICMPMonitor", { + id: String(testIcmpMonitorId), + monitor: { name: "test" }, + }); + + expect(res.status).toBe(401); + }); + + // This method is in SKIP_VALIDATION_METHODS, so protovalidate never sees the + // patch — the bounds it would have applied are enforced in the handler, and + // nothing downstream re-checks them. + test("enforces the proto bounds the validation interceptor skips", async () => { + for (const [field, monitor] of [ + ["retry above max", { retry: "11" }], + ["negative retry", { retry: "-1" }], + ["timeout above max", { timeout: "120001" }], + ["degradedAt above max", { degradedAt: "120001" }], + ["over-long description", { description: "d".repeat(1025) }], + ["over-long name", { name: "n".repeat(257) }], + ["over-long uri", { uri: "u".repeat(2049) }], + ] as const) { + const res = await connectRequest( + "UpdateICMPMonitor", + { id: String(testIcmpMonitorId), monitor }, + { "x-openstatus-key": "1" }, + ); + + expect(res.status).toBe(400); + } + }); + + test("leaves the stored monitor untouched when a patch is rejected", async () => { + const before = await db + .select() + .from(monitor) + .where(eq(monitor.id, testIcmpMonitorId)) + .get(); + + const res = await connectRequest( + "UpdateICMPMonitor", + { + id: String(testIcmpMonitorId), + // A valid name alongside an out-of-range retry: the whole patch must be + // refused, not partially applied. + monitor: { name: "should-not-be-written", retry: "99" }, + }, + { "x-openstatus-key": "1" }, + ); + + expect(res.status).toBe(400); + + const after = await db + .select() + .from(monitor) + .where(eq(monitor.id, testIcmpMonitorId)) + .get(); + expect(after?.name).toBe(before?.name); + expect(after?.retry).toBe(before?.retry); + }); + + test("still accepts values at the documented limits", async () => { + const res = await connectRequest( + "UpdateICMPMonitor", + { + id: String(testIcmpMonitorId), + monitor: { retry: "10", timeout: "120000" }, + }, + { "x-openstatus-key": "1" }, + ); + + expect(res.status).toBe(200); + + // Restore + await connectRequest( + "UpdateICMPMonitor", + { id: String(testIcmpMonitorId), monitor: { timeout: "5000" } }, + { "x-openstatus-key": "1" }, + ); + }); +}); + describe("MonitorService - private and internal URLs", () => { // Valid URIs, so protovalidate passes them through to the service guard. const BLOCKED = [ @@ -1450,6 +1747,47 @@ describe("MonitorService.TriggerMonitor", () => { expect(res.status).toBe(401); }); + + test("dispatches an ICMP monitor to the icmp checker endpoint", async () => { + // A throwaway monitor: triggering records a monitorRun that references it, + // and the suite's afterAll deletes monitors without clearing runs. + const icmpMon = await createMonitor(1, { + name: `${TEST_PREFIX}-icmp-trigger`, + url: "1.1.1.1", + jobType: "icmp", + periodicity: "10m", + active: true, + regions: "ams", + }); + + // Scoped to this test so the surrounding suite keeps the real fetch. + const realFetch = globalThis.fetch; + const calls: { url: string; body: string }[] = []; + globalThis.fetch = ((url: string | URL | Request, init?: RequestInit) => { + calls.push({ url: String(url), body: String(init?.body ?? "") }); + return Promise.resolve(new Response(null, { status: 200 })); + }) as typeof fetch; + + try { + const res = await connectRequest( + "TriggerMonitor", + { id: String(icmpMon.id) }, + { "x-openstatus-key": "1" }, + ); + + expect(res.status).toBe(200); + expect(calls.length).toBeGreaterThan(0); + expect(calls[0].url).toContain("/checker/icmp?"); + expect(JSON.parse(calls[0].body)).toMatchObject({ + uri: "1.1.1.1", + monitorId: String(icmpMon.id), + }); + } finally { + globalThis.fetch = realFetch; + await db.delete(monitorRun).where(eq(monitorRun.monitorId, icmpMon.id)); + await db.delete(monitor).where(eq(monitor.id, icmpMon.id)); + } + }); }); describe("MonitorService - Authentication", () => { @@ -2522,17 +2860,21 @@ describe("MonitorService.GetMonitor", () => { }); describe("MonitorService - Private Locations", () => { - async function seedMonitorWithPrivateLocation(suffix: string) { + async function seedMonitorWithPrivateLocation( + suffix: string, + jobType: "http" | "icmp" = "http", + ) { const mon = await db .insert(monitor) .values({ workspaceId: 1, name: `${TEST_PREFIX}-pl-${suffix}`, - url: `https://pl-${suffix}.example.com`, + url: + jobType === "icmp" ? "1.1.1.1" : `https://pl-${suffix}.example.com`, periodicity: "1m", active: true, regions: "ams", - jobType: "http", + jobType, }) .returning() .get(); @@ -2621,6 +2963,50 @@ describe("MonitorService - Private Locations", () => { } }); + test("GetMonitor returns the attached private location id for ICMP", async () => { + const { mon, pl } = await seedMonitorWithPrivateLocation( + "get-icmp", + "icmp", + ); + try { + const res = await connectRequest( + "GetMonitor", + { id: String(mon.id) }, + { "x-openstatus-key": "1" }, + ); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.monitor.icmp.privateLocationIds).toEqual([String(pl.id)]); + } finally { + await cleanupMonitorWithPrivateLocation(mon.id, pl.id); + } + }); + + test("ListMonitors returns private_location_ids for ICMP monitors", async () => { + const { mon, pl } = await seedMonitorWithPrivateLocation( + "list-icmp", + "icmp", + ); + try { + const res = await connectRequest( + "ListMonitors", + { limit: 100 }, + { "x-openstatus-key": "1" }, + ); + expect(res.status).toBe(200); + const data = await res.json(); + const icmpMonitors = (data.icmpMonitors ?? []) as Array<{ + id: string; + privateLocationIds?: string[]; + }>; + + const attached = icmpMonitors.find((m) => m.id === String(mon.id)); + expect(attached?.privateLocationIds).toEqual([String(pl.id)]); + } finally { + await cleanupMonitorWithPrivateLocation(mon.id, pl.id); + } + }); + test("UpdateHTTPMonitor keeps the attached private location id", async () => { const { mon, pl } = await seedMonitorWithPrivateLocation("update"); try { diff --git a/apps/server/src/routes/rpc/handlers/monitor/converters/index.ts b/apps/server/src/routes/rpc/handlers/monitor/converters/index.ts index dffcab836..be77f8d61 100644 --- a/apps/server/src/routes/rpc/handlers/monitor/converters/index.ts +++ b/apps/server/src/routes/rpc/handlers/monitor/converters/index.ts @@ -47,6 +47,7 @@ export { dbMonitorToHttpProto, dbMonitorToTcpProto, dbMonitorToDnsProto, + dbMonitorToIcmpProto, } from "./monitors"; // Regions diff --git a/apps/server/src/routes/rpc/handlers/monitor/converters/monitors.ts b/apps/server/src/routes/rpc/handlers/monitor/converters/monitors.ts index a7cc9a330..b8aba3d09 100644 --- a/apps/server/src/routes/rpc/handlers/monitor/converters/monitors.ts +++ b/apps/server/src/routes/rpc/handlers/monitor/converters/monitors.ts @@ -2,6 +2,7 @@ import type { Monitor } from "@openstatus/db/src/schema/monitors/validation"; import type { DNSMonitor, HTTPMonitor, + ICMPMonitor, TCPMonitor, } from "@openstatus/proto/monitor/v1"; @@ -76,6 +77,32 @@ export function dbMonitorToTcpProto( }; } +/** + * Transform database ICMP monitor to proto ICMPMonitor. + */ +export function dbMonitorToIcmpProto( + dbMon: Monitor, + privateLocationIds: string[] = [], +): ICMPMonitor { + return { + $typeName: "openstatus.monitor.v1.ICMPMonitor", + id: String(dbMon.id), + name: dbMon.name, + uri: dbMon.url, + periodicity: stringToPeriodicity(dbMon.periodicity), + timeout: BigInt(dbMon.timeout), + degradedAt: dbMon.degradedAfter ? BigInt(dbMon.degradedAfter) : undefined, + retry: BigInt(dbMon.retry ?? MONITOR_DEFAULTS.retry), + description: dbMon.description, + active: dbMon.active ?? MONITOR_DEFAULTS.active, + public: dbMon.public ?? MONITOR_DEFAULTS.public, + regions: stringsToRegions(dbMon.regions), + openTelemetry: parseOpenTelemetry(dbMon.otelEndpoint, dbMon.otelHeaders), + status: stringToMonitorStatus(dbMon.status), + privateLocationIds, + }; +} + /** * Transform database DNS monitor to proto DNSMonitor. */ diff --git a/apps/server/src/routes/rpc/handlers/monitor/index.ts b/apps/server/src/routes/rpc/handlers/monitor/index.ts index 351ab90ef..5967421b9 100644 --- a/apps/server/src/routes/rpc/handlers/monitor/index.ts +++ b/apps/server/src/routes/rpc/handlers/monitor/index.ts @@ -8,6 +8,7 @@ import type { GetMonitorSummaryResponse, HTTPMonitor, HTTPResponseLogPagination, + ICMPMonitor, ListMonitorHTTPResponseLogsResponse, MonitorConfig, MonitorService, @@ -47,6 +48,7 @@ import { MONITOR_DEFAULTS, dbMonitorToDnsProto, dbMonitorToHttpProto, + dbMonitorToIcmpProto, dbMonitorToTcpProto, protoDnsAssertionsToService, protoHeadersToService, @@ -79,6 +81,7 @@ import { getCommonUpdateInput, toValidMethod, validateCommonMonitorFields, + validateMonitorPatchBounds, } from "./validators"; /** @@ -107,7 +110,7 @@ type DBMonitor = NonNullable>>; async function validateAndGetMonitor( id: string | undefined, workspaceId: number, - expectedJobType: "http" | "tcp" | "dns", + expectedJobType: "http" | "tcp" | "dns" | "icmp", ): Promise { if (!id || id.trim() === "") { throw monitorIdRequiredError(); @@ -264,6 +267,42 @@ export const monitorServiceImpl: ServiceImpl = { } }, + async createICMPMonitor(req, ctx) { + const rpcCtx = getRpcContext(ctx); + const workspaceId = rpcCtx.workspace.id; + const limits = rpcCtx.workspace.limits; + + if (!req.monitor) { + throw monitorRequiredError(); + } + + const mon = req.monitor; + + // Validate required fields (proto validation handles name, uri, periodicity) + validateCommonMonitorFields(mon); + + // Check workspace limits + await checkMonitorLimits(workspaceId, limits, mon.periodicity, mon.regions); + + try { + const created = await createMonitor({ + ctx: toServiceCtx(rpcCtx), + input: { + ...getCommonCreateInput(mon), + jobType: "icmp", + url: mon.uri, + method: "GET", + headers: [], + assertions: [], + }, + }); + + return { monitor: dbMonitorToIcmpProto(created) }; + } catch (err) { + toConnectError(err); + } + }, + async updateHTTPMonitor(req, ctx) { const rpcCtx = getRpcContext(ctx); const workspaceId = rpcCtx.workspace.id; @@ -445,6 +484,58 @@ export const monitorServiceImpl: ServiceImpl = { ); }, + async updateICMPMonitor(req, ctx) { + const rpcCtx = getRpcContext(ctx); + const workspaceId = rpcCtx.workspace.id; + const limits = rpcCtx.workspace.limits; + + const dbMon = await validateAndGetMonitor(req.id, workspaceId, "icmp"); + + const plMap = await getPrivateLocationIdsByMonitor({ + ctx: toServiceCtx(rpcCtx), + input: { monitorIds: [dbMon.id] }, + }); + const privateLocationIds = plMap.get(dbMon.id) ?? []; + + // If no monitor data provided, return current monitor + if (!req.monitor) { + const parsed = selectMonitorSchema.safeParse(dbMon); + if (!parsed.success) { + throw monitorParseFailedError(req.id); + } + return { + monitor: dbMonitorToIcmpProto(parsed.data, privateLocationIds), + }; + } + + const mon = req.monitor; + + // Validate regions if provided + validateCommonMonitorFields(mon); + // This method skips the protovalidate interceptor (see SKIP_VALIDATION_METHODS), + // so the message's own bounds have to be applied here. + validateMonitorPatchBounds(mon); + + // Check workspace limits if periodicity or regions are changing + checkMonitorConfigLimits( + limits, + mon.periodicity || undefined, + mon.regions && mon.regions.length > 0 ? mon.regions : undefined, + ); + + // Build update values - only include fields that are provided + const updateValues = getCommonUpdateInput(mon); + + // Handle ICMP-specific fields + if (mon.uri !== undefined && mon.uri !== "") { + updateValues.url = mon.uri; + } + + return applyUpdate(rpcCtx, dbMon.id, updateValues, (data) => + dbMonitorToIcmpProto(data, privateLocationIds), + ); + }, + async triggerMonitor(req, ctx) { const rpcCtx = getRpcContext(ctx); const limits = rpcCtx.workspace.limits; @@ -564,6 +655,7 @@ export const monitorServiceImpl: ServiceImpl = { const httpMonitors: HTTPMonitor[] = []; const tcpMonitors: TCPMonitor[] = []; const dnsMonitors: DNSMonitor[] = []; + const icmpMonitors: ICMPMonitor[] = []; for (const data of parsedMonitors) { const privateLocationIds = plMap.get(data.id) ?? []; @@ -577,6 +669,9 @@ export const monitorServiceImpl: ServiceImpl = { case "dns": dnsMonitors.push(dbMonitorToDnsProto(data, privateLocationIds)); break; + case "icmp": + icmpMonitors.push(dbMonitorToIcmpProto(data, privateLocationIds)); + break; } } @@ -584,6 +679,7 @@ export const monitorServiceImpl: ServiceImpl = { httpMonitors, tcpMonitors, dnsMonitors, + icmpMonitors, totalSize: totalCount, }; }, @@ -664,10 +760,19 @@ export const monitorServiceImpl: ServiceImpl = { }, }; break; + case "icmp": + monitorConfig = { + $typeName: "openstatus.monitor.v1.MonitorConfig", + config: { + case: "icmp", + value: dbMonitorToIcmpProto(monitorData, privateLocationIds), + }, + }; + break; default: throw monitorTypeMismatchError( req.id, - "http, tcp, or dns", + "http, tcp, dns, or icmp", monitorData.jobType, ); } diff --git a/apps/server/src/routes/rpc/handlers/monitor/validators.test.ts b/apps/server/src/routes/rpc/handlers/monitor/validators.test.ts index 90b564930..216e77205 100644 --- a/apps/server/src/routes/rpc/handlers/monitor/validators.test.ts +++ b/apps/server/src/routes/rpc/handlers/monitor/validators.test.ts @@ -1,3 +1,4 @@ +import { ConnectError } from "@connectrpc/connect"; import { Periodicity, Region } from "@openstatus/proto/monitor/v1"; import { expect } from "@std/expect"; import { describe, test } from "@std/testing/bdd"; @@ -9,6 +10,7 @@ import { toValidMethod, toValidPeriodicity, validateCommonMonitorFields, + validateMonitorPatchBounds, } from "./validators"; describe("getCommonCreateInput", () => { @@ -167,3 +169,82 @@ describe("validateCommonMonitorFields", () => { ).toEqual(["ams"]); }); }); + +describe("validateMonitorPatchBounds", () => { + test("accepts an empty patch", () => { + validateMonitorPatchBounds({}); + }); + + test("accepts values at the documented limits", () => { + validateMonitorPatchBounds({ + name: "a".repeat(256), + uri: "b".repeat(2048), + description: "c".repeat(1024), + timeout: BigInt(120_000), + degradedAt: BigInt(120_000), + retry: BigInt(10), + }); + }); + + test("rejects a retry above the proto maximum", () => { + expect(() => validateMonitorPatchBounds({ retry: BigInt(11) })).toThrow( + ConnectError, + ); + }); + + test("rejects a negative retry", () => { + // Proto int64 accepts a negative; only the bound rejects it. Unchecked it + // reaches the checker, where `uint64(retry)` becomes a huge retry count. + expect(() => validateMonitorPatchBounds({ retry: BigInt(-1) })).toThrow( + ConnectError, + ); + }); + + test("rejects a timeout above the proto maximum", () => { + expect(() => + validateMonitorPatchBounds({ timeout: BigInt(120_001) }), + ).toThrow(ConnectError); + }); + + test("rejects a degradedAt outside its range", () => { + expect(() => + validateMonitorPatchBounds({ degradedAt: BigInt(120_001) }), + ).toThrow(ConnectError); + expect(() => + validateMonitorPatchBounds({ degradedAt: BigInt(-1) }), + ).toThrow(ConnectError); + }); + + test("rejects an over-long description, name and uri", () => { + expect(() => + validateMonitorPatchBounds({ description: "c".repeat(1025) }), + ).toThrow(ConnectError); + expect(() => validateMonitorPatchBounds({ name: "a".repeat(257) })).toThrow( + ConnectError, + ); + expect(() => validateMonitorPatchBounds({ uri: "b".repeat(2049) })).toThrow( + ConnectError, + ); + }); + + test("checks HTTP's `url` as well as the other types' `uri`", () => { + expect(() => validateMonitorPatchBounds({ url: "b".repeat(2049) })).toThrow( + ConnectError, + ); + }); + + test("skips fields the patch does not supply, matching getCommonUpdateInput", () => { + // Zero timeout/retry mean "not supplied", so they must not be range-checked + // — otherwise a name-only patch would be rejected on an unrelated field. + validateMonitorPatchBounds({ timeout: BigInt(0), retry: BigInt(0) }); + validateMonitorPatchBounds({ name: "" }); + validateMonitorPatchBounds({ uri: "" }); + }); + + test("rejects more regions than the proto allows", () => { + const tooMany = Array.from({ length: 29 }, () => 1 as Region); + expect(() => validateMonitorPatchBounds({ regions: tooMany })).toThrow( + ConnectError, + ); + }); +}); diff --git a/apps/server/src/routes/rpc/handlers/monitor/validators.ts b/apps/server/src/routes/rpc/handlers/monitor/validators.ts index ce876ab9b..23aeb2c8d 100644 --- a/apps/server/src/routes/rpc/handlers/monitor/validators.ts +++ b/apps/server/src/routes/rpc/handlers/monitor/validators.ts @@ -58,6 +58,109 @@ export function validateCommonMonitorFields(mon: { regions?: Region[] }): void { } } +/** + * The bounds protovalidate enforces on a complete monitor message. Update RPCs + * skip the interceptor — a partial patch cannot satisfy `min_len` on the fields + * it omits — so nothing else checks these on an update: `updateMonitorConfig` + * writes whatever it is handed straight to the column. + * + * Keep in sync with the `buf.validate` constraints in `*_monitor.proto`. + */ +const MONITOR_BOUNDS = { + nameMaxLen: 256, + uriMaxLen: 2048, + descriptionMaxLen: 1024, + timeoutMaxMs: 120_000, + degradedAtMaxMs: 120_000, + retryMax: 10, + regionsMaxItems: 28, +} as const; + +function invalidArgument(message: string): never { + throw new ConnectError(message, Code.InvalidArgument); +} + +/** + * Apply those bounds to whatever an update actually supplied. The "supplied" + * tests mirror `getCommonUpdateInput` exactly: a field this skips is a field + * that never reaches the database. + */ +export function validateMonitorPatchBounds(mon: { + name?: string; + uri?: string; + url?: string; + timeout?: bigint; + degradedAt?: bigint; + retry?: bigint; + description?: string; + regions?: Region[]; +}): void { + if (mon.name !== undefined && mon.name !== "") { + if (mon.name.length > MONITOR_BOUNDS.nameMaxLen) { + invalidArgument( + `monitor.name: must be at most ${MONITOR_BOUNDS.nameMaxLen} characters [string.max_len]`, + ); + } + } + + // HTTP calls it `url`, the other three call it `uri`. + const target = mon.uri ?? mon.url; + if (target !== undefined && target !== "") { + if (target.length > MONITOR_BOUNDS.uriMaxLen) { + invalidArgument( + `monitor.uri: must be at most ${MONITOR_BOUNDS.uriMaxLen} characters [string.max_len]`, + ); + } + } + + if (mon.description !== undefined) { + if (mon.description.length > MONITOR_BOUNDS.descriptionMaxLen) { + invalidArgument( + `monitor.description: must be at most ${MONITOR_BOUNDS.descriptionMaxLen} characters [string.max_len]`, + ); + } + } + + if (mon.timeout !== undefined && mon.timeout !== BigInt(0)) { + if ( + mon.timeout < BigInt(0) || + mon.timeout > BigInt(MONITOR_BOUNDS.timeoutMaxMs) + ) { + invalidArgument( + `monitor.timeout: must be between 0 and ${MONITOR_BOUNDS.timeoutMaxMs} [int64.gte_lte]`, + ); + } + } + + if (mon.degradedAt !== undefined) { + if ( + mon.degradedAt < BigInt(0) || + mon.degradedAt > BigInt(MONITOR_BOUNDS.degradedAtMaxMs) + ) { + invalidArgument( + `monitor.degraded_at: must be between 0 and ${MONITOR_BOUNDS.degradedAtMaxMs} [int64.gte_lte]`, + ); + } + } + + if (mon.retry !== undefined && mon.retry !== BigInt(0)) { + if (mon.retry < BigInt(0) || mon.retry > BigInt(MONITOR_BOUNDS.retryMax)) { + invalidArgument( + `monitor.retry: must be between 0 and ${MONITOR_BOUNDS.retryMax} [int64.gte_lte]`, + ); + } + } + + if ( + mon.regions !== undefined && + mon.regions.length > MONITOR_BOUNDS.regionsMaxItems + ) { + invalidArgument( + `monitor.regions: must contain at most ${MONITOR_BOUNDS.regionsMaxItems} items [repeated.max_items]`, + ); + } +} + /** * Extract the fields every monitor type shares, in the shape * `createMonitor` takes. Defaults are applied here rather than left to diff --git a/apps/server/src/routes/rpc/interceptors/__tests__/tracking.test.ts b/apps/server/src/routes/rpc/interceptors/__tests__/tracking.test.ts index 38a05bdbb..6ebe54be4 100644 --- a/apps/server/src/routes/rpc/interceptors/__tests__/tracking.test.ts +++ b/apps/server/src/routes/rpc/interceptors/__tests__/tracking.test.ts @@ -1,5 +1,6 @@ import type { Interceptor } from "@connectrpc/connect"; import { Events } from "@openstatus/analytics"; +import { MonitorService } from "@openstatus/proto/monitor/v1"; // @ts-nocheck — ConnectRPC's deep generic types (AnyFn, UnaryResponse, etc.) // are incompatible with the test mocks. All runtime behavior is correct. import { @@ -115,12 +116,12 @@ describe("trackingInterceptor", () => { }); }); - test("extracts additional props from message", async () => { + test("extracts additional props from nested create message", async () => { const interceptor = trackingInterceptor(); const req = createMockRequest( "openstatus.monitor.v1.MonitorService", "CreateHTTPMonitor", - { url: "https://example.com", jobType: "http", name: "my-monitor" }, + { monitor: { url: "https://example.com", name: "my-monitor" } }, ); const next = mockNext({}); @@ -133,6 +134,24 @@ describe("trackingInterceptor", () => { }); }); + test("maps icmp uri to url and stamps jobType", async () => { + const interceptor = trackingInterceptor(); + const req = createMockRequest( + "openstatus.monitor.v1.MonitorService", + "CreateICMPMonitor", + { monitor: { name: "ping", uri: "example.com" } }, + ); + const next = mockNext({}); + + await interceptor(next)(req as never); + await Promise.resolve(); + + expect(mockTrack).toHaveBeenCalledWith({ + ...Events.CreateMonitor, + additionalProps: { url: "example.com", jobType: "icmp" }, + }); + }); + test("silently skips unmapped methods", async () => { const interceptor = trackingInterceptor(); const req = createMockRequest( @@ -237,4 +256,37 @@ describe("RPC_EVENT_MAP", () => { expect(mapping.event.channel).toBeDefined(); } }); + + // Derived from the service definition rather than a hand-written list, so a + // newly added Create*/Update*/Delete* RPC fails here instead of silently + // going untracked — which is how ICMP was missed. + test("every mutating MonitorService RPC is tracked", () => { + const mutating = Object.values(MonitorService.method) + .map((m) => m.name) + .filter((name) => /^(Create|Update|Delete)/.test(name)); + + expect(mutating.length).toBeGreaterThan(0); + + const untracked = mutating.filter( + (name) => + !(`openstatus.monitor.v1.MonitorService/${name}` in RPC_EVENT_MAP), + ); + + expect(untracked).toEqual([]); + }); + + test("ICMP monitor mutations are tracked like the other monitor types", () => { + const create = + RPC_EVENT_MAP["openstatus.monitor.v1.MonitorService/CreateICMPMonitor"]; + const update = + RPC_EVENT_MAP["openstatus.monitor.v1.MonitorService/UpdateICMPMonitor"]; + + expect(create?.event).toEqual(Events.CreateMonitor); + expect(update?.event).toEqual(Events.UpdateMonitor); + // Same props as the sibling create entries. + expect(create?.eventProps).toEqual( + RPC_EVENT_MAP["openstatus.monitor.v1.MonitorService/CreateDNSMonitor"] + ?.eventProps, + ); + }); }); diff --git a/apps/server/src/routes/rpc/interceptors/tracking.ts b/apps/server/src/routes/rpc/interceptors/tracking.ts index 0cb8794f3..4ae4ff357 100644 --- a/apps/server/src/routes/rpc/interceptors/tracking.ts +++ b/apps/server/src/routes/rpc/interceptors/tracking.ts @@ -14,8 +14,23 @@ const logger = getLogger("api-server"); type RpcEventMapping = { event: EventProps; eventProps?: string[]; + normalizeInput?: (message: unknown) => Record; }; +// Create*Monitor requests nest the config under `monitor`, so top-level +// extraction yields nothing; ICMP names its target `uri` and none of them +// carries jobType on the wire. +function monitorCreateInput(jobType: string) { + return (message: unknown): Record => { + if (typeof message !== "object" || message === null) return {}; + const { monitor } = message as { + monitor?: Record | undefined; + }; + if (!monitor) return {}; + return { ...monitor, url: monitor.uri ?? monitor.url, jobType }; + }; +} + /** * Mapping from "ServiceTypeName/MethodName" to OpenPanel event + optional props. * Keys use PascalCase method names matching DescMethod.name (the proto source name). @@ -26,14 +41,22 @@ export const RPC_EVENT_MAP: Record = { "openstatus.monitor.v1.MonitorService/CreateHTTPMonitor": { event: Events.CreateMonitor, eventProps: ["url", "jobType"], + normalizeInput: monitorCreateInput("http"), }, "openstatus.monitor.v1.MonitorService/CreateTCPMonitor": { event: Events.CreateMonitor, eventProps: ["url", "jobType"], + normalizeInput: monitorCreateInput("tcp"), }, "openstatus.monitor.v1.MonitorService/CreateDNSMonitor": { event: Events.CreateMonitor, eventProps: ["url", "jobType"], + normalizeInput: monitorCreateInput("dns"), + }, + "openstatus.monitor.v1.MonitorService/CreateICMPMonitor": { + event: Events.CreateMonitor, + eventProps: ["url", "jobType"], + normalizeInput: monitorCreateInput("icmp"), }, "openstatus.monitor.v1.MonitorService/UpdateHTTPMonitor": { event: Events.UpdateMonitor, @@ -44,6 +67,9 @@ export const RPC_EVENT_MAP: Record = { "openstatus.monitor.v1.MonitorService/UpdateDNSMonitor": { event: Events.UpdateMonitor, }, + "openstatus.monitor.v1.MonitorService/UpdateICMPMonitor": { + event: Events.UpdateMonitor, + }, "openstatus.monitor.v1.MonitorService/DeleteMonitor": { event: Events.DeleteMonitor, }, @@ -138,7 +164,8 @@ export function trackingInterceptor(): Interceptor { return response; } - const additionalProps = parseInputToProps(req.message, mapping.eventProps); + const input = mapping.normalizeInput?.(req.message) ?? req.message; + const additionalProps = parseInputToProps(input, mapping.eventProps); setupAnalytics({ userId: `api_${rpcCtx.workspace.id}`, diff --git a/apps/server/src/routes/rpc/interceptors/validation.ts b/apps/server/src/routes/rpc/interceptors/validation.ts index 34b427d02..84611c3e2 100644 --- a/apps/server/src/routes/rpc/interceptors/validation.ts +++ b/apps/server/src/routes/rpc/interceptors/validation.ts @@ -7,6 +7,7 @@ const SKIP_VALIDATION_METHODS = new Set([ "UpdateHTTPMonitor", "UpdateTCPMonitor", "UpdateDNSMonitor", + "UpdateICMPMonitor", ]); // protovalidate >=1.2 dropped the "value " prefix from the string.pattern diff --git a/apps/server/src/routes/v1/monitors/run/post.ts b/apps/server/src/routes/v1/monitors/run/post.ts index c49a97821..0c34dcb8d 100644 --- a/apps/server/src/routes/v1/monitors/run/post.ts +++ b/apps/server/src/routes/v1/monitors/run/post.ts @@ -21,6 +21,7 @@ import { HTTPException } from "hono/http-exception"; import type { monitorsApi } from ".."; import { ParamsSchema, TriggerResult } from "../schema"; +import { assertLegacyRunnableJobType } from "../utils"; import { QuerySchema } from "./schema"; const postMonitor = createRoute({ @@ -98,6 +99,8 @@ export function registerRunMonitor(api: typeof monitorsApi) { const row = parseMonitor.data; + assertLegacyRunnableJobType(row.jobType); + // Maybe later overwrite the region const monitorStatusData = await db diff --git a/apps/server/src/routes/v1/monitors/schema.ts b/apps/server/src/routes/v1/monitors/schema.ts index bc24a6a89..21ee0ed36 100644 --- a/apps/server/src/routes/v1/monitors/schema.ts +++ b/apps/server/src/routes/v1/monitors/schema.ts @@ -306,6 +306,7 @@ export const TCPTriggerResult = z.object({ errorMessage: z.string().optional().nullable(), }); +// Only the two types the v1 API can run — see `assertLegacyRunnableJobType`. export const TriggerResult = z.discriminatedUnion("jobType", [ HTTPTriggerResult, TCPTriggerResult, diff --git a/apps/server/src/routes/v1/monitors/trigger/post.test.ts b/apps/server/src/routes/v1/monitors/trigger/post.test.ts index 028a0a713..f0b322c27 100644 --- a/apps/server/src/routes/v1/monitors/trigger/post.test.ts +++ b/apps/server/src/routes/v1/monitors/trigger/post.test.ts @@ -1,3 +1,4 @@ +import { createMonitor } from "@openstatus/db/src/test/factories"; import { afterEach, expect, mock, test } from "@openstatus/test-utils"; import { app } from "@/index"; @@ -184,3 +185,58 @@ test("trigger monitor with multiple regions should return result id", async () = expect(result.success).toBe(true); expect(json.resultId).toBeDefined(); }); + +test("trigger ICMP monitor is rejected by the legacy v1 API", async () => { + const icmpMonitor = await createMonitor(1, { + jobType: "icmp", + url: "1.1.1.1", + active: true, + regions: "ams", + periodicity: "10m", + }); + + mockFetch.mockReturnValue( + Promise.resolve(new Response(null, { status: 200 })), + ); + + const res = await app.request(`/v1/monitor/${icmpMonitor.id}/trigger`, { + method: "POST", + headers: { + "x-openstatus-key": "1", + "content-type": "application/json", + }, + }); + + expect(res.status).toBe(400); + + const json = await res.json(); + expect(json.message).toContain("not supported by the v1 API"); + + // Rejected before any probe leaves the process. + expect(mockFetch.mock.calls.length).toBe(0); +}); + +test("trigger DNS monitor is rejected by the legacy v1 API", async () => { + const dnsMonitor = await createMonitor(1, { + jobType: "dns", + url: "openstatus.dev", + active: true, + regions: "ams", + periodicity: "10m", + }); + + mockFetch.mockReturnValue( + Promise.resolve(new Response(null, { status: 200 })), + ); + + const res = await app.request(`/v1/monitor/${dnsMonitor.id}/trigger`, { + method: "POST", + headers: { + "x-openstatus-key": "1", + "content-type": "application/json", + }, + }); + + expect(res.status).toBe(400); + expect(mockFetch.mock.calls.length).toBe(0); +}); diff --git a/apps/server/src/routes/v1/monitors/trigger/post.ts b/apps/server/src/routes/v1/monitors/trigger/post.ts index 3e502967c..70f0da060 100644 --- a/apps/server/src/routes/v1/monitors/trigger/post.ts +++ b/apps/server/src/routes/v1/monitors/trigger/post.ts @@ -17,6 +17,7 @@ import { import { OpenStatusApiError, openApiErrorResponses } from "@/libs/errors"; import type { monitorsApi } from ".."; +import { assertLegacyRunnableJobType } from "../utils"; import { ParamsSchema, TriggerSchema } from "./schema"; const postRoute = createRoute({ @@ -100,6 +101,8 @@ export function registerTriggerMonitor(api: typeof monitorsApi) { const row = validateMonitor.data; + assertLegacyRunnableJobType(row.jobType); + // Maybe later overwrite the region const _monitorStatus = await db diff --git a/apps/server/src/routes/v1/monitors/utils.ts b/apps/server/src/routes/v1/monitors/utils.ts index 25fd4cb10..92225ffc6 100644 --- a/apps/server/src/routes/v1/monitors/utils.ts +++ b/apps/server/src/routes/v1/monitors/utils.ts @@ -33,6 +33,24 @@ export function assertSafeMonitorUrl(args: { } } +/** + * The v1 API is legacy and frozen to the monitor types it already shipped: + * `POST /v1/monitor` refuses anything but http/tcp, so a newer type can only + * reach these routes on a monitor created elsewhere. Running one here would + * dispatch a probe the route cannot describe back to the caller. New types are + * served by the ConnectRPC API (`TriggerMonitor`) instead. + */ +const LEGACY_RUNNABLE_JOB_TYPES = ["http", "tcp"]; + +export function assertLegacyRunnableJobType(jobType: string): void { + if (!LEGACY_RUNNABLE_JOB_TYPES.includes(jobType)) { + throw new OpenStatusApiError({ + code: "BAD_REQUEST", + message: `Running a '${jobType}' monitor is not supported by the v1 API. Use the ConnectRPC MonitorService.TriggerMonitor instead.`, + }); + } +} + export const getAssertions = ( assertions: z.infer[], ): Assertion[] => { diff --git a/apps/server/static/openapi.yaml b/apps/server/static/openapi.yaml index 507f9fae7..532ee0485 100644 --- a/apps/server/static/openapi.yaml +++ b/apps/server/static/openapi.yaml @@ -470,6 +470,28 @@ components: title: CreateHTTPMonitorResponse additionalProperties: false description: CreateHTTPMonitorResponse is the response after creating an HTTP monitor. + openstatus.monitor.v1.CreateICMPMonitorRequest: + type: object + properties: + monitor: + title: monitor + description: Monitor configuration (required). + $ref: '#/components/schemas/openstatus.monitor.v1.ICMPMonitor' + title: CreateICMPMonitorRequest + required: + - monitor + additionalProperties: false + description: CreateICMPMonitorRequest is the request to create a new ICMP monitor. + openstatus.monitor.v1.CreateICMPMonitorResponse: + type: object + properties: + monitor: + title: monitor + description: The created monitor with assigned ID. + $ref: '#/components/schemas/openstatus.monitor.v1.ICMPMonitor' + title: CreateICMPMonitorResponse + additionalProperties: false + description: CreateICMPMonitorResponse is the response after creating an ICMP monitor. openstatus.monitor.v1.CreateTCPMonitorRequest: type: object properties: @@ -665,7 +687,7 @@ components: properties: monitor: title: monitor - description: The monitor configuration (one of HTTP, TCP, or DNS). + description: The monitor configuration (one of HTTP, TCP, DNS, or ICMP). $ref: '#/components/schemas/openstatus.monitor.v1.MonitorConfig' title: GetMonitorResponse additionalProperties: false @@ -1189,6 +1211,109 @@ components: title: Headers additionalProperties: false description: Headers represents a key-value pair for HTTP headers. + openstatus.monitor.v1.ICMPMonitor: + type: object + properties: + id: + type: string + title: id + description: Unique identifier for the monitor (output only for create requests). + name: + type: string + examples: + - Ping Gateway + title: name + maxLength: 256 + minLength: 1 + description: Name of the monitor (required, max 256 characters). + uri: + type: string + examples: + - 1.1.1.1 + title: uri + maxLength: 2048 + minLength: 1 + description: URI to monitor in format "host or IP" (required, max 2048 characters). + periodicity: + not: + enum: + - PERIODICITY_UNSPECIFIED + title: periodicity + description: Check periodicity (required). + $ref: '#/components/schemas/openstatus.monitor.v1.Periodicity' + timeout: + type: + - integer + - string + title: timeout + maximum: 120000 + minimum: 0 + format: int64 + description: Timeout in milliseconds (0-120000, defaults to 45000). + degradedAt: + type: + - integer + - string + - "null" + title: degraded_at + maximum: 120000 + minimum: 0 + format: int64 + description: Latency threshold for degraded status in milliseconds (optional, 0-120000). + retry: + type: + - integer + - string + title: retry + maximum: 10 + minimum: 0 + format: int64 + description: Number of retry attempts (0-10, defaults to 3). + description: + type: + - string + - "null" + title: description + maxLength: 1024 + description: Description of the monitor (optional). + active: + type: + - boolean + - "null" + title: active + description: Whether the monitor is active (defaults to false). + public: + type: + - boolean + - "null" + title: public + description: Whether the monitor is publicly visible (defaults to false). + regions: + type: array + items: + $ref: '#/components/schemas/openstatus.monitor.v1.Region' + title: regions + maxItems: 28 + description: Geographic regions to run checks from. + openTelemetry: + title: open_telemetry + description: OpenTelemetry configuration for exporting metrics. + $ref: '#/components/schemas/openstatus.monitor.v1.OpenTelemetryConfig' + status: + title: status + description: Current operational status of the monitor. + $ref: '#/components/schemas/openstatus.monitor.v1.MonitorStatus' + privateLocationIds: + type: array + items: + type: string + readOnly: true + title: private_location_ids + description: IDs of private locations that run this monitor. Read-only. + readOnly: true + title: ICMPMonitor + additionalProperties: false + description: ICMPMonitor defines the configuration for a ICMP monitor. openstatus.monitor.v1.ListMonitorHTTPResponseLogsRequest: type: object properties: @@ -1293,6 +1418,12 @@ components: $ref: '#/components/schemas/openstatus.monitor.v1.DNSMonitor' title: dns_monitors description: DNS monitors in the workspace. + icmpMonitors: + type: array + items: + $ref: '#/components/schemas/openstatus.monitor.v1.ICMPMonitor' + title: icmp_monitors + description: ICMP monitors in the workspace. totalSize: type: integer title: total_size @@ -1322,6 +1453,15 @@ components: title: http required: - http + - type: object + properties: + icmp: + title: icmp + description: ICMP monitor configuration. + $ref: '#/components/schemas/openstatus.monitor.v1.ICMPMonitor' + title: icmp + required: + - icmp - type: object properties: tcp: @@ -1695,6 +1835,33 @@ components: title: UpdateHTTPMonitorResponse additionalProperties: false description: UpdateHTTPMonitorResponse is the response after updating an HTTP monitor. + openstatus.monitor.v1.UpdateICMPMonitorRequest: + type: object + properties: + id: + type: string + title: id + minLength: 1 + description: Monitor ID to update (required). + monitor: + oneOf: + - $ref: '#/components/schemas/openstatus.monitor.v1.ICMPMonitor' + - type: "null" + title: monitor + description: Updated monitor configuration (all fields optional for partial updates). + title: UpdateICMPMonitorRequest + additionalProperties: false + description: UpdateICMPMonitorRequest is the request to update an existing ICMP monitor. + openstatus.monitor.v1.UpdateICMPMonitorResponse: + type: object + properties: + monitor: + title: monitor + description: The updated monitor. + $ref: '#/components/schemas/openstatus.monitor.v1.ICMPMonitor' + title: UpdateICMPMonitorResponse + additionalProperties: false + description: UpdateICMPMonitorResponse is the response after updating an ICMP monitor. openstatus.monitor.v1.UpdateTCPMonitorRequest: type: object properties: @@ -5046,6 +5213,32 @@ paths: application/json: schema: $ref: '#/components/schemas/openstatus.monitor.v1.CreateHTTPMonitorResponse' + /rpc/openstatus.monitor.v1.MonitorService/CreateICMPMonitor: + post: + tags: + - MonitorService + summary: CreateICMPMonitor + description: CreateICMPMonitor creates a new ICMP monitor. + operationId: MonitorService_CreateICMPMonitor + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/openstatus.monitor.v1.CreateICMPMonitorRequest' + required: true + responses: + default: + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/connect.error' + "200": + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/openstatus.monitor.v1.CreateICMPMonitorResponse' /rpc/openstatus.monitor.v1.MonitorService/CreateTCPMonitor: post: tags: @@ -5105,7 +5298,7 @@ paths: summary: GetMonitor description: |- GetMonitor returns a single monitor by ID within the authenticated workspace. - Returns the monitor configuration (HTTP, TCP, or DNS) using the MonitorConfig oneof type. + Returns the monitor configuration (HTTP, TCP, DNS, or ICMP) using the MonitorConfig oneof type. operationId: MonitorService_GetMonitor.get parameters: - name: message @@ -5133,7 +5326,7 @@ paths: summary: GetMonitor description: |- GetMonitor returns a single monitor by ID within the authenticated workspace. - Returns the monitor configuration (HTTP, TCP, or DNS) using the MonitorConfig oneof type. + Returns the monitor configuration (HTTP, TCP, DNS, or ICMP) using the MonitorConfig oneof type. operationId: MonitorService_GetMonitor requestBody: content: @@ -5492,6 +5685,32 @@ paths: application/json: schema: $ref: '#/components/schemas/openstatus.monitor.v1.UpdateHTTPMonitorResponse' + /rpc/openstatus.monitor.v1.MonitorService/UpdateICMPMonitor: + post: + tags: + - MonitorService + summary: UpdateICMPMonitor + description: UpdateICMPMonitor updates an existing ICMP monitor. + operationId: MonitorService_UpdateICMPMonitor + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/openstatus.monitor.v1.UpdateICMPMonitorRequest' + required: true + responses: + default: + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/connect.error' + "200": + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/openstatus.monitor.v1.UpdateICMPMonitorResponse' /rpc/openstatus.monitor.v1.MonitorService/UpdateTCPMonitor: post: tags: diff --git a/apps/web/src/content/docs.config.ts b/apps/web/src/content/docs.config.ts index ab2a78d6b..85573dfd5 100644 --- a/apps/web/src/content/docs.config.ts +++ b/apps/web/src/content/docs.config.ts @@ -243,6 +243,7 @@ export const docsNav: DocsNavSection[] = [ { slug: "reference/mcp-server", label: "MCP Server" }, { slug: "reference/dns-monitor", label: "DNS Monitor Reference" }, { slug: "reference/http-monitor", label: "HTTP Monitor Reference" }, + { slug: "reference/icmp-monitor", label: "ICMP Monitor Reference" }, { slug: "reference/incident", label: "Incident Reference" }, { slug: "reference/tcp-monitor", label: "TCP Monitor Reference" }, { diff --git a/apps/web/src/content/pages/changelog/icmp-monitoring.mdx b/apps/web/src/content/pages/changelog/icmp-monitoring.mdx new file mode 100644 index 000000000..8501892b6 --- /dev/null +++ b/apps/web/src/content/pages/changelog/icmp-monitoring.mdx @@ -0,0 +1,14 @@ +--- +title: "ICMP Monitoring" +description: "Monitor your hosts with ICMP ping from openstatus." +publishedAt: "2026-07-21" +author: "openstatus" +category: "monitoring" +--- + +We're excited to announce that ICMP monitoring is now available in openstatus! You can now ping any host — routers, gateways, or bare network endpoints — to track reachability, round-trip latency, and packet loss, even when there's no TCP or HTTP service to check. + +Each check sends three echo requests and reports the average latency along with packet loss, so you get an early signal on lossy or degraded links. + +#### Get Started 🚀 +To start monitoring a host, add a new monitor and select "ICMP" as your monitor type, then enter the host or IP address you want to ping. diff --git a/apps/web/src/content/pages/docs/reference/icmp-monitor.mdx b/apps/web/src/content/pages/docs/reference/icmp-monitor.mdx new file mode 100644 index 000000000..d22f3cd71 --- /dev/null +++ b/apps/web/src/content/pages/docs/reference/icmp-monitor.mdx @@ -0,0 +1,98 @@ +--- +category: Reference +title: ICMP Monitor Reference +description: Complete technical specification for ICMP (ping) monitoring. +--- + +An ICMP monitor sends ICMP echo requests ("pings") to a host to verify that it is reachable and to measure round-trip latency. This is useful for monitoring hosts that expose no TCP or HTTP service, such as routers, gateways, and bare network endpoints. + +Each check sends **3 echo requests** spaced ~100ms apart. The check is considered up as long as **at least one** reply is received; it fails only when all three packets are lost. The reported latency is the **average** round-trip time of the replies received, and packet loss is recorded per check. + +**Use cases:** + +- Router, gateway, and firewall reachability. +- Baseline network latency and packet-loss tracking. +- Monitoring IoT and network devices without an application-layer service. + +## Configuration + +### Host + +**Type:** String (required) +**Format:** Hostname or IP address, without a port + +The host to ping. Both IPv4 and IPv6 targets are supported — the resolved address family selects the protocol automatically. + +**Examples:** +- `openstatus.dev` +- `1.1.1.1` +- `2001:4860:4860::8888` + +### Regions + +**Type:** Array of strings (required) +**Format:** Region identifiers (e.g., `iad`, `jnb`) + +The geographical regions from which the ping is sent. See the [Location Reference](/docs/reference/location) for the full list of regions and the IPs to allowlist. + +### Frequency + +**Type:** String (required) +**Format:** Duration string (e.g., `30s`, `1m`, `1h`) + +The interval at which the ICMP monitor pings the target host. Supported frequencies: +- `30 seconds` +- `1 minute` +- `5 minutes` +- `10 minutes` +- `30 minutes` +- `1 hour` + +### Response time thresholds + +#### Timeout + +**Type:** Duration (optional) +**Default:** `45 seconds` + +The total budget for the whole check (all three echo requests). Each packet waits at most the remaining budget for a reply. + +#### Degraded + +**Type:** Duration (optional) + +The average latency after which a check is considered to be in a degraded performance state, giving early warning of network slowdowns. + +### Retry + +**Type:** Integer (optional) +**Default:** `3` + +The number of times the monitor retries a fully failed check (all packets lost) before reporting a definitive error. + +### OpenTelemetry + +Configures the export of monitoring metrics to an OpenTelemetry-compatible observability platform. + +#### OTLP endpoint + +**Type:** String (optional) +**Protocol:** HTTP only + +The OTLP endpoint URL where collected metrics — including latency and packet loss — are exported. Only HTTP endpoints are supported. + +#### OTLP headers + +**Type:** Key-value pairs (optional) + +Custom headers to include when sending metrics to your OTLP endpoint, commonly used for authentication or tenant identification. + +**Common example:** +``` +Authorization: Bearer +``` + +## Related resources + +- **[Create your first monitor](/docs/tutorial/create-your-first-monitor)** — step-by-step tutorial on setting up a monitor. +- **[CLI reference](/docs/reference/cli-reference)** — manage monitors programmatically from the command line. diff --git a/apps/web/src/lib/tb.ts b/apps/web/src/lib/tb.ts index 1634abf6c..437bc5731 100644 --- a/apps/web/src/lib/tb.ts +++ b/apps/web/src/lib/tb.ts @@ -11,7 +11,7 @@ export const tb = new OSTinybird({ // REMINDER: we could extend the limits (WorkspacePlan) by // knowing which plan the user is on and disable some periods const periods = ["1d", "7d", "14d"] as const; -const types = ["http", "tcp"] as const; +const types = ["http", "tcp", "icmp"] as const; // FIXME: check we we can also use Period from elswhere type Period = (typeof periods)[number]; @@ -25,6 +25,7 @@ export function prepareListByPeriod(period: Period, type: Type = "http") { const getData = { http: tb.legacy_httpListDaily, tcp: tb.legacy_tcpListDaily, + icmp: tb.icmpListDaily, } as const; return { getData: getData[type] }; } @@ -32,6 +33,7 @@ export function prepareListByPeriod(period: Period, type: Type = "http") { const getData = { http: tb.legacy_httpListWeekly, tcp: tb.legacy_tcpListWeekly, + icmp: tb.icmpListWeekly, } as const; return { getData: getData[type] }; } @@ -39,6 +41,7 @@ export function prepareListByPeriod(period: Period, type: Type = "http") { const getData = { http: tb.legacy_httpListBiweekly, tcp: tb.legacy_tcpListBiweekly, + icmp: tb.icmpListBiweekly, } as const; return { getData: getData[type] }; } @@ -46,6 +49,7 @@ export function prepareListByPeriod(period: Period, type: Type = "http") { const getData = { http: tb.legacy_httpListDaily, tcp: tb.legacy_tcpListDaily, + icmp: tb.icmpListDaily, } as const; return { getData: getData[type] }; } @@ -58,6 +62,7 @@ export function prepareMetricsByPeriod(period: Period, type: Type = "http") { const getData = { http: tb.legacy_httpMetricsDaily, tcp: tb.legacy_tcpMetricsDaily, + icmp: tb.icmpMetricsDaily, } as const; return { getData: getData[type] }; } @@ -65,6 +70,7 @@ export function prepareMetricsByPeriod(period: Period, type: Type = "http") { const getData = { http: tb.legacy_httpMetricsWeekly, tcp: tb.legacy_tcpMetricsWeekly, + icmp: tb.icmpMetricsWeekly, } as const; return { getData: getData[type] }; } @@ -72,6 +78,7 @@ export function prepareMetricsByPeriod(period: Period, type: Type = "http") { const getData = { http: tb.legacy_httpMetricsBiweekly, tcp: tb.legacy_tcpMetricsBiweekly, + icmp: tb.icmpMetricsBiweekly, } as const; return { getData: getData[type] }; } @@ -79,6 +86,7 @@ export function prepareMetricsByPeriod(period: Period, type: Type = "http") { const getData = { http: tb.legacy_httpMetricsDaily, tcp: tb.legacy_tcpMetricsDaily, + icmp: tb.icmpMetricsDaily, } as const; return { getData: getData[type] }; } @@ -94,6 +102,7 @@ export function prepareMetricByRegionByPeriod( const getData = { http: tb.httpMetricsByRegionDaily, tcp: tb.tcpMetricsByRegionDaily, + icmp: tb.icmpMetricsByRegionDaily, } as const; return { getData: getData[type] }; } @@ -101,6 +110,7 @@ export function prepareMetricByRegionByPeriod( const getData = { http: tb.httpMetricsByRegionWeekly, tcp: tb.tcpMetricsByRegionWeekly, + icmp: tb.icmpMetricsByRegionWeekly, } as const; return { getData: getData[type] }; } @@ -108,6 +118,7 @@ export function prepareMetricByRegionByPeriod( const getData = { http: tb.httpMetricsByRegionBiweekly, tcp: tb.tcpMetricsByRegionBiweekly, + icmp: tb.icmpMetricsByRegionBiweekly, } as const; return { getData: getData[type] }; } @@ -115,6 +126,7 @@ export function prepareMetricByRegionByPeriod( const getData = { http: tb.httpMetricsByRegionDaily, tcp: tb.tcpMetricsByRegionDaily, + icmp: tb.icmpMetricsByRegionDaily, } as const; return { getData: getData[type] }; } @@ -130,6 +142,7 @@ export function prepareMetricByIntervalByPeriod( const getData = { http: tb.httpMetricsByIntervalDaily, tcp: tb.tcpMetricsByIntervalDaily, + icmp: tb.icmpMetricsByIntervalDaily, } as const; return { getData: getData[type] }; } @@ -137,6 +150,7 @@ export function prepareMetricByIntervalByPeriod( const getData = { http: tb.httpMetricsByIntervalWeekly, tcp: tb.tcpMetricsByIntervalWeekly, + icmp: tb.icmpMetricsByIntervalWeekly, } as const; return { getData: getData[type] }; } @@ -144,6 +158,7 @@ export function prepareMetricByIntervalByPeriod( const getData = { http: tb.httpMetricsByIntervalBiweekly, tcp: tb.tcpMetricsByIntervalBiweekly, + icmp: tb.icmpMetricsByIntervalBiweekly, } as const; return { getData: getData[type] }; } @@ -151,6 +166,7 @@ export function prepareMetricByIntervalByPeriod( const getData = { http: tb.httpMetricsByIntervalDaily, tcp: tb.tcpMetricsByIntervalDaily, + icmp: tb.icmpMetricsByIntervalDaily, } as const; return { getData: getData[type] }; } @@ -166,6 +182,7 @@ export function prepareStatusByPeriod( const getData = { http: tb.httpStatusWeekly, tcp: tb.tcpStatusWeekly, + icmp: tb.icmpStatusWeekly, } as const; return { getData: getData[type] }; } @@ -173,6 +190,7 @@ export function prepareStatusByPeriod( const getData = { http: tb.legacy_httpStatus45d, tcp: tb.legacy_tcpStatus45d, + icmp: tb.icmpStatus45d, } as const; return { getData: getData[type] }; } @@ -180,6 +198,7 @@ export function prepareStatusByPeriod( const getData = { http: tb.httpStatusWeekly, tcp: tb.tcpStatusWeekly, + icmp: tb.icmpStatusWeekly, } as const; return { getData: getData[type] }; } @@ -192,6 +211,7 @@ export function prepareGetByPeriod(period: "30d", type: Type = "http") { const getData = { http: tb.httpGetMonthly, tcp: tb.tcpGetMonthly, + icmp: tb.icmpGetMonthly, } as const; return { getData: getData[type] }; } @@ -199,6 +219,7 @@ export function prepareGetByPeriod(period: "30d", type: Type = "http") { const getData = { http: tb.httpGetMonthly, tcp: tb.tcpGetMonthly, + icmp: tb.icmpGetMonthly, } as const; return { getData: getData[type] }; } diff --git a/apps/workflows/src/cron/checker.ts b/apps/workflows/src/cron/checker.ts index 73d0dbb11..e39d950b5 100644 --- a/apps/workflows/src/cron/checker.ts +++ b/apps/workflows/src/cron/checker.ts @@ -28,6 +28,7 @@ import { regionDict } from "@openstatus/regions"; import { type DNSPayloadSchema, type httpPayloadSchema, + type icmpPayloadSchema, type tpcPayloadSchema, transformHeaders, } from "@openstatus/utils"; @@ -285,6 +286,7 @@ const createCronTask = async ( | z.infer | z.infer | z.infer + | z.infer | null = null; // @@ -354,6 +356,26 @@ const createCronTask = async ( }; } + if (row.jobType === "icmp") { + payload = { + workspaceId: String(row.workspaceId), + monitorId: String(row.id), + uri: row.url, + cronTimestamp: timestamp, + status: status, + degradedAfter: row.degradedAfter, + timeout: row.timeout, + trigger: "cron", + otelConfig: row.otelEndpoint + ? { + endpoint: row.otelEndpoint, + headers: transformHeaders(row.otelHeaders), + } + : undefined, + retry: row.retry || 3, + }; + } + if (!payload) { throw new Error("Invalid jobType"); } diff --git a/apps/workflows/src/cron/uptime-freeze.ts b/apps/workflows/src/cron/uptime-freeze.ts index 7a93f8bde..706bb21ea 100644 --- a/apps/workflows/src/cron/uptime-freeze.ts +++ b/apps/workflows/src/cron/uptime-freeze.ts @@ -21,6 +21,7 @@ const pipes: UptimeFreezePipes = { http: tb.httpStatus45d, tcp: tb.tcpStatus45d, dns: tb.dnsStatus45d, + icmp: tb.icmpStatus45d, }; export async function handleUptimeFreezeCron(c: Context) { diff --git a/packages/api/src/router/checker.ts b/packages/api/src/router/checker.ts index f99bfeca0..0e0119127 100644 --- a/packages/api/src/router/checker.ts +++ b/packages/api/src/router/checker.ts @@ -13,6 +13,7 @@ import { monitor, selectMonitorSchema } from "@openstatus/db/src/schema"; import { monitorRegionSchema } from "@openstatus/db/src/schema/constants"; import { type httpPayloadSchema, + type icmpPayloadSchema, safeUrlSchema, type tpcPayloadSchema, transformHeaders, @@ -25,6 +26,12 @@ import { createTRPCRouter, protectedProcedure } from "../trpc"; const ABORT_TIMEOUT = 10000; +// PingICMP treats its timeout as the deadline for the whole check, so omitting +// it means a deadline of "now": the send loop breaks before the first packet +// and every test reports "no reply". Kept under ABORT_TIMEOUT so the checker +// answers before the fetch above gives up. +const ICMP_TEST_TIMEOUT = 5000; + // Input schemas const httpTestInput = z.object({ url: safeUrlSchema, @@ -78,6 +85,37 @@ const dnsTestInput = z.object({ .prefault([]), }); +const icmpTestInput = z.object({ + url: z.string(), + region: monitorRegionSchema.optional().prefault("ams"), +}); + +export const icmpOutput = z + .object({ + state: z.literal("success").prefault("success"), + type: z.literal("icmp").prefault("icmp"), + requestId: z.number().optional(), + workspaceId: z.number().optional(), + monitorId: z.number().optional(), + timestamp: z.number(), + timing: z.object({ + rtts: z.array(z.number()), + }), + latency: z.number().optional(), + latencyMin: z.number().optional(), + latencyMax: z.number().optional(), + packetsSent: z.number().optional(), + packetsReceived: z.number().optional(), + error: z.string().optional(), + region: monitorRegionSchema, + }) + .or( + z.object({ + state: z.literal("error").prefault("error"), + message: z.string(), + }), + ); + export const tcpOutput = z .object({ state: z.literal("success").prefault("success"), @@ -370,12 +408,67 @@ export async function testDns(input: z.infer) { } } +export async function testIcmp(input: z.infer) { + try { + const res = await fetch( + `https://openstatus-checker.fly.dev/icmp/${input.region}`, + { + method: "POST", + headers: { + Authorization: `Basic ${env.CRON_SECRET}`, + "Content-Type": "application/json", + "fly-prefer-region": input.region, + }, + body: JSON.stringify({ + uri: input.url, + timeout: ICMP_TEST_TIMEOUT, + }), + signal: AbortSignal.timeout(ABORT_TIMEOUT), + }, + ); + + const json = await res.json(); + const result = icmpOutput.safeParse(json); + + if (!result.success) { + console.error( + `Checker ICMP test failed for ${input.url}:`, + result.error.message, + ); + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Checker response is not valid. Please try again. If the problem persists, please contact support. ${result.error.message}`, + }); + } + + if (result.data.state === "error") { + throw new TRPCError({ + code: "BAD_REQUEST", + message: result.data.message, + }); + } + + return result.data; + } catch (error) { + console.error("Checker ICMP test failed", error); + if (error instanceof TRPCError) { + throw error; + } + + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "ICMP check failed", + }); + } +} + export async function triggerChecker( input: z.infer, ) { let payload: | z.infer | z.infer + | z.infer | null = null; if (process.env.NODE_ENV !== "production") { @@ -450,6 +543,25 @@ export async function triggerChecker( followRedirects: input.followRedirects || true, }; } + if (input.jobType === "icmp") { + payload = { + workspaceId: String(input.workspaceId), + monitorId: String(input.id), + uri: input.url, + status: "active", + cronTimestamp: timestamp, + degradedAfter: input.degradedAfter, + timeout: input.timeout, + trigger: "cron", + retry: input.retry || 3, + otelConfig: input.otelEndpoint + ? { + endpoint: input.otelEndpoint, + headers: transformHeaders(input.otelHeaders), + } + : undefined, + }; + } const allResult = []; for (const region of input.regions) { @@ -477,6 +589,8 @@ function generateUrl({ row }: { row: z.infer }) { return `https://openstatus-checker.fly.dev/checker/tcp?monitor_id=${row.id}`; case "dns": return `https://openstatus-checker.fly.dev/checker/dns?monitor_id=${row.id}`; + case "icmp": + return `https://openstatus-checker.fly.dev/checker/icmp?monitor_id=${row.id}`; default: throw new Error("Invalid jobType"); } @@ -502,6 +616,12 @@ export const checkerRouter = createTRPCRouter({ .mutation(async ({ input }) => { return testDns(input); }), + testIcmp: protectedProcedure + .meta({ track: Events.TestMonitor }) + .input(icmpTestInput) + .mutation(async ({ input }) => { + return testIcmp(input); + }), triggerChecker: protectedProcedure .input(z.object({ id: z.number() })) diff --git a/packages/api/src/router/monitor.ts b/packages/api/src/router/monitor.ts index 08f0fadfe..a1a5b080c 100644 --- a/packages/api/src/router/monitor.ts +++ b/packages/api/src/router/monitor.ts @@ -35,7 +35,7 @@ import { z } from "zod"; import { env } from "../env"; import { toServiceCtx, toTRPCError } from "../service-adapter"; import { createTRPCRouter, protectedProcedure } from "../trpc"; -import { testDns, testHttp, testTcp } from "./checker"; +import { testDns, testHttp, testIcmp, testTcp } from "./checker"; // self-host has no access to the openstatus checker fleet, so the pre-save // endpoint test can never succeed — skip it entirely. @@ -342,6 +342,8 @@ export const monitorRouter = createTRPCRouter({ (a) => a.type === "dnsRecord", ), }); + } else if (input.jobType === "icmp") { + await testIcmp({ url: input.url, region: "ams" }); } } @@ -406,6 +408,8 @@ export const monitorRouter = createTRPCRouter({ (a) => a.type === "dnsRecord", ), }); + } else if (input.jobType === "icmp") { + await testIcmp({ url: input.url, region: "ams" }); } } diff --git a/packages/api/src/router/statusPage.ts b/packages/api/src/router/statusPage.ts index 684acc4b7..c25b4fc2f 100644 --- a/packages/api/src/router/statusPage.ts +++ b/packages/api/src/router/statusPage.ts @@ -704,12 +704,14 @@ export const statusPageRouter = createTRPCRouter({ http: monitors.filter((c) => c.monitor.jobType === "http"), tcp: monitors.filter((c) => c.monitor.jobType === "tcp"), dns: monitors.filter((c) => c.monitor.jobType === "dns"), + icmp: monitors.filter((c) => c.monitor.jobType === "icmp"), }; const proceduresByType = { http: getStatusProcedure("45d", "http"), tcp: getStatusProcedure("45d", "tcp"), dns: getStatusProcedure("45d", "dns"), + icmp: getStatusProcedure("45d", "icmp"), }; // Manual mode never touches Tinybird. Otherwise race the reads against @@ -717,7 +719,7 @@ export const statusPageRouter = createTRPCRouter({ // whole page to manual mode so bars still render from DB events. const tinybird = await withTinybirdFallback(() => input.barType === "manual" - ? Promise.resolve([null, null, null]) + ? Promise.resolve([null, null, null, null]) : Promise.all( Object.entries(proceduresByType).map(([type, procedure]) => { const monitorIds = monitorsByType[ @@ -730,7 +732,8 @@ export const statusPageRouter = createTRPCRouter({ ); const tinybirdUnhealthy = !tinybird.ok; - const [statusHttp, statusTcp, statusDns] = tinybird.data ?? [ + const [statusHttp, statusTcp, statusDns, statusIcmp] = tinybird.data ?? [ + null, null, null, null, @@ -741,10 +744,16 @@ export const statusPageRouter = createTRPCRouter({ | Awaited>["data"] | Awaited>["data"] | Awaited>["data"] + | Awaited>["data"] >(); // Consolidate status data from all monitor types into the map - for (const statusResult of [statusHttp, statusTcp, statusDns]) { + for (const statusResult of [ + statusHttp, + statusTcp, + statusDns, + statusIcmp, + ]) { if (statusResult?.data) { statusResult.data.forEach((status) => { const monitorId = status.monitorId; @@ -1010,12 +1019,14 @@ export const statusPageRouter = createTRPCRouter({ http: publicMonitors.filter((c) => c.monitor.jobType === "http"), tcp: publicMonitors.filter((c) => c.monitor.jobType === "tcp"), dns: publicMonitors.filter((c) => c.monitor.jobType === "dns"), + icmp: publicMonitors.filter((c) => c.monitor.jobType === "icmp"), }; const proceduresByType = { http: getMetricsLatencyMultiProcedure("1d", "http"), tcp: getMetricsLatencyMultiProcedure("1d", "tcp"), dns: getMetricsLatencyMultiProcedure("1d", "dns"), + icmp: getMetricsLatencyMultiProcedure("1d", "icmp"), }; // Slow/erroring Tinybird → empty latency data so the page still renders. @@ -1035,13 +1046,15 @@ export const statusPageRouter = createTRPCRouter({ metricsLatencyMultiHttp, metricsLatencyMultiTcp, metricsLatencyMultiDns, - ] = metrics.data ?? [null, null, null]; + metricsLatencyMultiIcmp, + ] = metrics.data ?? [null, null, null, null]; const metricsDataByMonitorId = new Map< string, | Awaited>["data"] | Awaited>["data"] | Awaited>["data"] + | Awaited>["data"] >(); if (metricsLatencyMultiHttp?.data) { @@ -1074,6 +1087,16 @@ export const statusPageRouter = createTRPCRouter({ }); } + if (metricsLatencyMultiIcmp?.data) { + metricsLatencyMultiIcmp.data.forEach((metric) => { + const monitorId = metric.monitorId; + if (!metricsDataByMonitorId.has(monitorId)) { + metricsDataByMonitorId.set(monitorId, []); + } + metricsDataByMonitorId.get(monitorId)?.push(metric); + }); + } + return publicMonitors.map((c) => { const monitorId = c.monitor.id.toString(); const data = metricsDataByMonitorId.get(monitorId) || []; @@ -1118,7 +1141,7 @@ export const statusPageRouter = createTRPCRouter({ if (!_monitor.public) return null; if (_monitor.deletedAt) return null; - const type = _monitor.jobType as "http" | "tcp"; + const type = _monitor.jobType as "http" | "tcp" | "dns" | "icmp"; const proceduresByType = { http: { @@ -1136,6 +1159,11 @@ export const statusPageRouter = createTRPCRouter({ regions: getMetricsRegionsProcedure("7d", "dns"), uptime: getUptimeProcedure("7d", "dns"), }, + icmp: { + latency: getMetricsLatencyProcedure("7d", "icmp"), + regions: getMetricsRegionsProcedure("7d", "icmp"), + uptime: getUptimeProcedure("7d", "icmp"), + }, }; const fromDate = startOfDay(subDays(new Date(), 7)).toISOString(); diff --git a/packages/api/src/router/tinybird/index.ts b/packages/api/src/router/tinybird/index.ts index 9f939c7fe..2e38de876 100644 --- a/packages/api/src/router/tinybird/index.ts +++ b/packages/api/src/router/tinybird/index.ts @@ -9,7 +9,7 @@ import { createTRPCRouter, protectedProcedure } from "../../trpc"; import { calculatePeriod } from "./utils"; const periods = ["1d", "7d", "14d", "30d", "90d"] as const; -const types = ["http", "tcp", "dns"] as const; +const types = ["http", "tcp", "dns", "icmp"] as const; type Period = (typeof periods)[number]; type Type = (typeof types)[number]; @@ -42,7 +42,9 @@ function clampInterval(period: Period, interval?: number) { // NEW: workspace-level counters helper export function getWorkspace30dProcedure(type: Type) { - return type === "http" ? tb.httpWorkspace30d : tb.tcpWorkspace30d; + if (type === "http") return tb.httpWorkspace30d; + if (type === "icmp") return tb.icmpWorkspace30d; + return tb.tcpWorkspace30d; } // Helper functions to get the right procedure based on period and type export function getListProcedure(period: Period, type: Type) { @@ -51,21 +53,25 @@ export function getListProcedure(period: Period, type: Type) { if (type === "http") return tb.httpListDaily; if (type === "tcp") return tb.tcpListDaily; if (type === "dns") return tb.dnsListBiweekly; + if (type === "icmp") return tb.icmpListDaily; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); case "7d": if (type === "http") return tb.httpListWeekly; if (type === "tcp") return tb.tcpListWeekly; if (type === "dns") return tb.dnsListBiweekly; + if (type === "icmp") return tb.icmpListWeekly; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); case "14d": if (type === "http") return tb.httpListBiweekly; if (type === "tcp") return tb.tcpListBiweekly; if (type === "dns") return tb.dnsListBiweekly; + if (type === "icmp") return tb.icmpListBiweekly; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); default: if (type === "http") return tb.httpListDaily; if (type === "tcp") return tb.tcpListDaily; if (type === "dns") return tb.dnsListBiweekly; + if (type === "icmp") return tb.icmpListDaily; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); } } @@ -76,31 +82,37 @@ export function getMetricsProcedure(period: Period, type: Type) { if (type === "dns") return tb.dnsMetricsDaily; if (type === "http") return tb.httpMetricsDaily; if (type === "tcp") return tb.tcpMetricsDaily; + if (type === "icmp") return tb.icmpMetricsDaily; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); case "7d": if (type === "dns") return tb.dnsMetricsWeekly; if (type === "http") return tb.httpMetricsWeekly; if (type === "tcp") return tb.tcpMetricsWeekly; + if (type === "icmp") return tb.icmpMetricsWeekly; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); case "14d": if (type === "dns") return tb.dnsMetricsBiweekly; if (type === "http") return tb.httpMetricsBiweekly; if (type === "tcp") return tb.tcpMetricsBiweekly; + if (type === "icmp") return tb.icmpMetricsBiweekly; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); case "30d": if (type === "dns") return tb.dnsMetrics30d; if (type === "http") return tb.httpMetrics30d; if (type === "tcp") return tb.tcpMetrics30d; + if (type === "icmp") return tb.icmpMetrics30d; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); case "90d": if (type === "dns") return tb.dnsMetrics90d; if (type === "http") return tb.httpMetrics90d; if (type === "tcp") return tb.tcpMetrics90d; + if (type === "icmp") return tb.icmpMetrics90d; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); default: if (type === "dns") return tb.dnsMetricsDaily; if (type === "http") return tb.httpMetricsDaily; if (type === "tcp") return tb.tcpMetricsDaily; + if (type === "icmp") return tb.icmpMetricsDaily; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); } } @@ -112,31 +124,37 @@ export function getMetricsRegionsProcedure(period: Period, type: Type) { if (type === "dns") return tb.dnsMetricsRegionsBiweekly; if (type === "http") return tb.httpMetricsRegionsDaily; if (type === "tcp") return tb.tcpMetricsByIntervalDaily; + if (type === "icmp") return tb.icmpMetricsByIntervalDaily; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); case "7d": if (type === "dns") return tb.dnsMetricsRegionsBiweekly; if (type === "http") return tb.httpMetricsRegionsWeekly; if (type === "tcp") return tb.tcpMetricsByIntervalWeekly; + if (type === "icmp") return tb.icmpMetricsByIntervalWeekly; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); case "14d": if (type === "dns") return tb.dnsMetricsRegionsBiweekly; if (type === "http") return tb.httpMetricsRegionsBiweekly; if (type === "tcp") return tb.tcpMetricsByIntervalBiweekly; + if (type === "icmp") return tb.icmpMetricsByIntervalBiweekly; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); case "30d": if (type === "dns") return tb.dnsMetricsRegions30d; if (type === "http") return tb.httpMetricsRegions30d; if (type === "tcp") return tb.tcpMetricsByInterval30d; + if (type === "icmp") return tb.icmpMetricsByInterval30d; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); case "90d": if (type === "dns") return tb.dnsMetricsRegions90d; if (type === "http") return tb.httpMetricsRegions90d; if (type === "tcp") return tb.tcpMetricsByInterval90d; + if (type === "icmp") return tb.icmpMetricsByInterval90d; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); default: if (type === "dns") return tb.dnsMetricsRegionsBiweekly; if (type === "http") return tb.httpMetricsRegionsDaily; if (type === "tcp") return tb.tcpMetricsByIntervalDaily; + if (type === "icmp") return tb.icmpMetricsByIntervalDaily; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); } } @@ -145,6 +163,7 @@ export function getStatusProcedure(_period: "45d", type: Type) { if (type === "dns") return tb.dnsStatus45d; if (type === "http") return tb.httpStatus45d; if (type === "tcp") return tb.tcpStatus45d; + if (type === "icmp") return tb.icmpStatus45d; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); } @@ -154,11 +173,13 @@ export function getGetProcedure(period: "14d", type: Type) { if (type === "http") return tb.httpGetBiweekly; if (type === "tcp") return tb.tcpGetBiweekly; if (type === "dns") return tb.dnsGetBiweekly; + if (type === "icmp") return tb.icmpGetBiweekly; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); default: if (type === "http") return tb.httpGetBiweekly; if (type === "tcp") return tb.tcpGetBiweekly; if (type === "dns") return tb.dnsGetBiweekly; + if (type === "icmp") return tb.icmpGetBiweekly; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); } } @@ -167,6 +188,7 @@ export function getGlobalMetricsProcedure(type: Type) { if (type === "http") return tb.httpGlobalMetricsDaily; if (type === "tcp") return tb.tcpGlobalMetricsDaily; if (type === "dns") return tb.dnsGlobalMetricsDaily; + if (type === "icmp") return tb.icmpGlobalMetricsDaily; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); } @@ -177,21 +199,25 @@ export function getUptimeProcedure(period: "7d" | "30d" | "90d", type: Type) { if (type === "dns") return tb.dnsUptime30d; if (type === "http") return tb.httpUptimeWeekly; if (type === "tcp") return tb.tcpUptimeWeekly; + if (type === "icmp") return tb.icmpUptimeWeekly; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); case "30d": if (type === "dns") return tb.dnsUptime30d; if (type === "http") return tb.httpUptime30d; if (type === "tcp") return tb.tcpUptime30d; + if (type === "icmp") return tb.icmpUptime30d; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); case "90d": if (type === "dns") return tb.dnsUptime90d; if (type === "http") return tb.httpUptime90d; if (type === "tcp") return tb.tcpUptime90d; + if (type === "icmp") return tb.icmpUptime90d; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); default: if (type === "dns") return tb.dnsUptime30d; if (type === "http") return tb.httpUptime30d; if (type === "tcp") return tb.tcpUptime30d; + if (type === "icmp") return tb.icmpUptime30d; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); } } @@ -203,11 +229,13 @@ export function getMetricsLatencyProcedure(_period: Period, type: Type) { if (type === "dns") return tb.dnsMetricsLatency7d; if (type === "http") return tb.httpMetricsLatency1d; if (type === "tcp") return tb.tcpMetricsLatency1d; + if (type === "icmp") return tb.icmpMetricsLatency1d; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); case "7d": if (type === "dns") return tb.dnsMetricsLatency7d; if (type === "http") return tb.httpMetricsLatency7d; if (type === "tcp") return tb.tcpMetricsLatency7d; + if (type === "icmp") return tb.icmpMetricsLatency7d; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); // no dedicated 14d latency pipe; 30d MV is the smallest window covering 14d case "14d": @@ -215,16 +243,19 @@ export function getMetricsLatencyProcedure(_period: Period, type: Type) { if (type === "dns") return tb.dnsMetricsLatency30d; if (type === "http") return tb.httpMetricsLatency30d; if (type === "tcp") return tb.tcpMetricsLatency30d; + if (type === "icmp") return tb.icmpMetricsLatency30d; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); case "90d": if (type === "dns") return tb.dnsMetricsLatency90d; if (type === "http") return tb.httpMetricsLatency90d; if (type === "tcp") return tb.tcpMetricsLatency90d; + if (type === "icmp") return tb.icmpMetricsLatency90d; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); default: if (type === "dns") return tb.dnsMetricsLatency7d; if (type === "http") return tb.httpMetricsLatency1d; if (type === "tcp") return tb.tcpMetricsLatency1d; + if (type === "icmp") return tb.icmpMetricsLatency1d; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); } } @@ -233,6 +264,7 @@ export function getMetricsLatencyMultiProcedure(_period: Period, type: Type) { if (type === "dns") return tb.dnsMetricsLatency1dMulti; if (type === "http") return tb.httpMetricsLatency1dMulti; if (type === "tcp") return tb.tcpMetricsLatency1dMulti; + if (type === "icmp") return tb.icmpMetricsLatency1dMulti; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); } @@ -280,7 +312,7 @@ export const tinybirdRouter = createTRPCRouter({ const procedure = getListProcedure( period, - _monitor.jobType as "http" | "tcp" | "dns", + _monitor.jobType as "http" | "tcp" | "dns" | "icmp", ); return await procedure({ ...opts.input, @@ -473,7 +505,7 @@ export const tinybirdRouter = createTRPCRouter({ const procedure = getGetProcedure( opts.input.period, - _monitor.jobType as "http" | "tcp" | "dns", + _monitor.jobType as "http" | "tcp" | "dns" | "icmp", ); return await procedure(opts.input); }), diff --git a/packages/importers/src/providers/betterstack/mapper.test.ts b/packages/importers/src/providers/betterstack/mapper.test.ts index 70dfab132..1d805155c 100644 --- a/packages/importers/src/providers/betterstack/mapper.test.ts +++ b/packages/importers/src/providers/betterstack/mapper.test.ts @@ -76,6 +76,8 @@ describe("mapMonitorType", () => { expect(mapMonitorType("tcp")).toBe("tcp"); expect(mapMonitorType("udp")).toBe("udp"); expect(mapMonitorType("dns")).toBe("dns"); + expect(mapMonitorType("ping")).toBe("icmp"); + expect(mapMonitorType("ping_icmp")).toBe("icmp"); }); test("defaults to http for unknown types", () => { diff --git a/packages/importers/src/providers/betterstack/mapper.ts b/packages/importers/src/providers/betterstack/mapper.ts index 7dac8c219..4701bdb0c 100644 --- a/packages/importers/src/providers/betterstack/mapper.ts +++ b/packages/importers/src/providers/betterstack/mapper.ts @@ -40,8 +40,8 @@ const MONITOR_TYPE_MAP: Record = { expected_status_code: "http", tcp: "tcp", udp: "udp", - ping: "http", - ping_icmp: "http", + ping: "icmp", + ping_icmp: "icmp", dns: "dns", smtp: "http", pop: "http", diff --git a/packages/proto/api/openstatus/monitor/v1/icmp_monitor.proto b/packages/proto/api/openstatus/monitor/v1/icmp_monitor.proto new file mode 100644 index 000000000..0982563a3 --- /dev/null +++ b/packages/proto/api/openstatus/monitor/v1/icmp_monitor.proto @@ -0,0 +1,87 @@ +syntax = "proto3"; + +package openstatus.monitor.v1; + +import "buf/validate/validate.proto"; +import "gnostic/openapi/v3/annotations.proto"; +import "openstatus/monitor/v1/http_monitor.proto"; +import "openstatus/monitor/v1/monitor.proto"; + +option go_package = "github.com/openstatushq/openstatus/packages/proto/openstatus/monitor/v1;monitorv1"; + +// ICMPMonitor defines the configuration for a ICMP monitor. +message ICMPMonitor { + // Unique identifier for the monitor (output only for create requests). + string id = 1; + + // Name of the monitor (required, max 256 characters). + string name = 2 [ + (buf.validate.field).string = { + min_len: 1 + max_len: 256 + }, + (gnostic.openapi.v3.property) = {example: {yaml: "Ping Gateway"}} + ]; + + // URI to monitor in format "host or IP" (required, max 2048 characters). + string uri = 3 [ + (buf.validate.field).string = { + min_len: 1 + max_len: 2048 + }, + (gnostic.openapi.v3.property) = {example: {yaml: "1.1.1.1"}} + ]; + + // Check periodicity (required). + Periodicity periodicity = 4 [(buf.validate.field).enum = { + not_in: [0] + }]; + + // Timeout in milliseconds (0-120000, defaults to 45000). + int64 timeout = 5 [(buf.validate.field).int64 = { + gte: 0 + lte: 120000 + }]; + + // Latency threshold for degraded status in milliseconds (optional, 0-120000). + optional int64 degraded_at = 6 [(buf.validate.field).int64 = { + gte: 0 + lte: 120000 + }]; + + // Number of retry attempts (0-10, defaults to 3). + int64 retry = 7 [(buf.validate.field).int64 = { + gte: 0 + lte: 10 + }]; + + // Description of the monitor (optional). + optional string description = 8 [(buf.validate.field).string.max_len = 1024]; + + // Whether the monitor is active (defaults to false). + optional bool active = 9; + + // Whether the monitor is publicly visible (defaults to false). + optional bool public = 10; + + // Geographic regions to run checks from. + repeated Region regions = 11 [(buf.validate.field).repeated = { + max_items: 28 + items: { + enum: { + not_in: [0] + } + } + }]; + + // OpenTelemetry configuration for exporting metrics. + OpenTelemetryConfig open_telemetry = 12; + + // Current operational status of the monitor. + MonitorStatus status = 13; + + // IDs of private locations that run this monitor. Read-only. + repeated string private_location_ids = 14 [ + (gnostic.openapi.v3.property) = {read_only: true} + ]; +} diff --git a/packages/proto/api/openstatus/monitor/v1/service.proto b/packages/proto/api/openstatus/monitor/v1/service.proto index 284664a7c..6ebb97800 100644 --- a/packages/proto/api/openstatus/monitor/v1/service.proto +++ b/packages/proto/api/openstatus/monitor/v1/service.proto @@ -6,6 +6,7 @@ import "buf/validate/validate.proto"; import "gnostic/openapi/v3/annotations.proto"; import "openstatus/monitor/v1/dns_monitor.proto"; import "openstatus/monitor/v1/http_monitor.proto"; +import "openstatus/monitor/v1/icmp_monitor.proto"; import "openstatus/monitor/v1/monitor.proto"; import "openstatus/monitor/v1/tcp_monitor.proto"; @@ -38,6 +39,9 @@ service MonitorService { // CreateDNSMonitor creates a new DNS monitor. rpc CreateDNSMonitor(CreateDNSMonitorRequest) returns (CreateDNSMonitorResponse); + // CreateICMPMonitor creates a new ICMP monitor. + rpc CreateICMPMonitor(CreateICMPMonitorRequest) returns (CreateICMPMonitorResponse); + // UpdateHTTPMonitor updates an existing HTTP monitor. rpc UpdateHTTPMonitor(UpdateHTTPMonitorRequest) returns (UpdateHTTPMonitorResponse); @@ -47,6 +51,9 @@ service MonitorService { // UpdateDNSMonitor updates an existing DNS monitor. rpc UpdateDNSMonitor(UpdateDNSMonitorRequest) returns (UpdateDNSMonitorResponse); + // UpdateICMPMonitor updates an existing ICMP monitor. + rpc UpdateICMPMonitor(UpdateICMPMonitorRequest) returns (UpdateICMPMonitorResponse); + // TriggerMonitor initiates an immediate check for a monitor across all configured regions. rpc TriggerMonitor(TriggerMonitorRequest) returns (TriggerMonitorResponse) { option (gnostic.openapi.v3.operation) = { @@ -76,7 +83,7 @@ service MonitorService { } // GetMonitor returns a single monitor by ID within the authenticated workspace. - // Returns the monitor configuration (HTTP, TCP, or DNS) using the MonitorConfig oneof type. + // Returns the monitor configuration (HTTP, TCP, DNS, or ICMP) using the MonitorConfig oneof type. rpc GetMonitor(GetMonitorRequest) returns (GetMonitorResponse) { option idempotency_level = NO_SIDE_EFFECTS; } @@ -128,6 +135,18 @@ message CreateDNSMonitorResponse { DNSMonitor monitor = 1; } +// CreateICMPMonitorRequest is the request to create a new ICMP monitor. +message CreateICMPMonitorRequest { + // Monitor configuration (required). + ICMPMonitor monitor = 1 [(buf.validate.field).required = true]; +} + +// CreateICMPMonitorResponse is the response after creating an ICMP monitor. +message CreateICMPMonitorResponse { + // The created monitor with assigned ID. + ICMPMonitor monitor = 1; +} + // UpdateHTTPMonitorRequest is the request to update an existing HTTP monitor. message UpdateHTTPMonitorRequest { // Monitor ID to update (required). @@ -173,6 +192,21 @@ message UpdateDNSMonitorResponse { DNSMonitor monitor = 1; } +// UpdateICMPMonitorRequest is the request to update an existing ICMP monitor. +message UpdateICMPMonitorRequest { + // Monitor ID to update (required). + string id = 1 [(buf.validate.field).string.min_len = 1]; + + // Updated monitor configuration (all fields optional for partial updates). + optional ICMPMonitor monitor = 2; +} + +// UpdateICMPMonitorResponse is the response after updating an ICMP monitor. +message UpdateICMPMonitorResponse { + // The updated monitor. + ICMPMonitor monitor = 1; +} + // TriggerMonitorRequest is the request to trigger a monitor check. message TriggerMonitorRequest { // Monitor ID to trigger (required). @@ -220,6 +254,9 @@ message ListMonitorsResponse { // DNS monitors in the workspace. repeated DNSMonitor dns_monitors = 3; + // ICMP monitors in the workspace. + repeated ICMPMonitor icmp_monitors = 5; + // Total number of monitors across all types. int32 total_size = 4; } @@ -257,6 +294,8 @@ message MonitorConfig { TCPMonitor tcp = 2; // DNS monitor configuration. DNSMonitor dns = 3; + // ICMP monitor configuration. + ICMPMonitor icmp = 4; } } @@ -319,7 +358,7 @@ message GetMonitorRequest { // GetMonitorResponse is the response containing the monitor. message GetMonitorResponse { - // The monitor configuration (one of HTTP, TCP, or DNS). + // The monitor configuration (one of HTTP, TCP, DNS, or ICMP). MonitorConfig monitor = 1; } diff --git a/packages/proto/gen/openapi.yaml b/packages/proto/gen/openapi.yaml index 507f9fae7..532ee0485 100644 --- a/packages/proto/gen/openapi.yaml +++ b/packages/proto/gen/openapi.yaml @@ -470,6 +470,28 @@ components: title: CreateHTTPMonitorResponse additionalProperties: false description: CreateHTTPMonitorResponse is the response after creating an HTTP monitor. + openstatus.monitor.v1.CreateICMPMonitorRequest: + type: object + properties: + monitor: + title: monitor + description: Monitor configuration (required). + $ref: '#/components/schemas/openstatus.monitor.v1.ICMPMonitor' + title: CreateICMPMonitorRequest + required: + - monitor + additionalProperties: false + description: CreateICMPMonitorRequest is the request to create a new ICMP monitor. + openstatus.monitor.v1.CreateICMPMonitorResponse: + type: object + properties: + monitor: + title: monitor + description: The created monitor with assigned ID. + $ref: '#/components/schemas/openstatus.monitor.v1.ICMPMonitor' + title: CreateICMPMonitorResponse + additionalProperties: false + description: CreateICMPMonitorResponse is the response after creating an ICMP monitor. openstatus.monitor.v1.CreateTCPMonitorRequest: type: object properties: @@ -665,7 +687,7 @@ components: properties: monitor: title: monitor - description: The monitor configuration (one of HTTP, TCP, or DNS). + description: The monitor configuration (one of HTTP, TCP, DNS, or ICMP). $ref: '#/components/schemas/openstatus.monitor.v1.MonitorConfig' title: GetMonitorResponse additionalProperties: false @@ -1189,6 +1211,109 @@ components: title: Headers additionalProperties: false description: Headers represents a key-value pair for HTTP headers. + openstatus.monitor.v1.ICMPMonitor: + type: object + properties: + id: + type: string + title: id + description: Unique identifier for the monitor (output only for create requests). + name: + type: string + examples: + - Ping Gateway + title: name + maxLength: 256 + minLength: 1 + description: Name of the monitor (required, max 256 characters). + uri: + type: string + examples: + - 1.1.1.1 + title: uri + maxLength: 2048 + minLength: 1 + description: URI to monitor in format "host or IP" (required, max 2048 characters). + periodicity: + not: + enum: + - PERIODICITY_UNSPECIFIED + title: periodicity + description: Check periodicity (required). + $ref: '#/components/schemas/openstatus.monitor.v1.Periodicity' + timeout: + type: + - integer + - string + title: timeout + maximum: 120000 + minimum: 0 + format: int64 + description: Timeout in milliseconds (0-120000, defaults to 45000). + degradedAt: + type: + - integer + - string + - "null" + title: degraded_at + maximum: 120000 + minimum: 0 + format: int64 + description: Latency threshold for degraded status in milliseconds (optional, 0-120000). + retry: + type: + - integer + - string + title: retry + maximum: 10 + minimum: 0 + format: int64 + description: Number of retry attempts (0-10, defaults to 3). + description: + type: + - string + - "null" + title: description + maxLength: 1024 + description: Description of the monitor (optional). + active: + type: + - boolean + - "null" + title: active + description: Whether the monitor is active (defaults to false). + public: + type: + - boolean + - "null" + title: public + description: Whether the monitor is publicly visible (defaults to false). + regions: + type: array + items: + $ref: '#/components/schemas/openstatus.monitor.v1.Region' + title: regions + maxItems: 28 + description: Geographic regions to run checks from. + openTelemetry: + title: open_telemetry + description: OpenTelemetry configuration for exporting metrics. + $ref: '#/components/schemas/openstatus.monitor.v1.OpenTelemetryConfig' + status: + title: status + description: Current operational status of the monitor. + $ref: '#/components/schemas/openstatus.monitor.v1.MonitorStatus' + privateLocationIds: + type: array + items: + type: string + readOnly: true + title: private_location_ids + description: IDs of private locations that run this monitor. Read-only. + readOnly: true + title: ICMPMonitor + additionalProperties: false + description: ICMPMonitor defines the configuration for a ICMP monitor. openstatus.monitor.v1.ListMonitorHTTPResponseLogsRequest: type: object properties: @@ -1293,6 +1418,12 @@ components: $ref: '#/components/schemas/openstatus.monitor.v1.DNSMonitor' title: dns_monitors description: DNS monitors in the workspace. + icmpMonitors: + type: array + items: + $ref: '#/components/schemas/openstatus.monitor.v1.ICMPMonitor' + title: icmp_monitors + description: ICMP monitors in the workspace. totalSize: type: integer title: total_size @@ -1322,6 +1453,15 @@ components: title: http required: - http + - type: object + properties: + icmp: + title: icmp + description: ICMP monitor configuration. + $ref: '#/components/schemas/openstatus.monitor.v1.ICMPMonitor' + title: icmp + required: + - icmp - type: object properties: tcp: @@ -1695,6 +1835,33 @@ components: title: UpdateHTTPMonitorResponse additionalProperties: false description: UpdateHTTPMonitorResponse is the response after updating an HTTP monitor. + openstatus.monitor.v1.UpdateICMPMonitorRequest: + type: object + properties: + id: + type: string + title: id + minLength: 1 + description: Monitor ID to update (required). + monitor: + oneOf: + - $ref: '#/components/schemas/openstatus.monitor.v1.ICMPMonitor' + - type: "null" + title: monitor + description: Updated monitor configuration (all fields optional for partial updates). + title: UpdateICMPMonitorRequest + additionalProperties: false + description: UpdateICMPMonitorRequest is the request to update an existing ICMP monitor. + openstatus.monitor.v1.UpdateICMPMonitorResponse: + type: object + properties: + monitor: + title: monitor + description: The updated monitor. + $ref: '#/components/schemas/openstatus.monitor.v1.ICMPMonitor' + title: UpdateICMPMonitorResponse + additionalProperties: false + description: UpdateICMPMonitorResponse is the response after updating an ICMP monitor. openstatus.monitor.v1.UpdateTCPMonitorRequest: type: object properties: @@ -5046,6 +5213,32 @@ paths: application/json: schema: $ref: '#/components/schemas/openstatus.monitor.v1.CreateHTTPMonitorResponse' + /rpc/openstatus.monitor.v1.MonitorService/CreateICMPMonitor: + post: + tags: + - MonitorService + summary: CreateICMPMonitor + description: CreateICMPMonitor creates a new ICMP monitor. + operationId: MonitorService_CreateICMPMonitor + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/openstatus.monitor.v1.CreateICMPMonitorRequest' + required: true + responses: + default: + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/connect.error' + "200": + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/openstatus.monitor.v1.CreateICMPMonitorResponse' /rpc/openstatus.monitor.v1.MonitorService/CreateTCPMonitor: post: tags: @@ -5105,7 +5298,7 @@ paths: summary: GetMonitor description: |- GetMonitor returns a single monitor by ID within the authenticated workspace. - Returns the monitor configuration (HTTP, TCP, or DNS) using the MonitorConfig oneof type. + Returns the monitor configuration (HTTP, TCP, DNS, or ICMP) using the MonitorConfig oneof type. operationId: MonitorService_GetMonitor.get parameters: - name: message @@ -5133,7 +5326,7 @@ paths: summary: GetMonitor description: |- GetMonitor returns a single monitor by ID within the authenticated workspace. - Returns the monitor configuration (HTTP, TCP, or DNS) using the MonitorConfig oneof type. + Returns the monitor configuration (HTTP, TCP, DNS, or ICMP) using the MonitorConfig oneof type. operationId: MonitorService_GetMonitor requestBody: content: @@ -5492,6 +5685,32 @@ paths: application/json: schema: $ref: '#/components/schemas/openstatus.monitor.v1.UpdateHTTPMonitorResponse' + /rpc/openstatus.monitor.v1.MonitorService/UpdateICMPMonitor: + post: + tags: + - MonitorService + summary: UpdateICMPMonitor + description: UpdateICMPMonitor updates an existing ICMP monitor. + operationId: MonitorService_UpdateICMPMonitor + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/openstatus.monitor.v1.UpdateICMPMonitorRequest' + required: true + responses: + default: + description: Error + content: + application/json: + schema: + $ref: '#/components/schemas/connect.error' + "200": + description: Success + content: + application/json: + schema: + $ref: '#/components/schemas/openstatus.monitor.v1.UpdateICMPMonitorResponse' /rpc/openstatus.monitor.v1.MonitorService/UpdateTCPMonitor: post: tags: diff --git a/packages/proto/gen/ts/openstatus/monitor/v1/icmp_monitor_pb.ts b/packages/proto/gen/ts/openstatus/monitor/v1/icmp_monitor_pb.ts new file mode 100644 index 000000000..a05cb46b2 --- /dev/null +++ b/packages/proto/gen/ts/openstatus/monitor/v1/icmp_monitor_pb.ts @@ -0,0 +1,132 @@ +// @generated by protoc-gen-es v2.12.0 with parameter "target=ts,import_extension=.ts" +// @generated from file openstatus/monitor/v1/icmp_monitor.proto (package openstatus.monitor.v1, syntax proto3) +/* eslint-disable */ + +import type { GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2"; +import { fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2"; +import { file_buf_validate_validate } from "../../../buf/validate/validate_pb.ts"; +import { file_gnostic_openapi_v3_annotations } from "../../../gnostic/openapi/v3/annotations_pb.ts"; +import type { OpenTelemetryConfig } from "./http_monitor_pb.ts"; +import { file_openstatus_monitor_v1_http_monitor } from "./http_monitor_pb.ts"; +import type { MonitorStatus, Periodicity, Region } from "./monitor_pb.ts"; +import { file_openstatus_monitor_v1_monitor } from "./monitor_pb.ts"; +import type { Message } from "@bufbuild/protobuf"; + +/** + * Describes the file openstatus/monitor/v1/icmp_monitor.proto. + */ +export const file_openstatus_monitor_v1_icmp_monitor: GenFile = /*@__PURE__*/ + fileDesc("CihvcGVuc3RhdHVzL21vbml0b3IvdjEvaWNtcF9tb25pdG9yLnByb3RvEhVvcGVuc3RhdHVzLm1vbml0b3IudjEi8wQKC0lDTVBNb25pdG9yEgoKAmlkGAEgASgJEisKBG5hbWUYAiABKAlCHbpHEDoOEgxQaW5nIEdhdGV3YXm6SAdyBRABGIACEiUKA3VyaRgDIAEoCUIYukcLOgkSBzEuMS4xLjG6SAdyBRABGIAQEkEKC3BlcmlvZGljaXR5GAQgASgOMiIub3BlbnN0YXR1cy5tb25pdG9yLnYxLlBlcmlvZGljaXR5Qgi6SAWCAQIgABIcCgd0aW1lb3V0GAUgASgDQgu6SAgiBhjAqQcoABIlCgtkZWdyYWRlZF9hdBgGIAEoA0ILukgIIgYYwKkHKABIAIgBARIYCgVyZXRyeRgHIAEoA0IJukgGIgQYCigAEiIKC2Rlc2NyaXB0aW9uGAggASgJQgi6SAVyAxiACEgBiAEBEhMKBmFjdGl2ZRgJIAEoCEgCiAEBEhMKBnB1YmxpYxgKIAEoCEgDiAEBEj8KB3JlZ2lvbnMYCyADKA4yHS5vcGVuc3RhdHVzLm1vbml0b3IudjEuUmVnaW9uQg+6SAySAQkQHCIFggECIAASQgoOb3Blbl90ZWxlbWV0cnkYDCABKAsyKi5vcGVuc3RhdHVzLm1vbml0b3IudjEuT3BlblRlbGVtZXRyeUNvbmZpZxI0CgZzdGF0dXMYDSABKA4yJC5vcGVuc3RhdHVzLm1vbml0b3IudjEuTW9uaXRvclN0YXR1cxIjChRwcml2YXRlX2xvY2F0aW9uX2lkcxgOIAMoCUIFukcCGAFCDgoMX2RlZ3JhZGVkX2F0Qg4KDF9kZXNjcmlwdGlvbkIJCgdfYWN0aXZlQgkKB19wdWJsaWNCU1pRZ2l0aHViLmNvbS9vcGVuc3RhdHVzaHEvb3BlbnN0YXR1cy9wYWNrYWdlcy9wcm90by9vcGVuc3RhdHVzL21vbml0b3IvdjE7bW9uaXRvcnYxYgZwcm90bzM", [file_buf_validate_validate, file_gnostic_openapi_v3_annotations, file_openstatus_monitor_v1_http_monitor, file_openstatus_monitor_v1_monitor]); + +/** + * ICMPMonitor defines the configuration for a ICMP monitor. + * + * @generated from message openstatus.monitor.v1.ICMPMonitor + */ +export type ICMPMonitor = Message<"openstatus.monitor.v1.ICMPMonitor"> & { + /** + * Unique identifier for the monitor (output only for create requests). + * + * @generated from field: string id = 1; + */ + id: string; + + /** + * Name of the monitor (required, max 256 characters). + * + * @generated from field: string name = 2; + */ + name: string; + + /** + * URI to monitor in format "host or IP" (required, max 2048 characters). + * + * @generated from field: string uri = 3; + */ + uri: string; + + /** + * Check periodicity (required). + * + * @generated from field: openstatus.monitor.v1.Periodicity periodicity = 4; + */ + periodicity: Periodicity; + + /** + * Timeout in milliseconds (0-120000, defaults to 45000). + * + * @generated from field: int64 timeout = 5; + */ + timeout: bigint; + + /** + * Latency threshold for degraded status in milliseconds (optional, 0-120000). + * + * @generated from field: optional int64 degraded_at = 6; + */ + degradedAt?: bigint | undefined; + + /** + * Number of retry attempts (0-10, defaults to 3). + * + * @generated from field: int64 retry = 7; + */ + retry: bigint; + + /** + * Description of the monitor (optional). + * + * @generated from field: optional string description = 8; + */ + description?: string | undefined; + + /** + * Whether the monitor is active (defaults to false). + * + * @generated from field: optional bool active = 9; + */ + active?: boolean | undefined; + + /** + * Whether the monitor is publicly visible (defaults to false). + * + * @generated from field: optional bool public = 10; + */ + public?: boolean | undefined; + + /** + * Geographic regions to run checks from. + * + * @generated from field: repeated openstatus.monitor.v1.Region regions = 11; + */ + regions: Region[]; + + /** + * OpenTelemetry configuration for exporting metrics. + * + * @generated from field: openstatus.monitor.v1.OpenTelemetryConfig open_telemetry = 12; + */ + openTelemetry?: OpenTelemetryConfig | undefined; + + /** + * Current operational status of the monitor. + * + * @generated from field: openstatus.monitor.v1.MonitorStatus status = 13; + */ + status: MonitorStatus; + + /** + * IDs of private locations that run this monitor. Read-only. + * + * @generated from field: repeated string private_location_ids = 14; + */ + privateLocationIds: string[]; +}; + +/** + * Describes the message openstatus.monitor.v1.ICMPMonitor. + * Use `create(ICMPMonitorSchema)` to create a new message. + */ +export const ICMPMonitorSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_openstatus_monitor_v1_icmp_monitor, 0); + diff --git a/packages/proto/gen/ts/openstatus/monitor/v1/index.ts b/packages/proto/gen/ts/openstatus/monitor/v1/index.ts index d6c08846d..e068ed2ec 100644 --- a/packages/proto/gen/ts/openstatus/monitor/v1/index.ts +++ b/packages/proto/gen/ts/openstatus/monitor/v1/index.ts @@ -2,6 +2,7 @@ export * from "./assertions_pb.js"; export * from "./dns_monitor_pb.js"; export * from "./http_monitor_pb.js"; +export * from "./icmp_monitor_pb.js"; export * from "./tcp_monitor_pb.js"; export * from "./monitor_pb.js"; export * from "./service_pb.js"; diff --git a/packages/proto/gen/ts/openstatus/monitor/v1/service_pb.ts b/packages/proto/gen/ts/openstatus/monitor/v1/service_pb.ts index 947917338..9de06bc9e 100644 --- a/packages/proto/gen/ts/openstatus/monitor/v1/service_pb.ts +++ b/packages/proto/gen/ts/openstatus/monitor/v1/service_pb.ts @@ -10,6 +10,8 @@ import type { DNSMonitor } from "./dns_monitor_pb.ts"; import { file_openstatus_monitor_v1_dns_monitor } from "./dns_monitor_pb.ts"; import type { HTTPMonitor } from "./http_monitor_pb.ts"; import { file_openstatus_monitor_v1_http_monitor } from "./http_monitor_pb.ts"; +import type { ICMPMonitor } from "./icmp_monitor_pb.ts"; +import { file_openstatus_monitor_v1_icmp_monitor } from "./icmp_monitor_pb.ts"; import type { MonitorStatus, Region } from "./monitor_pb.ts"; import { file_openstatus_monitor_v1_monitor } from "./monitor_pb.ts"; import type { TCPMonitor } from "./tcp_monitor_pb.ts"; @@ -20,7 +22,7 @@ import type { Message } from "@bufbuild/protobuf"; * Describes the file openstatus/monitor/v1/service.proto. */ export const file_openstatus_monitor_v1_service: GenFile = /*@__PURE__*/ - fileDesc("CiNvcGVuc3RhdHVzL21vbml0b3IvdjEvc2VydmljZS5wcm90bxIVb3BlbnN0YXR1cy5tb25pdG9yLnYxIlcKGENyZWF0ZUhUVFBNb25pdG9yUmVxdWVzdBI7Cgdtb25pdG9yGAEgASgLMiIub3BlbnN0YXR1cy5tb25pdG9yLnYxLkhUVFBNb25pdG9yQga6SAPIAQEiUAoZQ3JlYXRlSFRUUE1vbml0b3JSZXNwb25zZRIzCgdtb25pdG9yGAEgASgLMiIub3BlbnN0YXR1cy5tb25pdG9yLnYxLkhUVFBNb25pdG9yIlUKF0NyZWF0ZVRDUE1vbml0b3JSZXF1ZXN0EjoKB21vbml0b3IYASABKAsyIS5vcGVuc3RhdHVzLm1vbml0b3IudjEuVENQTW9uaXRvckIGukgDyAEBIk4KGENyZWF0ZVRDUE1vbml0b3JSZXNwb25zZRIyCgdtb25pdG9yGAEgASgLMiEub3BlbnN0YXR1cy5tb25pdG9yLnYxLlRDUE1vbml0b3IiVQoXQ3JlYXRlRE5TTW9uaXRvclJlcXVlc3QSOgoHbW9uaXRvchgBIAEoCzIhLm9wZW5zdGF0dXMubW9uaXRvci52MS5ETlNNb25pdG9yQga6SAPIAQEiTgoYQ3JlYXRlRE5TTW9uaXRvclJlc3BvbnNlEjIKB21vbml0b3IYASABKAsyIS5vcGVuc3RhdHVzLm1vbml0b3IudjEuRE5TTW9uaXRvciJ1ChhVcGRhdGVIVFRQTW9uaXRvclJlcXVlc3QSEwoCaWQYASABKAlCB7pIBHICEAESOAoHbW9uaXRvchgCIAEoCzIiLm9wZW5zdGF0dXMubW9uaXRvci52MS5IVFRQTW9uaXRvckgAiAEBQgoKCF9tb25pdG9yIlAKGVVwZGF0ZUhUVFBNb25pdG9yUmVzcG9uc2USMwoHbW9uaXRvchgBIAEoCzIiLm9wZW5zdGF0dXMubW9uaXRvci52MS5IVFRQTW9uaXRvciJzChdVcGRhdGVUQ1BNb25pdG9yUmVxdWVzdBITCgJpZBgBIAEoCUIHukgEcgIQARI3Cgdtb25pdG9yGAIgASgLMiEub3BlbnN0YXR1cy5tb25pdG9yLnYxLlRDUE1vbml0b3JIAIgBAUIKCghfbW9uaXRvciJOChhVcGRhdGVUQ1BNb25pdG9yUmVzcG9uc2USMgoHbW9uaXRvchgBIAEoCzIhLm9wZW5zdGF0dXMubW9uaXRvci52MS5UQ1BNb25pdG9yInMKF1VwZGF0ZUROU01vbml0b3JSZXF1ZXN0EhMKAmlkGAEgASgJQge6SARyAhABEjcKB21vbml0b3IYAiABKAsyIS5vcGVuc3RhdHVzLm1vbml0b3IudjEuRE5TTW9uaXRvckgAiAEBQgoKCF9tb25pdG9yIk4KGFVwZGF0ZUROU01vbml0b3JSZXNwb25zZRIyCgdtb25pdG9yGAEgASgLMiEub3BlbnN0YXR1cy5tb25pdG9yLnYxLkROU01vbml0b3IiLAoVVHJpZ2dlck1vbml0b3JSZXF1ZXN0EhMKAmlkGAEgASgJQge6SARyAhABIikKFlRyaWdnZXJNb25pdG9yUmVzcG9uc2USDwoHc3VjY2VzcxgBIAEoCCIrChREZWxldGVNb25pdG9yUmVxdWVzdBITCgJpZBgBIAEoCUIHukgEcgIQASIoChVEZWxldGVNb25pdG9yUmVzcG9uc2USDwoHc3VjY2VzcxgBIAEoCCJnChNMaXN0TW9uaXRvcnNSZXF1ZXN0Eh0KBWxpbWl0GAEgASgFQgm6SAYaBBhkKAFIAIgBARIcCgZvZmZzZXQYAiABKAVCB7pIBBoCKABIAYgBAUIICgZfbGltaXRCCQoHX29mZnNldCLXAQoUTGlzdE1vbml0b3JzUmVzcG9uc2USOQoNaHR0cF9tb25pdG9ycxgBIAMoCzIiLm9wZW5zdGF0dXMubW9uaXRvci52MS5IVFRQTW9uaXRvchI3Cgx0Y3BfbW9uaXRvcnMYAiADKAsyIS5vcGVuc3RhdHVzLm1vbml0b3IudjEuVENQTW9uaXRvchI3CgxkbnNfbW9uaXRvcnMYAyADKAsyIS5vcGVuc3RhdHVzLm1vbml0b3IudjEuRE5TTW9uaXRvchISCgp0b3RhbF9zaXplGAQgASgFIi4KF0dldE1vbml0b3JTdGF0dXNSZXF1ZXN0EhMKAmlkGAEgASgJQge6SARyAhABInMKDFJlZ2lvblN0YXR1cxItCgZyZWdpb24YASABKA4yHS5vcGVuc3RhdHVzLm1vbml0b3IudjEuUmVnaW9uEjQKBnN0YXR1cxgCIAEoDjIkLm9wZW5zdGF0dXMubW9uaXRvci52MS5Nb25pdG9yU3RhdHVzIlwKGEdldE1vbml0b3JTdGF0dXNSZXNwb25zZRIKCgJpZBgBIAEoCRI0CgdyZWdpb25zGAIgAygLMiMub3BlbnN0YXR1cy5tb25pdG9yLnYxLlJlZ2lvblN0YXR1cyKxAQoNTW9uaXRvckNvbmZpZxIyCgRodHRwGAEgASgLMiIub3BlbnN0YXR1cy5tb25pdG9yLnYxLkhUVFBNb25pdG9ySAASMAoDdGNwGAIgASgLMiEub3BlbnN0YXR1cy5tb25pdG9yLnYxLlRDUE1vbml0b3JIABIwCgNkbnMYAyABKAsyIS5vcGVuc3RhdHVzLm1vbml0b3IudjEuRE5TTW9uaXRvckgAQggKBmNvbmZpZyKfAQoYR2V0TW9uaXRvclN1bW1hcnlSZXF1ZXN0EhMKAmlkGAEgASgJQge6SARyAhABEjQKCnRpbWVfcmFuZ2UYAiABKA4yIC5vcGVuc3RhdHVzLm1vbml0b3IudjEuVGltZVJhbmdlEjgKB3JlZ2lvbnMYAyADKA4yHS5vcGVuc3RhdHVzLm1vbml0b3IudjEuUmVnaW9uQgi6SAWSAQIQHCKsAgoZR2V0TW9uaXRvclN1bW1hcnlSZXNwb25zZRIKCgJpZBgBIAEoCRIUCgxsYXN0X3BpbmdfYXQYAiABKAkSGAoQdG90YWxfc3VjY2Vzc2Z1bBgDIAEoAxIWCg50b3RhbF9kZWdyYWRlZBgEIAEoAxIUCgx0b3RhbF9mYWlsZWQYBSABKAMSCwoDcDUwGAYgASgDEgsKA3A3NRgHIAEoAxILCgNwOTAYCCABKAMSCwoDcDk1GAkgASgDEgsKA3A5ORgKIAEoAxI0Cgp0aW1lX3JhbmdlGAsgASgOMiAub3BlbnN0YXR1cy5tb25pdG9yLnYxLlRpbWVSYW5nZRIuCgdyZWdpb25zGAwgAygOMh0ub3BlbnN0YXR1cy5tb25pdG9yLnYxLlJlZ2lvbiIoChFHZXRNb25pdG9yUmVxdWVzdBITCgJpZBgBIAEoCUIHukgEcgIQASJLChJHZXRNb25pdG9yUmVzcG9uc2USNQoHbW9uaXRvchgBIAEoCzIkLm9wZW5zdGF0dXMubW9uaXRvci52MS5Nb25pdG9yQ29uZmlnImIKFUhUVFBSZXNwb25zZUxvZ1RpbWluZxILCgNkbnMYASABKAUSDwoHY29ubmVjdBgCIAEoBRILCgN0bHMYAyABKAUSDAoEdHRmYhgEIAEoBRIQCgh0cmFuc2ZlchgFIAEoBSK1AwoXSFRUUFJlc3BvbnNlTG9nTGlzdEl0ZW0SDwoCaWQYASABKAlIAIgBARIPCgdsYXRlbmN5GAIgASgFEhgKC3N0YXR1c19jb2RlGAMgASgFSAGIAQESEgoKbW9uaXRvcl9pZBgEIAEoCRJLCg5yZXF1ZXN0X3N0YXR1cxgFIAEoDjIzLm9wZW5zdGF0dXMubW9uaXRvci52MS5IVFRQUmVzcG9uc2VMb2dSZXF1ZXN0U3RhdHVzEi0KBnJlZ2lvbhgGIAEoDjIdLm9wZW5zdGF0dXMubW9uaXRvci52MS5SZWdpb24SFgoOY3Jvbl90aW1lc3RhbXAYByABKAMSPgoHdHJpZ2dlchgIIAEoDjItLm9wZW5zdGF0dXMubW9uaXRvci52MS5IVFRQUmVzcG9uc2VMb2dUcmlnZ2VyEhEKCXRpbWVzdGFtcBgJIAEoAxJBCgZ0aW1pbmcYCiABKAsyLC5vcGVuc3RhdHVzLm1vbml0b3IudjEuSFRUUFJlc3BvbnNlTG9nVGltaW5nSAKIAQFCBQoDX2lkQg4KDF9zdGF0dXNfY29kZUIJCgdfdGltaW5nIrYCChVIVFRQUmVzcG9uc2VMb2dEZXRhaWwSOwoDbG9nGAEgASgLMi4ub3BlbnN0YXR1cy5tb25pdG9yLnYxLkhUVFBSZXNwb25zZUxvZ0xpc3RJdGVtEgsKA3VybBgCIAEoCRINCgVlcnJvchgDIAEoCBIUCgdtZXNzYWdlGAQgASgJSACIAQESSgoHaGVhZGVycxgFIAMoCzI5Lm9wZW5zdGF0dXMubW9uaXRvci52MS5IVFRQUmVzcG9uc2VMb2dEZXRhaWwuSGVhZGVyc0VudHJ5EhcKCmFzc2VydGlvbnMYBiABKAlIAYgBARouCgxIZWFkZXJzRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4AUIKCghfbWVzc2FnZUINCgtfYXNzZXJ0aW9ucyLnAQoiTGlzdE1vbml0b3JIVFRQUmVzcG9uc2VMb2dzUmVxdWVzdBITCgJpZBgBIAEoCUIHukgEcgIQARIbCg5mcm9tX3RpbWVzdGFtcBgCIAEoA0gAiAEBEhkKDHRvX3RpbWVzdGFtcBgDIAEoA0gBiAEBEh0KBWxpbWl0GAQgASgFQgm6SAYaBBhkKAFIAogBARIcCgZvZmZzZXQYBSABKAVCB7pIBBoCKABIA4gBAUIRCg9fZnJvbV90aW1lc3RhbXBCDwoNX3RvX3RpbWVzdGFtcEIICgZfbGltaXRCCQoHX29mZnNldCJ2ChlIVFRQUmVzcG9uc2VMb2dQYWdpbmF0aW9uEg0KBWxpbWl0GAEgASgFEg4KBm9mZnNldBgCIAEoBRIQCghoYXNfbW9yZRgDIAEoCBIYCgtuZXh0X29mZnNldBgEIAEoBUgAiAEBQg4KDF9uZXh0X29mZnNldCKpAQojTGlzdE1vbml0b3JIVFRQUmVzcG9uc2VMb2dzUmVzcG9uc2USPAoEbG9ncxgBIAMoCzIuLm9wZW5zdGF0dXMubW9uaXRvci52MS5IVFRQUmVzcG9uc2VMb2dMaXN0SXRlbRJECgpwYWdpbmF0aW9uGAIgASgLMjAub3BlbnN0YXR1cy5tb25pdG9yLnYxLkhUVFBSZXNwb25zZUxvZ1BhZ2luYXRpb24iUAogR2V0TW9uaXRvckhUVFBSZXNwb25zZUxvZ1JlcXVlc3QSEwoCaWQYASABKAlCB7pIBHICEAESFwoGbG9nX2lkGAIgASgJQge6SARyAhABIl4KIUdldE1vbml0b3JIVFRQUmVzcG9uc2VMb2dSZXNwb25zZRI5CgNsb2cYASABKAsyLC5vcGVuc3RhdHVzLm1vbml0b3IudjEuSFRUUFJlc3BvbnNlTG9nRGV0YWlsKmEKCVRpbWVSYW5nZRIaChZUSU1FX1JBTkdFX1VOU1BFQ0lGSUVEEAASEQoNVElNRV9SQU5HRV8xRBABEhEKDVRJTUVfUkFOR0VfN0QQAhISCg5USU1FX1JBTkdFXzE0RBADKtkBChxIVFRQUmVzcG9uc2VMb2dSZXF1ZXN0U3RhdHVzEjAKLEhUVFBfUkVTUE9OU0VfTE9HX1JFUVVFU1RfU1RBVFVTX1VOU1BFQ0lGSUVEEAASLAooSFRUUF9SRVNQT05TRV9MT0dfUkVRVUVTVF9TVEFUVVNfU1VDQ0VTUxABEioKJkhUVFBfUkVTUE9OU0VfTE9HX1JFUVVFU1RfU1RBVFVTX0VSUk9SEAISLQopSFRUUF9SRVNQT05TRV9MT0dfUkVRVUVTVF9TVEFUVVNfREVHUkFERUQQAyqKAQoWSFRUUFJlc3BvbnNlTG9nVHJpZ2dlchIpCiVIVFRQX1JFU1BPTlNFX0xPR19UUklHR0VSX1VOU1BFQ0lGSUVEEAASIgoeSFRUUF9SRVNQT05TRV9MT0dfVFJJR0dFUl9DUk9OEAESIQodSFRUUF9SRVNQT05TRV9MT0dfVFJJR0dFUl9BUEkQAjKbFAoOTW9uaXRvclNlcnZpY2USuQMKEUNyZWF0ZUhUVFBNb25pdG9yEi8ub3BlbnN0YXR1cy5tb25pdG9yLnYxLkNyZWF0ZUhUVFBNb25pdG9yUmVxdWVzdBowLm9wZW5zdGF0dXMubW9uaXRvci52MS5DcmVhdGVIVFRQTW9uaXRvclJlc3BvbnNlIsACuke8Ahq5AkNyZWF0ZXMgYSBuZXcgSFRUUCBtb25pdG9yIGluIHRoZSBhdXRoZW50aWNhdGVkIHdvcmtzcGFjZS4gQ29uZmlndXJlIHRoZSB0YXJnZXQgVVJMLCBIVFRQIG1ldGhvZCwgcmVxdWVzdCBoZWFkZXJzIGFuZCBib2R5LCByZXNwb25zZSBhc3NlcnRpb25zIChzdGF0dXMgY29kZSwgYm9keSBjb250ZW50LCBoZWFkZXJzKSwgY2hlY2sgcGVyaW9kaWNpdHksIGdlb2dyYXBoaWMgcmVnaW9ucywgYW5kIG9wdGlvbmFsIE9wZW5UZWxlbWV0cnkgZXhwb3J0LiBUaGUgbW9uaXRvciBzdGFydHMgY2hlY2tpbmcgaW1tZWRpYXRlbHkgaWYgc2V0IHRvIGFjdGl2ZS4ScwoQQ3JlYXRlVENQTW9uaXRvchIuLm9wZW5zdGF0dXMubW9uaXRvci52MS5DcmVhdGVUQ1BNb25pdG9yUmVxdWVzdBovLm9wZW5zdGF0dXMubW9uaXRvci52MS5DcmVhdGVUQ1BNb25pdG9yUmVzcG9uc2UScwoQQ3JlYXRlRE5TTW9uaXRvchIuLm9wZW5zdGF0dXMubW9uaXRvci52MS5DcmVhdGVETlNNb25pdG9yUmVxdWVzdBovLm9wZW5zdGF0dXMubW9uaXRvci52MS5DcmVhdGVETlNNb25pdG9yUmVzcG9uc2USdgoRVXBkYXRlSFRUUE1vbml0b3ISLy5vcGVuc3RhdHVzLm1vbml0b3IudjEuVXBkYXRlSFRUUE1vbml0b3JSZXF1ZXN0GjAub3BlbnN0YXR1cy5tb25pdG9yLnYxLlVwZGF0ZUhUVFBNb25pdG9yUmVzcG9uc2UScwoQVXBkYXRlVENQTW9uaXRvchIuLm9wZW5zdGF0dXMubW9uaXRvci52MS5VcGRhdGVUQ1BNb25pdG9yUmVxdWVzdBovLm9wZW5zdGF0dXMubW9uaXRvci52MS5VcGRhdGVUQ1BNb25pdG9yUmVzcG9uc2UScwoQVXBkYXRlRE5TTW9uaXRvchIuLm9wZW5zdGF0dXMubW9uaXRvci52MS5VcGRhdGVETlNNb25pdG9yUmVxdWVzdBovLm9wZW5zdGF0dXMubW9uaXRvci52MS5VcGRhdGVETlNNb25pdG9yUmVzcG9uc2US6QIKDlRyaWdnZXJNb25pdG9yEiwub3BlbnN0YXR1cy5tb25pdG9yLnYxLlRyaWdnZXJNb25pdG9yUmVxdWVzdBotLm9wZW5zdGF0dXMubW9uaXRvci52MS5UcmlnZ2VyTW9uaXRvclJlc3BvbnNlIvkBukf1ARryAU1hbnVhbGx5IHRyaWdnZXJzIGFuIGltbWVkaWF0ZSBjaGVjayBmb3IgdGhlIHNwZWNpZmllZCBtb25pdG9yIGFjcm9zcyBhbGwgY29uZmlndXJlZCByZWdpb25zLiBUaGlzIG9wZXJhdGlvbiBpcyByYXRlLWxpbWl0ZWQgdW5kZXIgdGhlIHN5bnRoZXRpYy1jaGVja3MgcXVvdGEuIEEgbW9uaXRvciBydW4gcmVjb3JkIGlzIGNyZWF0ZWQgYW5kIHRoZSBjaGVjayBpcyBkaXNwYXRjaGVkIHRvIHRoZSBjaGVja2VyIHNlcnZpY2UuEmoKDURlbGV0ZU1vbml0b3ISKy5vcGVuc3RhdHVzLm1vbml0b3IudjEuRGVsZXRlTW9uaXRvclJlcXVlc3QaLC5vcGVuc3RhdHVzLm1vbml0b3IudjEuRGVsZXRlTW9uaXRvclJlc3BvbnNlEmwKDExpc3RNb25pdG9ycxIqLm9wZW5zdGF0dXMubW9uaXRvci52MS5MaXN0TW9uaXRvcnNSZXF1ZXN0Gisub3BlbnN0YXR1cy5tb25pdG9yLnYxLkxpc3RNb25pdG9yc1Jlc3BvbnNlIgOQAgESeAoQR2V0TW9uaXRvclN0YXR1cxIuLm9wZW5zdGF0dXMubW9uaXRvci52MS5HZXRNb25pdG9yU3RhdHVzUmVxdWVzdBovLm9wZW5zdGF0dXMubW9uaXRvci52MS5HZXRNb25pdG9yU3RhdHVzUmVzcG9uc2UiA5ACARKmAwoRR2V0TW9uaXRvclN1bW1hcnkSLy5vcGVuc3RhdHVzLm1vbml0b3IudjEuR2V0TW9uaXRvclN1bW1hcnlSZXF1ZXN0GjAub3BlbnN0YXR1cy5tb25pdG9yLnYxLkdldE1vbml0b3JTdW1tYXJ5UmVzcG9uc2UirQKQAgG6R6YCGqMCUmV0dXJucyBhZ2dyZWdhdGVkIG1ldHJpY3MgZm9yIGEgbW9uaXRvciBpbmNsdWRpbmcgbGF0ZW5jeSBwZXJjZW50aWxlcyAocDUwLCBwNzUsIHA5MCwgcDk1LCBwOTkpLCByZXF1ZXN0IGNvdW50cyBieSBzdGF0dXMgKHN1Y2Nlc3NmdWwsIGRlZ3JhZGVkLCBmYWlsZWQpLCBhbmQgdGhlIHRpbWVzdGFtcCBvZiB0aGUgbGFzdCBjaGVjay4gTWV0cmljcyBjYW4gYmUgc2NvcGVkIHRvIGEgdGltZSByYW5nZSAoMSBkYXksIDcgZGF5cywgb3IgMTQgZGF5cykgYW5kIGZpbHRlcmVkIGJ5IHNwZWNpZmljIHJlZ2lvbnMuEmYKCkdldE1vbml0b3ISKC5vcGVuc3RhdHVzLm1vbml0b3IudjEuR2V0TW9uaXRvclJlcXVlc3QaKS5vcGVuc3RhdHVzLm1vbml0b3IudjEuR2V0TW9uaXRvclJlc3BvbnNlIgOQAgESmQEKG0xpc3RNb25pdG9ySFRUUFJlc3BvbnNlTG9ncxI5Lm9wZW5zdGF0dXMubW9uaXRvci52MS5MaXN0TW9uaXRvckhUVFBSZXNwb25zZUxvZ3NSZXF1ZXN0Gjoub3BlbnN0YXR1cy5tb25pdG9yLnYxLkxpc3RNb25pdG9ySFRUUFJlc3BvbnNlTG9nc1Jlc3BvbnNlIgOQAgESkwEKGUdldE1vbml0b3JIVFRQUmVzcG9uc2VMb2cSNy5vcGVuc3RhdHVzLm1vbml0b3IudjEuR2V0TW9uaXRvckhUVFBSZXNwb25zZUxvZ1JlcXVlc3QaOC5vcGVuc3RhdHVzLm1vbml0b3IudjEuR2V0TW9uaXRvckhUVFBSZXNwb25zZUxvZ1Jlc3BvbnNlIgOQAgFCU1pRZ2l0aHViLmNvbS9vcGVuc3RhdHVzaHEvb3BlbnN0YXR1cy9wYWNrYWdlcy9wcm90by9vcGVuc3RhdHVzL21vbml0b3IvdjE7bW9uaXRvcnYxYgZwcm90bzM", [file_buf_validate_validate, file_gnostic_openapi_v3_annotations, file_openstatus_monitor_v1_dns_monitor, file_openstatus_monitor_v1_http_monitor, file_openstatus_monitor_v1_monitor, file_openstatus_monitor_v1_tcp_monitor]); + fileDesc("CiNvcGVuc3RhdHVzL21vbml0b3IvdjEvc2VydmljZS5wcm90bxIVb3BlbnN0YXR1cy5tb25pdG9yLnYxIlcKGENyZWF0ZUhUVFBNb25pdG9yUmVxdWVzdBI7Cgdtb25pdG9yGAEgASgLMiIub3BlbnN0YXR1cy5tb25pdG9yLnYxLkhUVFBNb25pdG9yQga6SAPIAQEiUAoZQ3JlYXRlSFRUUE1vbml0b3JSZXNwb25zZRIzCgdtb25pdG9yGAEgASgLMiIub3BlbnN0YXR1cy5tb25pdG9yLnYxLkhUVFBNb25pdG9yIlUKF0NyZWF0ZVRDUE1vbml0b3JSZXF1ZXN0EjoKB21vbml0b3IYASABKAsyIS5vcGVuc3RhdHVzLm1vbml0b3IudjEuVENQTW9uaXRvckIGukgDyAEBIk4KGENyZWF0ZVRDUE1vbml0b3JSZXNwb25zZRIyCgdtb25pdG9yGAEgASgLMiEub3BlbnN0YXR1cy5tb25pdG9yLnYxLlRDUE1vbml0b3IiVQoXQ3JlYXRlRE5TTW9uaXRvclJlcXVlc3QSOgoHbW9uaXRvchgBIAEoCzIhLm9wZW5zdGF0dXMubW9uaXRvci52MS5ETlNNb25pdG9yQga6SAPIAQEiTgoYQ3JlYXRlRE5TTW9uaXRvclJlc3BvbnNlEjIKB21vbml0b3IYASABKAsyIS5vcGVuc3RhdHVzLm1vbml0b3IudjEuRE5TTW9uaXRvciJXChhDcmVhdGVJQ01QTW9uaXRvclJlcXVlc3QSOwoHbW9uaXRvchgBIAEoCzIiLm9wZW5zdGF0dXMubW9uaXRvci52MS5JQ01QTW9uaXRvckIGukgDyAEBIlAKGUNyZWF0ZUlDTVBNb25pdG9yUmVzcG9uc2USMwoHbW9uaXRvchgBIAEoCzIiLm9wZW5zdGF0dXMubW9uaXRvci52MS5JQ01QTW9uaXRvciJ1ChhVcGRhdGVIVFRQTW9uaXRvclJlcXVlc3QSEwoCaWQYASABKAlCB7pIBHICEAESOAoHbW9uaXRvchgCIAEoCzIiLm9wZW5zdGF0dXMubW9uaXRvci52MS5IVFRQTW9uaXRvckgAiAEBQgoKCF9tb25pdG9yIlAKGVVwZGF0ZUhUVFBNb25pdG9yUmVzcG9uc2USMwoHbW9uaXRvchgBIAEoCzIiLm9wZW5zdGF0dXMubW9uaXRvci52MS5IVFRQTW9uaXRvciJzChdVcGRhdGVUQ1BNb25pdG9yUmVxdWVzdBITCgJpZBgBIAEoCUIHukgEcgIQARI3Cgdtb25pdG9yGAIgASgLMiEub3BlbnN0YXR1cy5tb25pdG9yLnYxLlRDUE1vbml0b3JIAIgBAUIKCghfbW9uaXRvciJOChhVcGRhdGVUQ1BNb25pdG9yUmVzcG9uc2USMgoHbW9uaXRvchgBIAEoCzIhLm9wZW5zdGF0dXMubW9uaXRvci52MS5UQ1BNb25pdG9yInMKF1VwZGF0ZUROU01vbml0b3JSZXF1ZXN0EhMKAmlkGAEgASgJQge6SARyAhABEjcKB21vbml0b3IYAiABKAsyIS5vcGVuc3RhdHVzLm1vbml0b3IudjEuRE5TTW9uaXRvckgAiAEBQgoKCF9tb25pdG9yIk4KGFVwZGF0ZUROU01vbml0b3JSZXNwb25zZRIyCgdtb25pdG9yGAEgASgLMiEub3BlbnN0YXR1cy5tb25pdG9yLnYxLkROU01vbml0b3IidQoYVXBkYXRlSUNNUE1vbml0b3JSZXF1ZXN0EhMKAmlkGAEgASgJQge6SARyAhABEjgKB21vbml0b3IYAiABKAsyIi5vcGVuc3RhdHVzLm1vbml0b3IudjEuSUNNUE1vbml0b3JIAIgBAUIKCghfbW9uaXRvciJQChlVcGRhdGVJQ01QTW9uaXRvclJlc3BvbnNlEjMKB21vbml0b3IYASABKAsyIi5vcGVuc3RhdHVzLm1vbml0b3IudjEuSUNNUE1vbml0b3IiLAoVVHJpZ2dlck1vbml0b3JSZXF1ZXN0EhMKAmlkGAEgASgJQge6SARyAhABIikKFlRyaWdnZXJNb25pdG9yUmVzcG9uc2USDwoHc3VjY2VzcxgBIAEoCCIrChREZWxldGVNb25pdG9yUmVxdWVzdBITCgJpZBgBIAEoCUIHukgEcgIQASIoChVEZWxldGVNb25pdG9yUmVzcG9uc2USDwoHc3VjY2VzcxgBIAEoCCJnChNMaXN0TW9uaXRvcnNSZXF1ZXN0Eh0KBWxpbWl0GAEgASgFQgm6SAYaBBhkKAFIAIgBARIcCgZvZmZzZXQYAiABKAVCB7pIBBoCKABIAYgBAUIICgZfbGltaXRCCQoHX29mZnNldCKSAgoUTGlzdE1vbml0b3JzUmVzcG9uc2USOQoNaHR0cF9tb25pdG9ycxgBIAMoCzIiLm9wZW5zdGF0dXMubW9uaXRvci52MS5IVFRQTW9uaXRvchI3Cgx0Y3BfbW9uaXRvcnMYAiADKAsyIS5vcGVuc3RhdHVzLm1vbml0b3IudjEuVENQTW9uaXRvchI3CgxkbnNfbW9uaXRvcnMYAyADKAsyIS5vcGVuc3RhdHVzLm1vbml0b3IudjEuRE5TTW9uaXRvchI5Cg1pY21wX21vbml0b3JzGAUgAygLMiIub3BlbnN0YXR1cy5tb25pdG9yLnYxLklDTVBNb25pdG9yEhIKCnRvdGFsX3NpemUYBCABKAUiLgoXR2V0TW9uaXRvclN0YXR1c1JlcXVlc3QSEwoCaWQYASABKAlCB7pIBHICEAEicwoMUmVnaW9uU3RhdHVzEi0KBnJlZ2lvbhgBIAEoDjIdLm9wZW5zdGF0dXMubW9uaXRvci52MS5SZWdpb24SNAoGc3RhdHVzGAIgASgOMiQub3BlbnN0YXR1cy5tb25pdG9yLnYxLk1vbml0b3JTdGF0dXMiXAoYR2V0TW9uaXRvclN0YXR1c1Jlc3BvbnNlEgoKAmlkGAEgASgJEjQKB3JlZ2lvbnMYAiADKAsyIy5vcGVuc3RhdHVzLm1vbml0b3IudjEuUmVnaW9uU3RhdHVzIuUBCg1Nb25pdG9yQ29uZmlnEjIKBGh0dHAYASABKAsyIi5vcGVuc3RhdHVzLm1vbml0b3IudjEuSFRUUE1vbml0b3JIABIwCgN0Y3AYAiABKAsyIS5vcGVuc3RhdHVzLm1vbml0b3IudjEuVENQTW9uaXRvckgAEjAKA2RucxgDIAEoCzIhLm9wZW5zdGF0dXMubW9uaXRvci52MS5ETlNNb25pdG9ySAASMgoEaWNtcBgEIAEoCzIiLm9wZW5zdGF0dXMubW9uaXRvci52MS5JQ01QTW9uaXRvckgAQggKBmNvbmZpZyKfAQoYR2V0TW9uaXRvclN1bW1hcnlSZXF1ZXN0EhMKAmlkGAEgASgJQge6SARyAhABEjQKCnRpbWVfcmFuZ2UYAiABKA4yIC5vcGVuc3RhdHVzLm1vbml0b3IudjEuVGltZVJhbmdlEjgKB3JlZ2lvbnMYAyADKA4yHS5vcGVuc3RhdHVzLm1vbml0b3IudjEuUmVnaW9uQgi6SAWSAQIQHCKsAgoZR2V0TW9uaXRvclN1bW1hcnlSZXNwb25zZRIKCgJpZBgBIAEoCRIUCgxsYXN0X3BpbmdfYXQYAiABKAkSGAoQdG90YWxfc3VjY2Vzc2Z1bBgDIAEoAxIWCg50b3RhbF9kZWdyYWRlZBgEIAEoAxIUCgx0b3RhbF9mYWlsZWQYBSABKAMSCwoDcDUwGAYgASgDEgsKA3A3NRgHIAEoAxILCgNwOTAYCCABKAMSCwoDcDk1GAkgASgDEgsKA3A5ORgKIAEoAxI0Cgp0aW1lX3JhbmdlGAsgASgOMiAub3BlbnN0YXR1cy5tb25pdG9yLnYxLlRpbWVSYW5nZRIuCgdyZWdpb25zGAwgAygOMh0ub3BlbnN0YXR1cy5tb25pdG9yLnYxLlJlZ2lvbiIoChFHZXRNb25pdG9yUmVxdWVzdBITCgJpZBgBIAEoCUIHukgEcgIQASJLChJHZXRNb25pdG9yUmVzcG9uc2USNQoHbW9uaXRvchgBIAEoCzIkLm9wZW5zdGF0dXMubW9uaXRvci52MS5Nb25pdG9yQ29uZmlnImIKFUhUVFBSZXNwb25zZUxvZ1RpbWluZxILCgNkbnMYASABKAUSDwoHY29ubmVjdBgCIAEoBRILCgN0bHMYAyABKAUSDAoEdHRmYhgEIAEoBRIQCgh0cmFuc2ZlchgFIAEoBSK1AwoXSFRUUFJlc3BvbnNlTG9nTGlzdEl0ZW0SDwoCaWQYASABKAlIAIgBARIPCgdsYXRlbmN5GAIgASgFEhgKC3N0YXR1c19jb2RlGAMgASgFSAGIAQESEgoKbW9uaXRvcl9pZBgEIAEoCRJLCg5yZXF1ZXN0X3N0YXR1cxgFIAEoDjIzLm9wZW5zdGF0dXMubW9uaXRvci52MS5IVFRQUmVzcG9uc2VMb2dSZXF1ZXN0U3RhdHVzEi0KBnJlZ2lvbhgGIAEoDjIdLm9wZW5zdGF0dXMubW9uaXRvci52MS5SZWdpb24SFgoOY3Jvbl90aW1lc3RhbXAYByABKAMSPgoHdHJpZ2dlchgIIAEoDjItLm9wZW5zdGF0dXMubW9uaXRvci52MS5IVFRQUmVzcG9uc2VMb2dUcmlnZ2VyEhEKCXRpbWVzdGFtcBgJIAEoAxJBCgZ0aW1pbmcYCiABKAsyLC5vcGVuc3RhdHVzLm1vbml0b3IudjEuSFRUUFJlc3BvbnNlTG9nVGltaW5nSAKIAQFCBQoDX2lkQg4KDF9zdGF0dXNfY29kZUIJCgdfdGltaW5nIrYCChVIVFRQUmVzcG9uc2VMb2dEZXRhaWwSOwoDbG9nGAEgASgLMi4ub3BlbnN0YXR1cy5tb25pdG9yLnYxLkhUVFBSZXNwb25zZUxvZ0xpc3RJdGVtEgsKA3VybBgCIAEoCRINCgVlcnJvchgDIAEoCBIUCgdtZXNzYWdlGAQgASgJSACIAQESSgoHaGVhZGVycxgFIAMoCzI5Lm9wZW5zdGF0dXMubW9uaXRvci52MS5IVFRQUmVzcG9uc2VMb2dEZXRhaWwuSGVhZGVyc0VudHJ5EhcKCmFzc2VydGlvbnMYBiABKAlIAYgBARouCgxIZWFkZXJzRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4AUIKCghfbWVzc2FnZUINCgtfYXNzZXJ0aW9ucyLnAQoiTGlzdE1vbml0b3JIVFRQUmVzcG9uc2VMb2dzUmVxdWVzdBITCgJpZBgBIAEoCUIHukgEcgIQARIbCg5mcm9tX3RpbWVzdGFtcBgCIAEoA0gAiAEBEhkKDHRvX3RpbWVzdGFtcBgDIAEoA0gBiAEBEh0KBWxpbWl0GAQgASgFQgm6SAYaBBhkKAFIAogBARIcCgZvZmZzZXQYBSABKAVCB7pIBBoCKABIA4gBAUIRCg9fZnJvbV90aW1lc3RhbXBCDwoNX3RvX3RpbWVzdGFtcEIICgZfbGltaXRCCQoHX29mZnNldCJ2ChlIVFRQUmVzcG9uc2VMb2dQYWdpbmF0aW9uEg0KBWxpbWl0GAEgASgFEg4KBm9mZnNldBgCIAEoBRIQCghoYXNfbW9yZRgDIAEoCBIYCgtuZXh0X29mZnNldBgEIAEoBUgAiAEBQg4KDF9uZXh0X29mZnNldCKpAQojTGlzdE1vbml0b3JIVFRQUmVzcG9uc2VMb2dzUmVzcG9uc2USPAoEbG9ncxgBIAMoCzIuLm9wZW5zdGF0dXMubW9uaXRvci52MS5IVFRQUmVzcG9uc2VMb2dMaXN0SXRlbRJECgpwYWdpbmF0aW9uGAIgASgLMjAub3BlbnN0YXR1cy5tb25pdG9yLnYxLkhUVFBSZXNwb25zZUxvZ1BhZ2luYXRpb24iUAogR2V0TW9uaXRvckhUVFBSZXNwb25zZUxvZ1JlcXVlc3QSEwoCaWQYASABKAlCB7pIBHICEAESFwoGbG9nX2lkGAIgASgJQge6SARyAhABIl4KIUdldE1vbml0b3JIVFRQUmVzcG9uc2VMb2dSZXNwb25zZRI5CgNsb2cYASABKAsyLC5vcGVuc3RhdHVzLm1vbml0b3IudjEuSFRUUFJlc3BvbnNlTG9nRGV0YWlsKmEKCVRpbWVSYW5nZRIaChZUSU1FX1JBTkdFX1VOU1BFQ0lGSUVEEAASEQoNVElNRV9SQU5HRV8xRBABEhEKDVRJTUVfUkFOR0VfN0QQAhISCg5USU1FX1JBTkdFXzE0RBADKtkBChxIVFRQUmVzcG9uc2VMb2dSZXF1ZXN0U3RhdHVzEjAKLEhUVFBfUkVTUE9OU0VfTE9HX1JFUVVFU1RfU1RBVFVTX1VOU1BFQ0lGSUVEEAASLAooSFRUUF9SRVNQT05TRV9MT0dfUkVRVUVTVF9TVEFUVVNfU1VDQ0VTUxABEioKJkhUVFBfUkVTUE9OU0VfTE9HX1JFUVVFU1RfU1RBVFVTX0VSUk9SEAISLQopSFRUUF9SRVNQT05TRV9MT0dfUkVRVUVTVF9TVEFUVVNfREVHUkFERUQQAyqKAQoWSFRUUFJlc3BvbnNlTG9nVHJpZ2dlchIpCiVIVFRQX1JFU1BPTlNFX0xPR19UUklHR0VSX1VOU1BFQ0lGSUVEEAASIgoeSFRUUF9SRVNQT05TRV9MT0dfVFJJR0dFUl9DUk9OEAESIQodSFRUUF9SRVNQT05TRV9MT0dfVFJJR0dFUl9BUEkQAjKLFgoOTW9uaXRvclNlcnZpY2USuQMKEUNyZWF0ZUhUVFBNb25pdG9yEi8ub3BlbnN0YXR1cy5tb25pdG9yLnYxLkNyZWF0ZUhUVFBNb25pdG9yUmVxdWVzdBowLm9wZW5zdGF0dXMubW9uaXRvci52MS5DcmVhdGVIVFRQTW9uaXRvclJlc3BvbnNlIsACuke8Ahq5AkNyZWF0ZXMgYSBuZXcgSFRUUCBtb25pdG9yIGluIHRoZSBhdXRoZW50aWNhdGVkIHdvcmtzcGFjZS4gQ29uZmlndXJlIHRoZSB0YXJnZXQgVVJMLCBIVFRQIG1ldGhvZCwgcmVxdWVzdCBoZWFkZXJzIGFuZCBib2R5LCByZXNwb25zZSBhc3NlcnRpb25zIChzdGF0dXMgY29kZSwgYm9keSBjb250ZW50LCBoZWFkZXJzKSwgY2hlY2sgcGVyaW9kaWNpdHksIGdlb2dyYXBoaWMgcmVnaW9ucywgYW5kIG9wdGlvbmFsIE9wZW5UZWxlbWV0cnkgZXhwb3J0LiBUaGUgbW9uaXRvciBzdGFydHMgY2hlY2tpbmcgaW1tZWRpYXRlbHkgaWYgc2V0IHRvIGFjdGl2ZS4ScwoQQ3JlYXRlVENQTW9uaXRvchIuLm9wZW5zdGF0dXMubW9uaXRvci52MS5DcmVhdGVUQ1BNb25pdG9yUmVxdWVzdBovLm9wZW5zdGF0dXMubW9uaXRvci52MS5DcmVhdGVUQ1BNb25pdG9yUmVzcG9uc2UScwoQQ3JlYXRlRE5TTW9uaXRvchIuLm9wZW5zdGF0dXMubW9uaXRvci52MS5DcmVhdGVETlNNb25pdG9yUmVxdWVzdBovLm9wZW5zdGF0dXMubW9uaXRvci52MS5DcmVhdGVETlNNb25pdG9yUmVzcG9uc2USdgoRQ3JlYXRlSUNNUE1vbml0b3ISLy5vcGVuc3RhdHVzLm1vbml0b3IudjEuQ3JlYXRlSUNNUE1vbml0b3JSZXF1ZXN0GjAub3BlbnN0YXR1cy5tb25pdG9yLnYxLkNyZWF0ZUlDTVBNb25pdG9yUmVzcG9uc2USdgoRVXBkYXRlSFRUUE1vbml0b3ISLy5vcGVuc3RhdHVzLm1vbml0b3IudjEuVXBkYXRlSFRUUE1vbml0b3JSZXF1ZXN0GjAub3BlbnN0YXR1cy5tb25pdG9yLnYxLlVwZGF0ZUhUVFBNb25pdG9yUmVzcG9uc2UScwoQVXBkYXRlVENQTW9uaXRvchIuLm9wZW5zdGF0dXMubW9uaXRvci52MS5VcGRhdGVUQ1BNb25pdG9yUmVxdWVzdBovLm9wZW5zdGF0dXMubW9uaXRvci52MS5VcGRhdGVUQ1BNb25pdG9yUmVzcG9uc2UScwoQVXBkYXRlRE5TTW9uaXRvchIuLm9wZW5zdGF0dXMubW9uaXRvci52MS5VcGRhdGVETlNNb25pdG9yUmVxdWVzdBovLm9wZW5zdGF0dXMubW9uaXRvci52MS5VcGRhdGVETlNNb25pdG9yUmVzcG9uc2USdgoRVXBkYXRlSUNNUE1vbml0b3ISLy5vcGVuc3RhdHVzLm1vbml0b3IudjEuVXBkYXRlSUNNUE1vbml0b3JSZXF1ZXN0GjAub3BlbnN0YXR1cy5tb25pdG9yLnYxLlVwZGF0ZUlDTVBNb25pdG9yUmVzcG9uc2US6QIKDlRyaWdnZXJNb25pdG9yEiwub3BlbnN0YXR1cy5tb25pdG9yLnYxLlRyaWdnZXJNb25pdG9yUmVxdWVzdBotLm9wZW5zdGF0dXMubW9uaXRvci52MS5UcmlnZ2VyTW9uaXRvclJlc3BvbnNlIvkBukf1ARryAU1hbnVhbGx5IHRyaWdnZXJzIGFuIGltbWVkaWF0ZSBjaGVjayBmb3IgdGhlIHNwZWNpZmllZCBtb25pdG9yIGFjcm9zcyBhbGwgY29uZmlndXJlZCByZWdpb25zLiBUaGlzIG9wZXJhdGlvbiBpcyByYXRlLWxpbWl0ZWQgdW5kZXIgdGhlIHN5bnRoZXRpYy1jaGVja3MgcXVvdGEuIEEgbW9uaXRvciBydW4gcmVjb3JkIGlzIGNyZWF0ZWQgYW5kIHRoZSBjaGVjayBpcyBkaXNwYXRjaGVkIHRvIHRoZSBjaGVja2VyIHNlcnZpY2UuEmoKDURlbGV0ZU1vbml0b3ISKy5vcGVuc3RhdHVzLm1vbml0b3IudjEuRGVsZXRlTW9uaXRvclJlcXVlc3QaLC5vcGVuc3RhdHVzLm1vbml0b3IudjEuRGVsZXRlTW9uaXRvclJlc3BvbnNlEmwKDExpc3RNb25pdG9ycxIqLm9wZW5zdGF0dXMubW9uaXRvci52MS5MaXN0TW9uaXRvcnNSZXF1ZXN0Gisub3BlbnN0YXR1cy5tb25pdG9yLnYxLkxpc3RNb25pdG9yc1Jlc3BvbnNlIgOQAgESeAoQR2V0TW9uaXRvclN0YXR1cxIuLm9wZW5zdGF0dXMubW9uaXRvci52MS5HZXRNb25pdG9yU3RhdHVzUmVxdWVzdBovLm9wZW5zdGF0dXMubW9uaXRvci52MS5HZXRNb25pdG9yU3RhdHVzUmVzcG9uc2UiA5ACARKmAwoRR2V0TW9uaXRvclN1bW1hcnkSLy5vcGVuc3RhdHVzLm1vbml0b3IudjEuR2V0TW9uaXRvclN1bW1hcnlSZXF1ZXN0GjAub3BlbnN0YXR1cy5tb25pdG9yLnYxLkdldE1vbml0b3JTdW1tYXJ5UmVzcG9uc2UirQKQAgG6R6YCGqMCUmV0dXJucyBhZ2dyZWdhdGVkIG1ldHJpY3MgZm9yIGEgbW9uaXRvciBpbmNsdWRpbmcgbGF0ZW5jeSBwZXJjZW50aWxlcyAocDUwLCBwNzUsIHA5MCwgcDk1LCBwOTkpLCByZXF1ZXN0IGNvdW50cyBieSBzdGF0dXMgKHN1Y2Nlc3NmdWwsIGRlZ3JhZGVkLCBmYWlsZWQpLCBhbmQgdGhlIHRpbWVzdGFtcCBvZiB0aGUgbGFzdCBjaGVjay4gTWV0cmljcyBjYW4gYmUgc2NvcGVkIHRvIGEgdGltZSByYW5nZSAoMSBkYXksIDcgZGF5cywgb3IgMTQgZGF5cykgYW5kIGZpbHRlcmVkIGJ5IHNwZWNpZmljIHJlZ2lvbnMuEmYKCkdldE1vbml0b3ISKC5vcGVuc3RhdHVzLm1vbml0b3IudjEuR2V0TW9uaXRvclJlcXVlc3QaKS5vcGVuc3RhdHVzLm1vbml0b3IudjEuR2V0TW9uaXRvclJlc3BvbnNlIgOQAgESmQEKG0xpc3RNb25pdG9ySFRUUFJlc3BvbnNlTG9ncxI5Lm9wZW5zdGF0dXMubW9uaXRvci52MS5MaXN0TW9uaXRvckhUVFBSZXNwb25zZUxvZ3NSZXF1ZXN0Gjoub3BlbnN0YXR1cy5tb25pdG9yLnYxLkxpc3RNb25pdG9ySFRUUFJlc3BvbnNlTG9nc1Jlc3BvbnNlIgOQAgESkwEKGUdldE1vbml0b3JIVFRQUmVzcG9uc2VMb2cSNy5vcGVuc3RhdHVzLm1vbml0b3IudjEuR2V0TW9uaXRvckhUVFBSZXNwb25zZUxvZ1JlcXVlc3QaOC5vcGVuc3RhdHVzLm1vbml0b3IudjEuR2V0TW9uaXRvckhUVFBSZXNwb25zZUxvZ1Jlc3BvbnNlIgOQAgFCU1pRZ2l0aHViLmNvbS9vcGVuc3RhdHVzaHEvb3BlbnN0YXR1cy9wYWNrYWdlcy9wcm90by9vcGVuc3RhdHVzL21vbml0b3IvdjE7bW9uaXRvcnYxYgZwcm90bzM", [file_buf_validate_validate, file_gnostic_openapi_v3_annotations, file_openstatus_monitor_v1_dns_monitor, file_openstatus_monitor_v1_http_monitor, file_openstatus_monitor_v1_icmp_monitor, file_openstatus_monitor_v1_monitor, file_openstatus_monitor_v1_tcp_monitor]); /** * CreateHTTPMonitorRequest is the request to create a new HTTP monitor. @@ -148,6 +150,48 @@ export type CreateDNSMonitorResponse = Message<"openstatus.monitor.v1.CreateDNSM export const CreateDNSMonitorResponseSchema: GenMessage = /*@__PURE__*/ messageDesc(file_openstatus_monitor_v1_service, 5); +/** + * CreateICMPMonitorRequest is the request to create a new ICMP monitor. + * + * @generated from message openstatus.monitor.v1.CreateICMPMonitorRequest + */ +export type CreateICMPMonitorRequest = Message<"openstatus.monitor.v1.CreateICMPMonitorRequest"> & { + /** + * Monitor configuration (required). + * + * @generated from field: openstatus.monitor.v1.ICMPMonitor monitor = 1; + */ + monitor?: ICMPMonitor | undefined; +}; + +/** + * Describes the message openstatus.monitor.v1.CreateICMPMonitorRequest. + * Use `create(CreateICMPMonitorRequestSchema)` to create a new message. + */ +export const CreateICMPMonitorRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_openstatus_monitor_v1_service, 6); + +/** + * CreateICMPMonitorResponse is the response after creating an ICMP monitor. + * + * @generated from message openstatus.monitor.v1.CreateICMPMonitorResponse + */ +export type CreateICMPMonitorResponse = Message<"openstatus.monitor.v1.CreateICMPMonitorResponse"> & { + /** + * The created monitor with assigned ID. + * + * @generated from field: openstatus.monitor.v1.ICMPMonitor monitor = 1; + */ + monitor?: ICMPMonitor | undefined; +}; + +/** + * Describes the message openstatus.monitor.v1.CreateICMPMonitorResponse. + * Use `create(CreateICMPMonitorResponseSchema)` to create a new message. + */ +export const CreateICMPMonitorResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_openstatus_monitor_v1_service, 7); + /** * UpdateHTTPMonitorRequest is the request to update an existing HTTP monitor. * @@ -174,7 +218,7 @@ export type UpdateHTTPMonitorRequest = Message<"openstatus.monitor.v1.UpdateHTTP * Use `create(UpdateHTTPMonitorRequestSchema)` to create a new message. */ export const UpdateHTTPMonitorRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 6); + messageDesc(file_openstatus_monitor_v1_service, 8); /** * UpdateHTTPMonitorResponse is the response after updating an HTTP monitor. @@ -195,7 +239,7 @@ export type UpdateHTTPMonitorResponse = Message<"openstatus.monitor.v1.UpdateHTT * Use `create(UpdateHTTPMonitorResponseSchema)` to create a new message. */ export const UpdateHTTPMonitorResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 7); + messageDesc(file_openstatus_monitor_v1_service, 9); /** * UpdateTCPMonitorRequest is the request to update an existing TCP monitor. @@ -223,7 +267,7 @@ export type UpdateTCPMonitorRequest = Message<"openstatus.monitor.v1.UpdateTCPMo * Use `create(UpdateTCPMonitorRequestSchema)` to create a new message. */ export const UpdateTCPMonitorRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 8); + messageDesc(file_openstatus_monitor_v1_service, 10); /** * UpdateTCPMonitorResponse is the response after updating a TCP monitor. @@ -244,7 +288,7 @@ export type UpdateTCPMonitorResponse = Message<"openstatus.monitor.v1.UpdateTCPM * Use `create(UpdateTCPMonitorResponseSchema)` to create a new message. */ export const UpdateTCPMonitorResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 9); + messageDesc(file_openstatus_monitor_v1_service, 11); /** * UpdateDNSMonitorRequest is the request to update an existing DNS monitor. @@ -272,7 +316,7 @@ export type UpdateDNSMonitorRequest = Message<"openstatus.monitor.v1.UpdateDNSMo * Use `create(UpdateDNSMonitorRequestSchema)` to create a new message. */ export const UpdateDNSMonitorRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 10); + messageDesc(file_openstatus_monitor_v1_service, 12); /** * UpdateDNSMonitorResponse is the response after updating a DNS monitor. @@ -293,7 +337,56 @@ export type UpdateDNSMonitorResponse = Message<"openstatus.monitor.v1.UpdateDNSM * Use `create(UpdateDNSMonitorResponseSchema)` to create a new message. */ export const UpdateDNSMonitorResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 11); + messageDesc(file_openstatus_monitor_v1_service, 13); + +/** + * UpdateICMPMonitorRequest is the request to update an existing ICMP monitor. + * + * @generated from message openstatus.monitor.v1.UpdateICMPMonitorRequest + */ +export type UpdateICMPMonitorRequest = Message<"openstatus.monitor.v1.UpdateICMPMonitorRequest"> & { + /** + * Monitor ID to update (required). + * + * @generated from field: string id = 1; + */ + id: string; + + /** + * Updated monitor configuration (all fields optional for partial updates). + * + * @generated from field: optional openstatus.monitor.v1.ICMPMonitor monitor = 2; + */ + monitor?: ICMPMonitor | undefined; +}; + +/** + * Describes the message openstatus.monitor.v1.UpdateICMPMonitorRequest. + * Use `create(UpdateICMPMonitorRequestSchema)` to create a new message. + */ +export const UpdateICMPMonitorRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_openstatus_monitor_v1_service, 14); + +/** + * UpdateICMPMonitorResponse is the response after updating an ICMP monitor. + * + * @generated from message openstatus.monitor.v1.UpdateICMPMonitorResponse + */ +export type UpdateICMPMonitorResponse = Message<"openstatus.monitor.v1.UpdateICMPMonitorResponse"> & { + /** + * The updated monitor. + * + * @generated from field: openstatus.monitor.v1.ICMPMonitor monitor = 1; + */ + monitor?: ICMPMonitor | undefined; +}; + +/** + * Describes the message openstatus.monitor.v1.UpdateICMPMonitorResponse. + * Use `create(UpdateICMPMonitorResponseSchema)` to create a new message. + */ +export const UpdateICMPMonitorResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_openstatus_monitor_v1_service, 15); /** * TriggerMonitorRequest is the request to trigger a monitor check. @@ -314,7 +407,7 @@ export type TriggerMonitorRequest = Message<"openstatus.monitor.v1.TriggerMonito * Use `create(TriggerMonitorRequestSchema)` to create a new message. */ export const TriggerMonitorRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 12); + messageDesc(file_openstatus_monitor_v1_service, 16); /** * TriggerMonitorResponse is the response after triggering a monitor. @@ -335,7 +428,7 @@ export type TriggerMonitorResponse = Message<"openstatus.monitor.v1.TriggerMonit * Use `create(TriggerMonitorResponseSchema)` to create a new message. */ export const TriggerMonitorResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 13); + messageDesc(file_openstatus_monitor_v1_service, 17); /** * DeleteMonitorRequest is the request to delete a monitor. @@ -356,7 +449,7 @@ export type DeleteMonitorRequest = Message<"openstatus.monitor.v1.DeleteMonitorR * Use `create(DeleteMonitorRequestSchema)` to create a new message. */ export const DeleteMonitorRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 14); + messageDesc(file_openstatus_monitor_v1_service, 18); /** * DeleteMonitorResponse is the response after deleting a monitor. @@ -377,7 +470,7 @@ export type DeleteMonitorResponse = Message<"openstatus.monitor.v1.DeleteMonitor * Use `create(DeleteMonitorResponseSchema)` to create a new message. */ export const DeleteMonitorResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 15); + messageDesc(file_openstatus_monitor_v1_service, 19); /** * ListMonitorsRequest is the request to list monitors. @@ -405,7 +498,7 @@ export type ListMonitorsRequest = Message<"openstatus.monitor.v1.ListMonitorsReq * Use `create(ListMonitorsRequestSchema)` to create a new message. */ export const ListMonitorsRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 16); + messageDesc(file_openstatus_monitor_v1_service, 20); /** * ListMonitorsResponse is the response containing a list of monitors. @@ -434,6 +527,13 @@ export type ListMonitorsResponse = Message<"openstatus.monitor.v1.ListMonitorsRe */ dnsMonitors: DNSMonitor[]; + /** + * ICMP monitors in the workspace. + * + * @generated from field: repeated openstatus.monitor.v1.ICMPMonitor icmp_monitors = 5; + */ + icmpMonitors: ICMPMonitor[]; + /** * Total number of monitors across all types. * @@ -447,7 +547,7 @@ export type ListMonitorsResponse = Message<"openstatus.monitor.v1.ListMonitorsRe * Use `create(ListMonitorsResponseSchema)` to create a new message. */ export const ListMonitorsResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 17); + messageDesc(file_openstatus_monitor_v1_service, 21); /** * GetMonitorStatusRequest is the request to get the status of all regions for a monitor. @@ -468,7 +568,7 @@ export type GetMonitorStatusRequest = Message<"openstatus.monitor.v1.GetMonitorS * Use `create(GetMonitorStatusRequestSchema)` to create a new message. */ export const GetMonitorStatusRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 18); + messageDesc(file_openstatus_monitor_v1_service, 22); /** * RegionStatus represents the status of a monitor in a specific region. @@ -496,7 +596,7 @@ export type RegionStatus = Message<"openstatus.monitor.v1.RegionStatus"> & { * Use `create(RegionStatusSchema)` to create a new message. */ export const RegionStatusSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 19); + messageDesc(file_openstatus_monitor_v1_service, 23); /** * GetMonitorStatusResponse is the response containing the status of all regions for a monitor. @@ -524,7 +624,7 @@ export type GetMonitorStatusResponse = Message<"openstatus.monitor.v1.GetMonitor * Use `create(GetMonitorStatusResponseSchema)` to create a new message. */ export const GetMonitorStatusResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 20); + messageDesc(file_openstatus_monitor_v1_service, 24); /** * MonitorConfig represents the type-specific configuration for a monitor. @@ -559,6 +659,14 @@ export type MonitorConfig = Message<"openstatus.monitor.v1.MonitorConfig"> & { */ value: DNSMonitor; case: "dns"; + } | { + /** + * ICMP monitor configuration. + * + * @generated from field: openstatus.monitor.v1.ICMPMonitor icmp = 4; + */ + value: ICMPMonitor; + case: "icmp"; } | { case: undefined; value?: undefined }; }; @@ -567,7 +675,7 @@ export type MonitorConfig = Message<"openstatus.monitor.v1.MonitorConfig"> & { * Use `create(MonitorConfigSchema)` to create a new message. */ export const MonitorConfigSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 21); + messageDesc(file_openstatus_monitor_v1_service, 25); /** * GetMonitorSummaryRequest is the request to get aggregated metrics for a monitor. @@ -602,7 +710,7 @@ export type GetMonitorSummaryRequest = Message<"openstatus.monitor.v1.GetMonitor * Use `create(GetMonitorSummaryRequestSchema)` to create a new message. */ export const GetMonitorSummaryRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 22); + messageDesc(file_openstatus_monitor_v1_service, 26); /** * GetMonitorSummaryResponse is the response containing aggregated metrics for a monitor. @@ -700,7 +808,7 @@ export type GetMonitorSummaryResponse = Message<"openstatus.monitor.v1.GetMonito * Use `create(GetMonitorSummaryResponseSchema)` to create a new message. */ export const GetMonitorSummaryResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 23); + messageDesc(file_openstatus_monitor_v1_service, 27); /** * GetMonitorRequest is the request to get a single monitor by ID. @@ -721,7 +829,7 @@ export type GetMonitorRequest = Message<"openstatus.monitor.v1.GetMonitorRequest * Use `create(GetMonitorRequestSchema)` to create a new message. */ export const GetMonitorRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 24); + messageDesc(file_openstatus_monitor_v1_service, 28); /** * GetMonitorResponse is the response containing the monitor. @@ -730,7 +838,7 @@ export const GetMonitorRequestSchema: GenMessage = /*@__PURE_ */ export type GetMonitorResponse = Message<"openstatus.monitor.v1.GetMonitorResponse"> & { /** - * The monitor configuration (one of HTTP, TCP, or DNS). + * The monitor configuration (one of HTTP, TCP, DNS, or ICMP). * * @generated from field: openstatus.monitor.v1.MonitorConfig monitor = 1; */ @@ -742,7 +850,7 @@ export type GetMonitorResponse = Message<"openstatus.monitor.v1.GetMonitorRespon * Use `create(GetMonitorResponseSchema)` to create a new message. */ export const GetMonitorResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 25); + messageDesc(file_openstatus_monitor_v1_service, 29); /** * HTTPResponseLogTiming contains calculated timing phases in milliseconds. @@ -791,7 +899,7 @@ export type HTTPResponseLogTiming = Message<"openstatus.monitor.v1.HTTPResponseL * Use `create(HTTPResponseLogTimingSchema)` to create a new message. */ export const HTTPResponseLogTimingSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 26); + messageDesc(file_openstatus_monitor_v1_service, 30); /** * HTTPResponseLogListItem is a compact response log entry. @@ -875,7 +983,7 @@ export type HTTPResponseLogListItem = Message<"openstatus.monitor.v1.HTTPRespons * Use `create(HTTPResponseLogListItemSchema)` to create a new message. */ export const HTTPResponseLogListItemSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 27); + messageDesc(file_openstatus_monitor_v1_service, 31); /** * HTTPResponseLogDetail contains full response log debugging data. @@ -931,7 +1039,7 @@ export type HTTPResponseLogDetail = Message<"openstatus.monitor.v1.HTTPResponseL * Use `create(HTTPResponseLogDetailSchema)` to create a new message. */ export const HTTPResponseLogDetailSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 28); + messageDesc(file_openstatus_monitor_v1_service, 32); /** * ListMonitorHTTPResponseLogsRequest is the request to list response logs within the 14-day HTTP response-log window. @@ -980,7 +1088,7 @@ export type ListMonitorHTTPResponseLogsRequest = Message<"openstatus.monitor.v1. * Use `create(ListMonitorHTTPResponseLogsRequestSchema)` to create a new message. */ export const ListMonitorHTTPResponseLogsRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 29); + messageDesc(file_openstatus_monitor_v1_service, 33); /** * HTTPResponseLogPagination contains offset pagination metadata. @@ -1022,7 +1130,7 @@ export type HTTPResponseLogPagination = Message<"openstatus.monitor.v1.HTTPRespo * Use `create(HTTPResponseLogPaginationSchema)` to create a new message. */ export const HTTPResponseLogPaginationSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 30); + messageDesc(file_openstatus_monitor_v1_service, 34); /** * ListMonitorHTTPResponseLogsResponse is the response containing response logs. @@ -1050,7 +1158,7 @@ export type ListMonitorHTTPResponseLogsResponse = Message<"openstatus.monitor.v1 * Use `create(ListMonitorHTTPResponseLogsResponseSchema)` to create a new message. */ export const ListMonitorHTTPResponseLogsResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 31); + messageDesc(file_openstatus_monitor_v1_service, 35); /** * GetMonitorHTTPResponseLogRequest is the request to get one response log. @@ -1078,7 +1186,7 @@ export type GetMonitorHTTPResponseLogRequest = Message<"openstatus.monitor.v1.Ge * Use `create(GetMonitorHTTPResponseLogRequestSchema)` to create a new message. */ export const GetMonitorHTTPResponseLogRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 32); + messageDesc(file_openstatus_monitor_v1_service, 36); /** * GetMonitorHTTPResponseLogResponse is the response containing one response log. @@ -1099,7 +1207,7 @@ export type GetMonitorHTTPResponseLogResponse = Message<"openstatus.monitor.v1.G * Use `create(GetMonitorHTTPResponseLogResponseSchema)` to create a new message. */ export const GetMonitorHTTPResponseLogResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 33); + messageDesc(file_openstatus_monitor_v1_service, 37); /** * TimeRange represents the time period for metrics aggregation. @@ -1253,6 +1361,16 @@ export const MonitorService: GenService<{ input: typeof CreateDNSMonitorRequestSchema; output: typeof CreateDNSMonitorResponseSchema; }, + /** + * CreateICMPMonitor creates a new ICMP monitor. + * + * @generated from rpc openstatus.monitor.v1.MonitorService.CreateICMPMonitor + */ + createICMPMonitor: { + methodKind: "unary"; + input: typeof CreateICMPMonitorRequestSchema; + output: typeof CreateICMPMonitorResponseSchema; + }, /** * UpdateHTTPMonitor updates an existing HTTP monitor. * @@ -1283,6 +1401,16 @@ export const MonitorService: GenService<{ input: typeof UpdateDNSMonitorRequestSchema; output: typeof UpdateDNSMonitorResponseSchema; }, + /** + * UpdateICMPMonitor updates an existing ICMP monitor. + * + * @generated from rpc openstatus.monitor.v1.MonitorService.UpdateICMPMonitor + */ + updateICMPMonitor: { + methodKind: "unary"; + input: typeof UpdateICMPMonitorRequestSchema; + output: typeof UpdateICMPMonitorResponseSchema; + }, /** * TriggerMonitor initiates an immediate check for a monitor across all configured regions. * @@ -1335,7 +1463,7 @@ export const MonitorService: GenService<{ }, /** * GetMonitor returns a single monitor by ID within the authenticated workspace. - * Returns the monitor configuration (HTTP, TCP, or DNS) using the MonitorConfig oneof type. + * Returns the monitor configuration (HTTP, TCP, DNS, or ICMP) using the MonitorConfig oneof type. * * @generated from rpc openstatus.monitor.v1.MonitorService.GetMonitor */ diff --git a/packages/proto/internal/private_location/v1/icmp_monitor.proto b/packages/proto/internal/private_location/v1/icmp_monitor.proto new file mode 100644 index 000000000..38df2a7b9 --- /dev/null +++ b/packages/proto/internal/private_location/v1/icmp_monitor.proto @@ -0,0 +1,22 @@ +syntax = "proto3"; + +package private_location.v1; + +import "private_location/v1/otel.proto"; + +option go_package = "github.com/openstatushq/openstatus/packages/proto/private_location/v1;v1"; + + + + +message ICMPMonitor { + string id = 1; + string uri = 2; + int64 timeout = 3; + optional int64 degraded_at = 4; + string periodicity = 5; + int64 retry = 6; + + OtelConfig otel_config = 20; + +} diff --git a/packages/proto/internal/private_location/v1/private_location.proto b/packages/proto/internal/private_location/v1/private_location.proto index 28ce249c8..b8633c7d9 100644 --- a/packages/proto/internal/private_location/v1/private_location.proto +++ b/packages/proto/internal/private_location/v1/private_location.proto @@ -4,6 +4,7 @@ package private_location.v1; import "private_location/v1/dns_monitor.proto"; import "private_location/v1/http_monitor.proto"; +import "private_location/v1/icmp_monitor.proto"; import "private_location/v1/tcp_monitor.proto"; @@ -14,6 +15,7 @@ service PrivateLocationService { rpc IngestTCP(IngestTCPRequest) returns (IngestTCPResponse) {} rpc IngestHTTP(IngestHTTPRequest) returns (IngestHTTPResponse) {} rpc IngestDNS(IngestDNSRequest) returns (IngestDNSResponse) {} + rpc IngestICMP(IngestICMPRequest) returns (IngestICMPResponse) {} } @@ -23,6 +25,7 @@ message MonitorsResponse { repeated HTTPMonitor http_monitors = 1; repeated TCPMonitor tcp_monitors = 2; repeated DNSMonitor dns_monitors = 3; + repeated ICMPMonitor icmp_monitors = 5; string region = 4; } @@ -85,3 +88,24 @@ message IngestDNSRequest { message IngestDNSResponse { } + +message IngestICMPRequest { + string id = 1; + string monitorId = 2; + int64 latency = 3; + int64 latencyMin = 4; + int64 latencyMax = 5; + int64 packetsSent = 6; + int64 packetsReceived = 7; + int64 timestamp = 8; + int64 cronTimestamp = 9; + string uri = 10; + string message = 11; + string requestStatus = 12; + int64 error = 13; + string timing = 14; +} + +message IngestICMPResponse { + +} diff --git a/packages/services/src/frozen-uptime/__tests__/get-history.test.ts b/packages/services/src/frozen-uptime/__tests__/get-history.test.ts index 4a3f938a5..5335f78ea 100644 --- a/packages/services/src/frozen-uptime/__tests__/get-history.test.ts +++ b/packages/services/src/frozen-uptime/__tests__/get-history.test.ts @@ -77,12 +77,12 @@ function fullMonth( function makePipes(rows: ComputeCountRow[]): UptimeFreezePipes { const pipe = () => Promise.resolve({ data: rows }); - return { http: pipe, tcp: pipe, dns: pipe }; + return { http: pipe, tcp: pipe, dns: pipe, icmp: pipe }; } function failingPipes(): UptimeFreezePipes { const pipe = () => Promise.reject(new Error("tinybird down")); - return { http: pipe, tcp: pipe, dns: pipe }; + return { http: pipe, tcp: pipe, dns: pipe, icmp: pipe }; } type Tx = Parameters[0]>[0]; diff --git a/packages/services/src/frozen-uptime/__tests__/run.test.ts b/packages/services/src/frozen-uptime/__tests__/run.test.ts index a973fb1de..7c6a3df71 100644 --- a/packages/services/src/frozen-uptime/__tests__/run.test.ts +++ b/packages/services/src/frozen-uptime/__tests__/run.test.ts @@ -32,6 +32,7 @@ function makePipes( http: overrides.http ?? fallback, tcp: overrides.tcp ?? fallback, dns: overrides.dns ?? fallback, + icmp: overrides.icmp ?? fallback, }; } @@ -157,7 +158,7 @@ describe("fetchFreezeCounts", () => { const http = okPipe(); const { counts, failedMonitorIds } = await fetchFreezeCounts({ monitorIdsByJobType: new Map([ - ["icmp", new Set(["9"])], + ["udp", new Set(["9"])], ["ssl", new Set(["10"])], ]), pipes: makePipes({ http: http.pipe }), diff --git a/packages/services/src/frozen-uptime/get-history.ts b/packages/services/src/frozen-uptime/get-history.ts index bb4eea2e9..ec1e3fa53 100644 --- a/packages/services/src/frozen-uptime/get-history.ts +++ b/packages/services/src/frozen-uptime/get-history.ts @@ -241,6 +241,7 @@ export async function getUptimeHistory(args: { http: defaultTb.httpStatus45d, tcp: defaultTb.tcpStatus45d, dns: defaultTb.dnsStatus45d, + icmp: defaultTb.icmpStatus45d, }; return fetchFreezeCounts({ monitorIdsByJobType, diff --git a/packages/services/src/frozen-uptime/run.ts b/packages/services/src/frozen-uptime/run.ts index 767ba7905..ba66b6a1a 100644 --- a/packages/services/src/frozen-uptime/run.ts +++ b/packages/services/src/frozen-uptime/run.ts @@ -19,9 +19,12 @@ export type StatusPipeFn = (params: { monitorIds: string[]; }) => Promise<{ data: ComputeCountRow[] }>; -// only these job types have a 45d status pipe; others (icmp/udp/ssl) have no +// only these job types have a 45d status pipe; others (udp/ssl) have no // counts on the live status page either and are skipped -export type UptimeFreezePipes = Record<"http" | "tcp" | "dns", StatusPipeFn>; +export type UptimeFreezePipes = Record< + "http" | "tcp" | "dns" | "icmp", + StatusPipeFn +>; export type ChunkFailure = { jobType: string; @@ -53,7 +56,12 @@ function chunk(items: T[], size: number): T[][] { function hasStatusPipe( jobType: string | null | undefined, ): jobType is keyof UptimeFreezePipes { - return jobType === "http" || jobType === "tcp" || jobType === "dns"; + return ( + jobType === "http" || + jobType === "tcp" || + jobType === "dns" || + jobType === "icmp" + ); } /** diff --git a/packages/services/src/monitor/__tests__/reads.test.ts b/packages/services/src/monitor/__tests__/reads.test.ts index 3c7705116..bc4d24d85 100644 --- a/packages/services/src/monitor/__tests__/reads.test.ts +++ b/packages/services/src/monitor/__tests__/reads.test.ts @@ -146,13 +146,13 @@ describe("getMonitorSummary", () => { }); }); - test("throws ValidationError for unsupported jobType (icmp)", async () => { + test("throws ValidationError for unsupported jobType (udp)", async () => { await withTestTransaction(async (tx) => { const row = await createMonitor({ ctx: { ...teamCtx, db: tx }, input: { - name: `${TEST_PREFIX}-icmp-summary`, - jobType: "icmp", + name: `${TEST_PREFIX}-udp-summary`, + jobType: "udp", url: "1.1.1.1", method: "GET", headers: [], @@ -169,6 +169,29 @@ describe("getMonitorSummary", () => { ).rejects.toBeInstanceOf(ValidationError); }); }); + + test("returns a summary for icmp jobType", async () => { + await withTestTransaction(async (tx) => { + const row = await createMonitor({ + ctx: { ...teamCtx, db: tx }, + input: { + name: `${TEST_PREFIX}-icmp-summary`, + jobType: "icmp", + url: "1.1.1.1", + method: "GET", + headers: [], + assertions: [], + active: false, + regions: ["ams"], + }, + }); + const summary = await getMonitorSummary({ + ctx: { ...teamCtx, db: tx }, + input: { monitorId: row.id, timeRange: "1d" }, + }); + expect(summary.monitorId).toBe(row.id); + }); + }); }); describe("fetchMonitorDailyStats", () => { @@ -176,6 +199,7 @@ describe("fetchMonitorDailyStats", () => { httpStatus45d: () => Promise.resolve({ data: [] }), tcpStatus45d: () => Promise.resolve({ data: [] }), dnsStatus45d: () => Promise.resolve({ data: [] }), + icmpStatus45d: () => Promise.resolve({ data: [] }), } as unknown as NonNullable; test("skips cross-workspace monitorId (returns empty, no throw)", async () => { @@ -215,13 +239,13 @@ describe("fetchMonitorDailyStats", () => { }); }); - test("skips unsupported jobType (icmp)", async () => { + test("skips unsupported jobType (udp)", async () => { await withTestTransaction(async (tx) => { const row = await createMonitor({ ctx: { ...teamCtx, db: tx }, input: { - name: `${TEST_PREFIX}-icmp-daily`, - jobType: "icmp", + name: `${TEST_PREFIX}-udp-daily`, + jobType: "udp", url: "1.1.1.1", method: "GET", headers: [], @@ -240,6 +264,48 @@ describe("fetchMonitorDailyStats", () => { }); }); + test("queries the icmp pipe for icmp jobType", async () => { + await withTestTransaction(async (tx) => { + const row = await createMonitor({ + ctx: { ...teamCtx, db: tx }, + input: { + name: `${TEST_PREFIX}-icmp-daily`, + jobType: "icmp", + url: "1.1.1.1", + method: "GET", + headers: [], + assertions: [], + active: false, + regions: ["ams"], + }, + }); + const icmpTb = { + httpStatus45d: () => Promise.resolve({ data: [] }), + tcpStatus45d: () => Promise.resolve({ data: [] }), + dnsStatus45d: () => Promise.resolve({ data: [] }), + icmpStatus45d: ({ monitorIds }: { monitorIds: string[] }) => + Promise.resolve({ + data: monitorIds.map((monitorId) => ({ + day: "2024-01-01T00:00:00.000Z", + count: 1, + ok: 1, + degraded: 0, + error: 0, + monitorId, + })), + }), + } as unknown as NonNullable; + const stats = await fetchMonitorDailyStats({ + db: tx, + tb: icmpTb, + monitorIds: [row.id], + workspaceId: teamCtx.workspace.id, + }); + expect(stats).toHaveLength(1); + expect(stats[0]?.monitorId).toBe(String(row.id)); + }); + }); + test("queries each pipe with its own job type's ids and merges the rows", async () => { await withTestTransaction(async (tx) => { const httpMon = await createMonitor({ diff --git a/packages/services/src/monitor/get-daily-summary.ts b/packages/services/src/monitor/get-daily-summary.ts index 0d8c952ff..00e5eefb4 100644 --- a/packages/services/src/monitor/get-daily-summary.ts +++ b/packages/services/src/monitor/get-daily-summary.ts @@ -5,7 +5,7 @@ import type { OSTinybird } from "@openstatus/tinybird"; import type { DB } from "../context"; import type { StatusData } from "../status-timeline"; -type SupportedJobType = "http" | "tcp" | "dns"; +type SupportedJobType = "http" | "tcp" | "dns" | "icmp"; /** * Raw daily status buckets (one row per monitor per day) from the 45d Tinybird @@ -38,19 +38,21 @@ export async function fetchMonitorDailyStats(args: { http: [], tcp: [], dns: [], + icmp: [], }; for (const row of rows) { if ( row.jobType === "http" || row.jobType === "tcp" || - row.jobType === "dns" + row.jobType === "dns" || + row.jobType === "icmp" ) { idsByJobType[row.jobType].push(String(row.id)); } } const results = await Promise.all( - (["http", "tcp", "dns"] as const) + (["http", "tcp", "dns", "icmp"] as const) .filter((jobType) => idsByJobType[jobType].length > 0) .map((jobType) => { const monitorIds = idsByJobType[jobType]; @@ -59,7 +61,9 @@ export async function fetchMonitorDailyStats(args: { ? args.tb.httpStatus45d : jobType === "tcp" ? args.tb.tcpStatus45d - : args.tb.dnsStatus45d; + : jobType === "dns" + ? args.tb.dnsStatus45d + : args.tb.icmpStatus45d; return pipe({ monitorIds }); }), ); diff --git a/packages/services/src/monitor/get-monitor-summary.ts b/packages/services/src/monitor/get-monitor-summary.ts index bef9fe3c8..5345242d4 100644 --- a/packages/services/src/monitor/get-monitor-summary.ts +++ b/packages/services/src/monitor/get-monitor-summary.ts @@ -34,7 +34,7 @@ type MetricsRow = { lastTimestamp: number | null; }; -type SupportedJobType = "http" | "tcp" | "dns"; +type SupportedJobType = "http" | "tcp" | "dns" | "icmp"; function fetchMetrics( tb: NonNullable, @@ -58,6 +58,11 @@ function fetchMetrics( "7d": tb.dnsMetricsWeekly, "14d": tb.dnsMetricsBiweekly, }, + icmp: { + "1d": tb.icmpMetricsDaily, + "7d": tb.icmpMetricsWeekly, + "14d": tb.icmpMetricsBiweekly, + }, }[jobType][timeRange]; return pipe(params); } @@ -80,7 +85,8 @@ export async function getMonitorSummary(args: { if ( parsed.jobType !== "http" && parsed.jobType !== "tcp" && - parsed.jobType !== "dns" + parsed.jobType !== "dns" && + parsed.jobType !== "icmp" ) { throw new ValidationError( `getMonitorSummary does not support jobType '${parsed.jobType}'`, diff --git a/packages/tinybird/datasources/check_icmp_response__v0.datasource b/packages/tinybird/datasources/check_icmp_response__v0.datasource new file mode 100644 index 000000000..a5c1a9258 --- /dev/null +++ b/packages/tinybird/datasources/check_icmp_response__v0.datasource @@ -0,0 +1,21 @@ +SCHEMA > + `monitorId` Int32 `json:$.monitorId`, + `region` String `json:$.region`, + `timestamp` Int64 `json:$.timestamp`, + `cronTimestamp` Int64 `json:$.cronTimestamp`, + `timing` String `json:$.timing`, + `latency` Int64 `json:$.latency`, + `latencyMin` Int64 `json:$.latencyMin`, + `latencyMax` Int64 `json:$.latencyMax`, + `packetsSent` UInt8 `json:$.packetsSent`, + `packetsReceived` UInt8 `json:$.packetsReceived`, + `errorMessage` Nullable(String) `json:$.errorMessage`, + `error` Int16 `json:$.error`, + `trigger` Nullable(String) `json:$.trigger`, + `uri` Nullable(String) `json:$.uri`, + `id` Nullable(String) `json:$.id`, + `requestStatus` Nullable(String) `json:$.requestStatus`, + `requestId` Int64 `json:$.requestId` + +ENGINE "MergeTree" +ENGINE_SORTING_KEY "monitorId, requestId, timestamp" diff --git a/packages/tinybird/datasources/icmp_response__v0.datasource b/packages/tinybird/datasources/icmp_response__v0.datasource new file mode 100644 index 000000000..9f4b34737 --- /dev/null +++ b/packages/tinybird/datasources/icmp_response__v0.datasource @@ -0,0 +1,22 @@ +SCHEMA > + `monitorId` Int32 `json:$.monitorId`, + `region` String `json:$.region`, + `timestamp` Int64 `json:$.timestamp`, + `cronTimestamp` Int64 `json:$.cronTimestamp`, + `timing` String `json:$.timing`, + `workspaceId` Int32 `json:$.workspaceId`, + `latency` Int64 `json:$.latency`, + `latencyMin` Int64 `json:$.latencyMin`, + `latencyMax` Int64 `json:$.latencyMax`, + `packetsSent` UInt8 `json:$.packetsSent`, + `packetsReceived` UInt8 `json:$.packetsReceived`, + `errorMessage` Nullable(String) `json:$.errorMessage`, + `error` Int16 `json:$.error`, + `trigger` Nullable(String) `json:$.trigger`, + `uri` Nullable(String) `json:$.uri`, + `id` Nullable(String) `json:$.id`, + `requestStatus` Nullable(String) `json:$.requestStatus` + +ENGINE "MergeTree" +ENGINE_PARTITION_KEY "toYYYYMM(fromUnixTimestamp64Milli(cronTimestamp))" +ENGINE_SORTING_KEY "monitorId, cronTimestamp" diff --git a/packages/tinybird/datasources/mv__icmp_14d__v0.datasource b/packages/tinybird/datasources/mv__icmp_14d__v0.datasource new file mode 100644 index 000000000..c7717ddae --- /dev/null +++ b/packages/tinybird/datasources/mv__icmp_14d__v0.datasource @@ -0,0 +1,22 @@ +# Data Source created from Pipe 'aggregate__icmp_14d__v0' + +SCHEMA > + `time` DateTime, + `latency` Int64, + `latencyMin` Int64, + `latencyMax` Int64, + `packetsSent` UInt8, + `packetsReceived` UInt8, + `error` Int16, + `region` String, + `trigger` Nullable(String), + `timestamp` Int64, + `cronTimestamp` Int64, + `monitorId` Int32, + `requestStatus` Nullable(String), + `id` Nullable(String) + +ENGINE "MergeTree" +ENGINE_PARTITION_KEY "toYYYYMM(time)" +ENGINE_SORTING_KEY "monitorId, time" +ENGINE_TTL "time + toIntervalDay(14)" diff --git a/packages/tinybird/datasources/mv__icmp_1d__v0.datasource b/packages/tinybird/datasources/mv__icmp_1d__v0.datasource new file mode 100644 index 000000000..f0eb16eaa --- /dev/null +++ b/packages/tinybird/datasources/mv__icmp_1d__v0.datasource @@ -0,0 +1,22 @@ +# Data Source created from Pipe 'aggregate__icmp_1d__v0' + +SCHEMA > + `time` DateTime, + `latency` Int64, + `latencyMin` Int64, + `latencyMax` Int64, + `packetsSent` UInt8, + `packetsReceived` UInt8, + `error` Int16, + `region` String, + `trigger` Nullable(String), + `timestamp` Int64, + `cronTimestamp` Int64, + `monitorId` Int32, + `requestStatus` Nullable(String), + `id` Nullable(String) + +ENGINE "MergeTree" +ENGINE_PARTITION_KEY "toYYYYMM(time)" +ENGINE_SORTING_KEY "monitorId, time" +ENGINE_TTL "time + toIntervalDay(1)" diff --git a/packages/tinybird/datasources/mv__icmp_30d__v0.datasource b/packages/tinybird/datasources/mv__icmp_30d__v0.datasource new file mode 100644 index 000000000..6a078052c --- /dev/null +++ b/packages/tinybird/datasources/mv__icmp_30d__v0.datasource @@ -0,0 +1,22 @@ +# Data Source created from Pipe 'aggregate__icmp_30d__v0' + +SCHEMA > + `time` DateTime, + `latency` Int64, + `latencyMin` Int64, + `latencyMax` Int64, + `packetsSent` UInt8, + `packetsReceived` UInt8, + `error` Int16, + `region` String, + `trigger` Nullable(String), + `timestamp` Int64, + `cronTimestamp` Int64, + `monitorId` Int32, + `requestStatus` Nullable(String), + `id` Nullable(String) + +ENGINE "MergeTree" +ENGINE_PARTITION_KEY "toYYYYMM(time)" +ENGINE_SORTING_KEY "monitorId, time" +ENGINE_TTL "time + toIntervalDay(30)" diff --git a/packages/tinybird/datasources/mv__icmp_7d__v0.datasource b/packages/tinybird/datasources/mv__icmp_7d__v0.datasource new file mode 100644 index 000000000..de23c1dfb --- /dev/null +++ b/packages/tinybird/datasources/mv__icmp_7d__v0.datasource @@ -0,0 +1,22 @@ +# Data Source created from Pipe 'aggregate__icmp_7d__v0' + +SCHEMA > + `time` DateTime, + `latency` Int64, + `latencyMin` Int64, + `latencyMax` Int64, + `packetsSent` UInt8, + `packetsReceived` UInt8, + `error` Int16, + `region` String, + `trigger` Nullable(String), + `timestamp` Int64, + `cronTimestamp` Int64, + `monitorId` Int32, + `requestStatus` Nullable(String), + `id` Nullable(String) + +ENGINE "MergeTree" +ENGINE_PARTITION_KEY "toYYYYMM(time)" +ENGINE_SORTING_KEY "monitorId, time" +ENGINE_TTL "time + toIntervalDay(7)" diff --git a/packages/tinybird/datasources/mv__icmp_90d__v0.datasource b/packages/tinybird/datasources/mv__icmp_90d__v0.datasource new file mode 100644 index 000000000..aabfb87ef --- /dev/null +++ b/packages/tinybird/datasources/mv__icmp_90d__v0.datasource @@ -0,0 +1,22 @@ +# Data Source created from Pipe 'aggregate__icmp_90d__v0' + +SCHEMA > + `time` DateTime, + `latency` Int64, + `latencyMin` Int64, + `latencyMax` Int64, + `packetsSent` UInt8, + `packetsReceived` UInt8, + `error` Int16, + `region` String, + `trigger` Nullable(String), + `timestamp` Int64, + `cronTimestamp` Int64, + `monitorId` Int32, + `requestStatus` Nullable(String), + `id` Nullable(String) + +ENGINE "MergeTree" +ENGINE_PARTITION_KEY "toYYYYMM(time)" +ENGINE_SORTING_KEY "monitorId, time" +ENGINE_TTL "time + toIntervalDay(90)" diff --git a/packages/tinybird/datasources/mv__icmp_full_14d__v0.datasource b/packages/tinybird/datasources/mv__icmp_full_14d__v0.datasource new file mode 100644 index 000000000..5fb53b61c --- /dev/null +++ b/packages/tinybird/datasources/mv__icmp_full_14d__v0.datasource @@ -0,0 +1,26 @@ +# Data Source created from Pipe 'aggregate__icmp_full_14d__v0' + +SCHEMA > + `time` DateTime, + `monitorId` Int32, + `region` String, + `timestamp` Int64, + `cronTimestamp` Int64, + `timing` String, + `workspaceId` Int32, + `latency` Int64, + `latencyMin` Int64, + `latencyMax` Int64, + `packetsSent` UInt8, + `packetsReceived` UInt8, + `errorMessage` Nullable(String), + `error` Int16, + `trigger` Nullable(String), + `uri` Nullable(String), + `id` Nullable(String), + `requestStatus` Nullable(String) + +ENGINE "MergeTree" +ENGINE_PARTITION_KEY "toYYYYMM(time)" +ENGINE_SORTING_KEY "monitorId, time" +ENGINE_TTL "time + toIntervalDay(14)" diff --git a/packages/tinybird/datasources/mv__icmp_full_30d__v0.datasource b/packages/tinybird/datasources/mv__icmp_full_30d__v0.datasource new file mode 100644 index 000000000..5f6514e44 --- /dev/null +++ b/packages/tinybird/datasources/mv__icmp_full_30d__v0.datasource @@ -0,0 +1,26 @@ +# Data Source created from Pipe 'aggregate__icmp_full_30d__v0' + +SCHEMA > + `time` DateTime, + `monitorId` Int32, + `region` String, + `timestamp` Int64, + `cronTimestamp` Int64, + `timing` String, + `workspaceId` Int32, + `latency` Int64, + `latencyMin` Int64, + `latencyMax` Int64, + `packetsSent` UInt8, + `packetsReceived` UInt8, + `errorMessage` Nullable(String), + `error` Int16, + `trigger` Nullable(String), + `uri` Nullable(String), + `id` Nullable(String), + `requestStatus` Nullable(String) + +ENGINE "MergeTree" +ENGINE_PARTITION_KEY "toYYYYMM(time)" +ENGINE_SORTING_KEY "monitorId, time" +ENGINE_TTL "time + toIntervalDay(30)" diff --git a/packages/tinybird/datasources/mv__icmp_status_45d__v0.datasource b/packages/tinybird/datasources/mv__icmp_status_45d__v0.datasource new file mode 100644 index 000000000..440acac39 --- /dev/null +++ b/packages/tinybird/datasources/mv__icmp_status_45d__v0.datasource @@ -0,0 +1,14 @@ +# Data Source created from Pipe 'aggregate__icmp_status_45d__v0' + +SCHEMA > + `time` DateTime('UTC'), + `monitorId` Int32, + `count` AggregateFunction(count), + `success` AggregateFunction(count, Nullable(UInt8)), + `error` AggregateFunction(count, Nullable(UInt8)), + `degraded` AggregateFunction(count, Nullable(UInt8)) + +ENGINE "AggregatingMergeTree" +ENGINE_PARTITION_KEY "toYYYYMM(time)" +ENGINE_SORTING_KEY "monitorId, time" +ENGINE_TTL "time + toIntervalDay(46)" diff --git a/packages/tinybird/datasources/mv__icmp_status_7d__v0.datasource b/packages/tinybird/datasources/mv__icmp_status_7d__v0.datasource new file mode 100644 index 000000000..3813a5256 --- /dev/null +++ b/packages/tinybird/datasources/mv__icmp_status_7d__v0.datasource @@ -0,0 +1,12 @@ +# Data Source created from Pipe 'aggregate__icmp_status_7d__v0' + +SCHEMA > + `time` DateTime('UTC'), + `monitorId` Int32, + `count` AggregateFunction(count), + `ok` AggregateFunction(count, Nullable(UInt8)) + +ENGINE "AggregatingMergeTree" +ENGINE_PARTITION_KEY "toYYYYMM(time)" +ENGINE_SORTING_KEY "monitorId, time" +ENGINE_TTL "time + toIntervalDay(7)" diff --git a/packages/tinybird/datasources/mv__icmp_uptime_30d__v0.datasource b/packages/tinybird/datasources/mv__icmp_uptime_30d__v0.datasource new file mode 100644 index 000000000..1c5602137 --- /dev/null +++ b/packages/tinybird/datasources/mv__icmp_uptime_30d__v0.datasource @@ -0,0 +1,13 @@ +# Data Source created from Pipe 'aggregate__icmp_uptime_30d__v0' + +SCHEMA > + `time` DateTime, + `region` String, + `requestStatus` Nullable(String), + `monitorId` Int32, + `workspaceId` Int32 + +ENGINE "MergeTree" +ENGINE_PARTITION_KEY "toYYYYMM(time)" +ENGINE_SORTING_KEY "monitorId, time" +ENGINE_TTL "time + toIntervalDay(30)" diff --git a/packages/tinybird/datasources/mv__icmp_uptime_7d__v0.datasource b/packages/tinybird/datasources/mv__icmp_uptime_7d__v0.datasource new file mode 100644 index 000000000..f255167d8 --- /dev/null +++ b/packages/tinybird/datasources/mv__icmp_uptime_7d__v0.datasource @@ -0,0 +1,13 @@ +# Data Source created from Pipe 'aggregate__icmp_uptime_7d__v0' + +SCHEMA > + `time` DateTime, + `region` String, + `requestStatus` Nullable(String), + `monitorId` Int32, + `workspaceId` Int32 + +ENGINE "MergeTree" +ENGINE_PARTITION_KEY "toYYYYMM(time)" +ENGINE_SORTING_KEY "monitorId, time" +ENGINE_TTL "time + toIntervalDay(7)" diff --git a/packages/tinybird/datasources/mv__icmp_uptime_90d__v0.datasource b/packages/tinybird/datasources/mv__icmp_uptime_90d__v0.datasource new file mode 100644 index 000000000..52b205df5 --- /dev/null +++ b/packages/tinybird/datasources/mv__icmp_uptime_90d__v0.datasource @@ -0,0 +1,13 @@ +# Data Source created from Pipe 'aggregate__icmp_uptime_90d__v0' + +SCHEMA > + `time` DateTime, + `region` String, + `requestStatus` Nullable(String), + `monitorId` Int32, + `workspaceId` Int32 + +ENGINE "MergeTree" +ENGINE_PARTITION_KEY "toYYYYMM(time)" +ENGINE_SORTING_KEY "monitorId, time" +ENGINE_TTL "time + toIntervalDay(90)" diff --git a/packages/tinybird/datasources/mv__icmp_workspace_30d__v0.datasource b/packages/tinybird/datasources/mv__icmp_workspace_30d__v0.datasource new file mode 100644 index 000000000..b6746934c --- /dev/null +++ b/packages/tinybird/datasources/mv__icmp_workspace_30d__v0.datasource @@ -0,0 +1,12 @@ +# Data Source created from Pipe 'aggregate__icmp_workspace_30d__v0' + +SCHEMA > + `time` DateTime('UTC'), + `workspaceId` Int32, + `trigger` String, + `count_state` AggregateFunction(count) + +ENGINE "AggregatingMergeTree" +ENGINE_PARTITION_KEY "toYYYYMM(time)" +ENGINE_SORTING_KEY "workspaceId, time, trigger" +ENGINE_TTL "time + toIntervalDay(30)" diff --git a/packages/tinybird/endpoints/endpoint__icmp_get_14d__v0.pipe b/packages/tinybird/endpoints/endpoint__icmp_get_14d__v0.pipe new file mode 100644 index 000000000..1c88b0d59 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__icmp_get_14d__v0.pipe @@ -0,0 +1,15 @@ +TAGS "icmp" + +NODE endpoint +SQL > + + % + SELECT * + FROM mv__icmp_full_14d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + AND id = {{ String(id, '', required=True) }} + ORDER BY time DESC + + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__icmp_get_30d__v0.pipe b/packages/tinybird/endpoints/endpoint__icmp_get_30d__v0.pipe new file mode 100644 index 000000000..54bf4e254 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__icmp_get_30d__v0.pipe @@ -0,0 +1,15 @@ +TAGS "icmp" + +NODE endpoint +SQL > + +% + SELECT * + FROM mv__icmp_full_30d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + AND cronTimestamp = {{ Int64(cronTimestamp, 1709477432205, required=True) }} + AND region = {{ String(region, 'ams', required=True) }} + ORDER BY cronTimestamp DESC + +TYPE endpoint diff --git a/packages/tinybird/endpoints/endpoint__icmp_list_14d__v0.pipe b/packages/tinybird/endpoints/endpoint__icmp_list_14d__v0.pipe new file mode 100644 index 000000000..242cd6ccf --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__icmp_list_14d__v0.pipe @@ -0,0 +1,19 @@ +TAGS "icmp" + +NODE endpoint +SQL > + + % + SELECT * FROM mv__icmp_14d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + {% if defined(fromDate) %} + AND time >= toDateTime(fromUnixTimestamp64Milli(toInt64({{ String(fromDate) }}))) + {% end %} + {% if defined(toDate) %} + AND time <= toDateTime(fromUnixTimestamp64Milli(toInt64({{ String(toDate) }}))) + {% end %} + ORDER BY time DESC + + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__icmp_list_1d__v0.pipe b/packages/tinybird/endpoints/endpoint__icmp_list_1d__v0.pipe new file mode 100644 index 000000000..f44a9ba21 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__icmp_list_1d__v0.pipe @@ -0,0 +1,19 @@ +TAGS "icmp" + +NODE endpoint +SQL > + + % + SELECT * FROM mv__icmp_1d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + {% if defined(fromDate) %} + AND time >= toDateTime(fromUnixTimestamp64Milli(toInt64({{ String(fromDate) }}))) + {% end %} + {% if defined(toDate) %} + AND time <= toDateTime(fromUnixTimestamp64Milli(toInt64({{ String(toDate) }}))) + {% end %} + ORDER BY time DESC + + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__icmp_list_7d__v0.pipe b/packages/tinybird/endpoints/endpoint__icmp_list_7d__v0.pipe new file mode 100644 index 000000000..803000bd2 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__icmp_list_7d__v0.pipe @@ -0,0 +1,19 @@ +TAGS "icmp" + +NODE endpoint +SQL > + + % + SELECT * FROM mv__icmp_7d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + {% if defined(fromDate) %} + AND time >= toDateTime(fromUnixTimestamp64Milli(toInt64({{ String(fromDate) }}))) + {% end %} + {% if defined(toDate) %} + AND time <= toDateTime(fromUnixTimestamp64Milli(toInt64({{ String(toDate) }}))) + {% end %} + ORDER BY time DESC + + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__icmp_metrics_14d__v0.pipe b/packages/tinybird/endpoints/endpoint__icmp_metrics_14d__v0.pipe new file mode 100644 index 000000000..ddf8581e2 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__icmp_metrics_14d__v0.pipe @@ -0,0 +1,43 @@ +TAGS "icmp" + +NODE endpoint +SQL > + + % + SELECT + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency, + count() as count, + countIf(requestStatus = 'success') AS success, + countIf(requestStatus = 'degraded') AS degraded, + countIf(requestStatus = 'error') AS error, + max(cronTimestamp) AS lastTimestamp + FROM mv__icmp_14d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + AND time >= toDateTime64(now() - INTERVAL 14 DAY, 3) + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + UNION ALL + SELECT + round(quantile(0.50)(latency)) AS p50Latency, + round(quantile(0.75)(latency)) AS p75Latency, + round(quantile(0.90)(latency)) AS p90Latency, + round(quantile(0.95)(latency)) AS p95Latency, + round(quantile(0.99)(latency)) AS p99Latency, + count() as count, + countIf(requestStatus = 'success') AS success, + countIf(requestStatus = 'degraded') AS degraded, + countIf(requestStatus = 'error') AS error, + NULL as lastTimestamp -- no need to query the `lastTimestamp` as not relevant + FROM mv__icmp_30d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + AND time >= toDateTime64(now() - INTERVAL 28 DAY, 3) + AND time < toDateTime64(now() - INTERVAL 14 DAY, 3) + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__icmp_metrics_1d__v0.pipe b/packages/tinybird/endpoints/endpoint__icmp_metrics_1d__v0.pipe new file mode 100644 index 000000000..178bb505b --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__icmp_metrics_1d__v0.pipe @@ -0,0 +1,43 @@ +TAGS "icmp" + +NODE endpoint +SQL > + + % + SELECT + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency, + count() as count, + countIf(requestStatus = 'success') AS success, + countIf(requestStatus = 'degraded') AS degraded, + countIf(requestStatus = 'error') AS error, + max(cronTimestamp) AS lastTimestamp + FROM mv__icmp_1d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + AND time >= toDateTime64(now() - INTERVAL 1 DAY, 3) + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + UNION ALL + SELECT + round(quantile(0.50)(latency)) AS p50Latency, + round(quantile(0.75)(latency)) AS p75Latency, + round(quantile(0.90)(latency)) AS p90Latency, + round(quantile(0.95)(latency)) AS p95Latency, + round(quantile(0.99)(latency)) AS p99Latency, + count() as count, + countIf(requestStatus = 'success') AS success, + countIf(requestStatus = 'degraded') AS degraded, + countIf(requestStatus = 'error') AS error, + NULL as lastTimestamp -- no need to query the `lastTimestamp` as not relevant + FROM mv__icmp_7d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + AND time >= toDateTime64(now() - INTERVAL 2 DAY, 3) + AND time < toDateTime64(now() - INTERVAL 1 DAY, 3) + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__icmp_metrics_30d__v0.pipe b/packages/tinybird/endpoints/endpoint__icmp_metrics_30d__v0.pipe new file mode 100644 index 000000000..fe3da8989 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__icmp_metrics_30d__v0.pipe @@ -0,0 +1,42 @@ +TAGS "icmp" + +NODE endpoint +SQL > + + % + SELECT + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency, + count() as count, + countIf(requestStatus = 'success') AS success, + countIf(requestStatus = 'degraded') AS degraded, + countIf(requestStatus = 'error') AS error, + max(cronTimestamp) AS lastTimestamp + FROM mv__icmp_30d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + AND time >= toDateTime64(now() - INTERVAL 30 DAY, 3) + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + UNION ALL + SELECT + round(quantile(0.50)(latency)) AS p50Latency, + round(quantile(0.75)(latency)) AS p75Latency, + round(quantile(0.90)(latency)) AS p90Latency, + round(quantile(0.95)(latency)) AS p95Latency, + round(quantile(0.99)(latency)) AS p99Latency, + count() as count, + countIf(requestStatus = 'success') AS success, + countIf(requestStatus = 'degraded') AS degraded, + countIf(requestStatus = 'error') AS error, + NULL as lastTimestamp + FROM mv__icmp_90d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + AND time >= toDateTime64(now() - INTERVAL 60 DAY, 3) + AND time < toDateTime64(now() - INTERVAL 30 DAY, 3) + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__icmp_metrics_7d__v0.pipe b/packages/tinybird/endpoints/endpoint__icmp_metrics_7d__v0.pipe new file mode 100644 index 000000000..ab6b9d2f3 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__icmp_metrics_7d__v0.pipe @@ -0,0 +1,43 @@ +TAGS "icmp" + +NODE endpoint +SQL > + + % + SELECT + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency, + count() as count, + countIf(requestStatus = 'success') AS success, + countIf(requestStatus = 'degraded') AS degraded, + countIf(requestStatus = 'error') AS error, + max(cronTimestamp) AS lastTimestamp + FROM mv__icmp_7d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + AND time >= toDateTime64(now() - INTERVAL 7 DAY, 3) + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + UNION ALL + SELECT + round(quantile(0.50)(latency)) AS p50Latency, + round(quantile(0.75)(latency)) AS p75Latency, + round(quantile(0.90)(latency)) AS p90Latency, + round(quantile(0.95)(latency)) AS p95Latency, + round(quantile(0.99)(latency)) AS p99Latency, + count() as count, + countIf(requestStatus = 'success') AS success, + countIf(requestStatus = 'degraded') AS degraded, + countIf(requestStatus = 'error') AS error, + NULL as lastTimestamp -- no need to query the `lastTimestamp` as not relevant + FROM mv__icmp_14d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + AND time >= toDateTime64(now() - INTERVAL 14 DAY, 3) + AND time < toDateTime64(now() - INTERVAL 7 DAY, 3) + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__icmp_metrics_90d__v0.pipe b/packages/tinybird/endpoints/endpoint__icmp_metrics_90d__v0.pipe new file mode 100644 index 000000000..0b1d0ccd3 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__icmp_metrics_90d__v0.pipe @@ -0,0 +1,39 @@ +TAGS "icmp" + +NODE endpoint +SQL > + + % + SELECT + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency, + count() as count, + countIf(requestStatus = 'success') AS success, + countIf(requestStatus = 'degraded') AS degraded, + countIf(requestStatus = 'error') AS error, + max(cronTimestamp) AS lastTimestamp + FROM mv__icmp_90d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + AND time >= toDateTime64(now() - INTERVAL 90 DAY, 3) + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + UNION ALL + -- the previous 90d window (90-180d ago) is past the 90d MV TTL, so there's no + -- real comparison data. emit an empty row to keep the 2-row contract; count 0 + -- makes the client suppress the trend badge (NaN). + SELECT + 0 AS p50Latency, + 0 AS p75Latency, + 0 AS p90Latency, + 0 AS p95Latency, + 0 AS p99Latency, + 0 AS count, + 0 AS success, + 0 AS degraded, + 0 AS error, + NULL AS lastTimestamp + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__icmp_metrics_by_interval_14d__v0.pipe b/packages/tinybird/endpoints/endpoint__icmp_metrics_by_interval_14d__v0.pipe new file mode 100644 index 000000000..879c6c9ea --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__icmp_metrics_by_interval_14d__v0.pipe @@ -0,0 +1,28 @@ +VERSION 0 + +TAGS icmp + +NODE endpoint +SQL > + + % + SELECT + region, + toStartOfInterval( + toDateTime(cronTimestamp / 1000), + INTERVAL {{ Int64(interval, 30) }} MINUTE -- use 2880 (2d) in case you want the 1d summary for the regions + ) as h, + toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency + FROM mv__icmp_14d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + GROUP BY h, region + ORDER BY h DESC + + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__icmp_metrics_by_interval_1d__v0.pipe b/packages/tinybird/endpoints/endpoint__icmp_metrics_by_interval_1d__v0.pipe new file mode 100644 index 000000000..5aee25367 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__icmp_metrics_by_interval_1d__v0.pipe @@ -0,0 +1,28 @@ +VERSION 0 + +TAGS icmp + +NODE endpoint +SQL > + + % + SELECT + region, + toStartOfInterval( + toDateTime(cronTimestamp / 1000), + INTERVAL {{ Int64(interval, 30) }} MINUTE -- use 2880 (2d) in case you want the 1d summary for the regions + ) as h, + toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency + FROM mv__icmp_1d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + GROUP BY h, region + ORDER BY h DESC + + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__icmp_metrics_by_interval_30d__v0.pipe b/packages/tinybird/endpoints/endpoint__icmp_metrics_by_interval_30d__v0.pipe new file mode 100644 index 000000000..094e63903 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__icmp_metrics_by_interval_30d__v0.pipe @@ -0,0 +1,26 @@ +VERSION 0 + +TAGS icmp + +NODE endpoint +SQL > + + % + SELECT + region, + toStartOfInterval( + toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 1440) }} MINUTE + ) as h, + toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency + FROM mv__icmp_30d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + GROUP BY h, region + ORDER BY h DESC + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__icmp_metrics_by_interval_7d__v0.pipe b/packages/tinybird/endpoints/endpoint__icmp_metrics_by_interval_7d__v0.pipe new file mode 100644 index 000000000..638bccea4 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__icmp_metrics_by_interval_7d__v0.pipe @@ -0,0 +1,28 @@ +VERSION 0 + +TAGS icmp + +NODE endpoint +SQL > + + % + SELECT + region, + toStartOfInterval( + toDateTime(cronTimestamp / 1000), + INTERVAL {{ Int64(interval, 30) }} MINUTE -- use 2880 (2d) in case you want the 1d summary for the regions + ) as h, + toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency + FROM mv__icmp_7d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + GROUP BY h, region + ORDER BY h DESC + + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__icmp_metrics_by_interval_90d__v0.pipe b/packages/tinybird/endpoints/endpoint__icmp_metrics_by_interval_90d__v0.pipe new file mode 100644 index 000000000..b7c788072 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__icmp_metrics_by_interval_90d__v0.pipe @@ -0,0 +1,26 @@ +VERSION 0 + +TAGS icmp + +NODE endpoint +SQL > + + % + SELECT + region, + toStartOfInterval( + toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 1440) }} MINUTE + ) as h, + toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency + FROM mv__icmp_90d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + GROUP BY h, region + ORDER BY h DESC + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__icmp_metrics_by_region_14d__v0.pipe b/packages/tinybird/endpoints/endpoint__icmp_metrics_by_region_14d__v0.pipe new file mode 100644 index 000000000..384b03395 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__icmp_metrics_by_region_14d__v0.pipe @@ -0,0 +1,22 @@ +TAGS "icmp" + +NODE endpoint +SQL > + +% + SELECT + region, + round(quantile(0.5)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.9)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency, + count() as count, + count(if(error = 0, 1, NULL)) AS ok + FROM mv__icmp_14d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + GROUP BY region + +TYPE endpoint diff --git a/packages/tinybird/endpoints/endpoint__icmp_metrics_by_region_1d__v0.pipe b/packages/tinybird/endpoints/endpoint__icmp_metrics_by_region_1d__v0.pipe new file mode 100644 index 000000000..f4f409acb --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__icmp_metrics_by_region_1d__v0.pipe @@ -0,0 +1,22 @@ +TAGS "icmp" + +NODE endpoint +SQL > + +% + SELECT + region, + round(quantile(0.5)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.9)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency, + count() as count, + count(if(error = 0, 1, NULL)) AS ok + FROM mv__icmp_1d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + GROUP BY region + +TYPE endpoint diff --git a/packages/tinybird/endpoints/endpoint__icmp_metrics_by_region_7d__v0.pipe b/packages/tinybird/endpoints/endpoint__icmp_metrics_by_region_7d__v0.pipe new file mode 100644 index 000000000..d65688cd6 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__icmp_metrics_by_region_7d__v0.pipe @@ -0,0 +1,22 @@ +TAGS "icmp" + +NODE endpoint +SQL > + +% + SELECT + region, + round(quantile(0.5)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.9)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency, + count() as count, + count(if(error = 0, 1, NULL)) AS ok + FROM mv__icmp_7d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + GROUP BY region + +TYPE endpoint diff --git a/packages/tinybird/endpoints/endpoint__icmp_metrics_global_1d__v0.pipe b/packages/tinybird/endpoints/endpoint__icmp_metrics_global_1d__v0.pipe new file mode 100644 index 000000000..2b6e5f9c4 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__icmp_metrics_global_1d__v0.pipe @@ -0,0 +1,25 @@ +VERSION 0 + +TAGS "icmp" + +NODE endpoint +SQL > + + % + SELECT + round(min(latency), 0) as minLatency, + round(max(latency), 0) as maxLatency, + round(quantile(0.5)(latency), 0) as p50Latency, + round(quantile(0.75)(latency), 0) as p75Latency, + round(quantile(0.9)(latency), 0) as p90Latency, + round(quantile(0.95)(latency), 0) as p95Latency, + round(quantile(0.99)(latency), 0) as p99Latency, + max(cronTimestamp) as lastTimestamp, + count() as count, + monitorId + FROM mv__icmp_1d__v0 + WHERE monitorId IN {{ Array(monitorIds, 'String', '4433') }} + GROUP BY monitorId + + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__icmp_metrics_latency_1d__v0.pipe b/packages/tinybird/endpoints/endpoint__icmp_metrics_latency_1d__v0.pipe new file mode 100644 index 000000000..2e00ff92b --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__icmp_metrics_latency_1d__v0.pipe @@ -0,0 +1,24 @@ +TAGS "icmp" + +NODE endpoint +SQL > + + % + SELECT + toStartOfInterval( + toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 30) }} MINUTE + ) as h, + toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency + FROM mv__icmp_1d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + GROUP BY h + ORDER BY h DESC + + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__icmp_metrics_latency_1d_multi__v0.pipe b/packages/tinybird/endpoints/endpoint__icmp_metrics_latency_1d_multi__v0.pipe new file mode 100644 index 000000000..c7e82e35c --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__icmp_metrics_latency_1d_multi__v0.pipe @@ -0,0 +1,25 @@ +TAGS "icmp" + +NODE endpoint +SQL > + + % + SELECT + toStartOfInterval( + toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 30) }} MINUTE + ) as h, + monitorId, + toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency + FROM mv__icmp_1d__v0 + WHERE + monitorId IN {{ Array(monitorIds, 'String', '4433') }} + GROUP BY h, monitorId + ORDER BY h DESC + + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__icmp_metrics_latency_30d__v0.pipe b/packages/tinybird/endpoints/endpoint__icmp_metrics_latency_30d__v0.pipe new file mode 100644 index 000000000..7b6a79e7e --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__icmp_metrics_latency_30d__v0.pipe @@ -0,0 +1,23 @@ +TAGS "icmp" + +NODE endpoint +SQL > + + % + SELECT + toStartOfInterval( + toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 1440) }} MINUTE + ) as h, + toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency + FROM mv__icmp_30d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + GROUP BY h + ORDER BY h DESC + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__icmp_metrics_latency_7d__v0.pipe b/packages/tinybird/endpoints/endpoint__icmp_metrics_latency_7d__v0.pipe new file mode 100644 index 000000000..045e47c16 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__icmp_metrics_latency_7d__v0.pipe @@ -0,0 +1,24 @@ +TAGS "icmp" + +NODE endpoint +SQL > + + % + SELECT + toStartOfInterval( + toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 30) }} MINUTE + ) as h, + toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency + FROM mv__icmp_7d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + GROUP BY h + ORDER BY h DESC + + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__icmp_metrics_latency_90d__v0.pipe b/packages/tinybird/endpoints/endpoint__icmp_metrics_latency_90d__v0.pipe new file mode 100644 index 000000000..e8c5d4057 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__icmp_metrics_latency_90d__v0.pipe @@ -0,0 +1,23 @@ +TAGS "icmp" + +NODE endpoint +SQL > + + % + SELECT + toStartOfInterval( + toDateTime(cronTimestamp / 1000), INTERVAL {{ Int64(interval, 1440) }} MINUTE + ) as h, + toUnixTimestamp64Milli(toDateTime64(h, 3)) as timestamp, + round(quantile(0.50)(latency)) as p50Latency, + round(quantile(0.75)(latency)) as p75Latency, + round(quantile(0.90)(latency)) as p90Latency, + round(quantile(0.95)(latency)) as p95Latency, + round(quantile(0.99)(latency)) as p99Latency + FROM mv__icmp_90d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + GROUP BY h + ORDER BY h DESC + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__icmp_status_45d__v0.pipe b/packages/tinybird/endpoints/endpoint__icmp_status_45d__v0.pipe new file mode 100644 index 000000000..53593c6b5 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__icmp_status_45d__v0.pipe @@ -0,0 +1,20 @@ +TAGS "icmp" + +NODE endpoint +SQL > + + % + SELECT + time as day, + monitorId, + countMerge(count) as count, + countMerge(success) as ok, + countMerge(error) as error, + countMerge(degraded) as degraded + FROM mv__icmp_status_45d__v0 + WHERE monitorId IN {{ Array(monitorIds, 'String', '4433') }} + GROUP BY day, monitorId + ORDER BY day DESC + + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__icmp_status_7d__v0.pipe b/packages/tinybird/endpoints/endpoint__icmp_status_7d__v0.pipe new file mode 100644 index 000000000..1dc9edf50 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__icmp_status_7d__v0.pipe @@ -0,0 +1,20 @@ +TAGS "icmp" + +NODE endpoint +SQL > + +% + SELECT time as day, countMerge(count) as count, countMerge(ok) as ok + FROM mv__icmp_status_7d__v0 + WHERE + monitorId = {{ String(monitorId, '1', required=True) }} + GROUP BY day + ORDER BY day DESC + WITH FILL + FROM + toStartOfDay(toStartOfDay(toTimeZone(now(), 'UTC'))) + TO toStartOfDay( + date_sub(DAY, 7, now()) + ) STEP INTERVAL -1 DAY + +TYPE endpoint diff --git a/packages/tinybird/endpoints/endpoint__icmp_uptime_30d__v0.pipe b/packages/tinybird/endpoints/endpoint__icmp_uptime_30d__v0.pipe new file mode 100644 index 000000000..8bada9c99 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__icmp_uptime_30d__v0.pipe @@ -0,0 +1,22 @@ +TAGS "icmp" + +NODE endpoint +SQL > + + % + SELECT + toStartOfInterval(time, INTERVAL {{ String(interval, '30', required=True) }} minute) AS interval, + countIf(requestStatus = 'success') AS success, + countIf(requestStatus = 'degraded') AS degraded, + countIf(requestStatus = 'error') AS error + FROM mv__icmp_uptime_30d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + {% if fromDate %} AND time >= parseDateTimeBestEffortOrNull({{ String(fromDate) }}) {% end %} + {% if toDate %} AND time <= parseDateTimeBestEffortOrNull({{ String(toDate) }}) {% end %} + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + GROUP BY interval + ORDER BY interval DESC + + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__icmp_uptime_7d__v0.pipe b/packages/tinybird/endpoints/endpoint__icmp_uptime_7d__v0.pipe new file mode 100644 index 000000000..98673f9bf --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__icmp_uptime_7d__v0.pipe @@ -0,0 +1,22 @@ +TAGS "icmp" + +NODE endpoint +SQL > + + % + SELECT + toStartOfInterval(time, INTERVAL {{ String(interval, '30', required=True) }} minute) AS interval, + countIf(requestStatus = 'success') AS success, + countIf(requestStatus = 'degraded') AS degraded, + countIf(requestStatus = 'error') AS error + FROM mv__icmp_uptime_7d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + {% if fromDate %} AND time >= parseDateTimeBestEffortOrNull({{ String(fromDate) }}) {% end %} + {% if toDate %} AND time <= parseDateTimeBestEffortOrNull({{ String(toDate) }}) {% end %} + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + GROUP BY interval + ORDER BY interval DESC + + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__icmp_uptime_90d__v0.pipe b/packages/tinybird/endpoints/endpoint__icmp_uptime_90d__v0.pipe new file mode 100644 index 000000000..c77799c5b --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__icmp_uptime_90d__v0.pipe @@ -0,0 +1,21 @@ +TAGS "icmp" + +NODE endpoint +SQL > + + % + SELECT + toStartOfInterval(time, INTERVAL {{ String(interval, '1440', required=True) }} minute) AS interval, + countIf(requestStatus = 'success') AS success, + countIf(requestStatus = 'degraded') AS degraded, + countIf(requestStatus = 'error') AS error + FROM mv__icmp_uptime_90d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + {% if fromDate %} AND time >= parseDateTimeBestEffortOrNull({{ String(fromDate) }}) {% end %} + {% if toDate %} AND time <= parseDateTimeBestEffortOrNull({{ String(toDate) }}) {% end %} + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + GROUP BY interval + ORDER BY interval DESC + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__icmp_workspace_30d__v0.pipe b/packages/tinybird/endpoints/endpoint__icmp_workspace_30d__v0.pipe new file mode 100644 index 000000000..f9b73d399 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__icmp_workspace_30d__v0.pipe @@ -0,0 +1,16 @@ +TAGS "icmp" + +NODE endpoint +SQL > + + % + SELECT + time as day, + countMerge(count_state) as count + FROM mv__icmp_workspace_30d__v0 + WHERE workspaceId = {{ Int32(workspaceId, 1, required=True) }} + GROUP BY day + ORDER BY day DESC + + +TYPE ENDPOINT diff --git a/packages/tinybird/materializations/aggregate__icmp_full_30d__v0.pipe b/packages/tinybird/materializations/aggregate__icmp_full_30d__v0.pipe new file mode 100644 index 000000000..f959a515b --- /dev/null +++ b/packages/tinybird/materializations/aggregate__icmp_full_30d__v0.pipe @@ -0,0 +1,32 @@ +DESCRIPTION > + Stores all the data from the icmp_response table for the last 30 days, mainly used for accessing the data details. + + +TAGS "icmp, full" + +NODE aggregate +SQL > + + SELECT + toDateTime(fromUnixTimestamp64Milli(cronTimestamp)) AS time, + monitorId, + region, + timestamp, + cronTimestamp, + timing, + workspaceId, + latency, + latencyMin, + latencyMax, + packetsSent, + packetsReceived, + errorMessage, + error, + trigger, + uri, + id, + requestStatus + FROM icmp_response__v0 + +TYPE materialized +DATASOURCE mv__icmp_full_30d__v0 diff --git a/packages/tinybird/materializations/aggregate__icmp_status_7d__v0.pipe b/packages/tinybird/materializations/aggregate__icmp_status_7d__v0.pipe new file mode 100644 index 000000000..3a491626c --- /dev/null +++ b/packages/tinybird/materializations/aggregate__icmp_status_7d__v0.pipe @@ -0,0 +1,17 @@ +TAGS "icmp" + +NODE aggregate +SQL > + + SELECT + toStartOfDay(toTimeZone(fromUnixTimestamp64Milli(cronTimestamp), 'UTC')) AS time, + monitorId, + countState() AS count, + countState(if(error = 0, 1, NULL)) AS ok + FROM icmp_response__v0 + GROUP BY + time, + monitorId + +TYPE materialized +DATASOURCE mv__icmp_status_7d__v0 diff --git a/packages/tinybird/pipes/aggregate__icmp_14d__v0.pipe b/packages/tinybird/pipes/aggregate__icmp_14d__v0.pipe new file mode 100644 index 000000000..413bde969 --- /dev/null +++ b/packages/tinybird/pipes/aggregate__icmp_14d__v0.pipe @@ -0,0 +1,24 @@ +TAGS "icmp" + +NODE aggregate +SQL > + + SELECT + toDateTime(fromUnixTimestamp64Milli(cronTimestamp)) AS time, + latency, + latencyMin, + latencyMax, + packetsSent, + packetsReceived, + error, + region, + trigger, + timestamp, + cronTimestamp, + monitorId, + requestStatus, + id + FROM icmp_response__v0 + +TYPE materialized +DATASOURCE mv__icmp_14d__v0 diff --git a/packages/tinybird/pipes/aggregate__icmp_1d__v0.pipe b/packages/tinybird/pipes/aggregate__icmp_1d__v0.pipe new file mode 100644 index 000000000..2f5cd5abf --- /dev/null +++ b/packages/tinybird/pipes/aggregate__icmp_1d__v0.pipe @@ -0,0 +1,24 @@ +TAGS "icmp" + +NODE aggregate +SQL > + + SELECT + toDateTime(fromUnixTimestamp64Milli(cronTimestamp)) AS time, + latency, + latencyMin, + latencyMax, + packetsSent, + packetsReceived, + error, + region, + trigger, + timestamp, + cronTimestamp, + monitorId, + requestStatus, + id + FROM icmp_response__v0 + +TYPE materialized +DATASOURCE mv__icmp_1d__v0 diff --git a/packages/tinybird/pipes/aggregate__icmp_30d__v0.pipe b/packages/tinybird/pipes/aggregate__icmp_30d__v0.pipe new file mode 100644 index 000000000..307dc5f4c --- /dev/null +++ b/packages/tinybird/pipes/aggregate__icmp_30d__v0.pipe @@ -0,0 +1,24 @@ +TAGS "icmp" + +NODE aggregate +SQL > + + SELECT + toDateTime(fromUnixTimestamp64Milli(cronTimestamp)) AS time, + latency, + latencyMin, + latencyMax, + packetsSent, + packetsReceived, + error, + region, + trigger, + timestamp, + cronTimestamp, + monitorId, + requestStatus, + id + FROM icmp_response__v0 + +TYPE materialized +DATASOURCE mv__icmp_30d__v0 diff --git a/packages/tinybird/pipes/aggregate__icmp_7d__v0.pipe b/packages/tinybird/pipes/aggregate__icmp_7d__v0.pipe new file mode 100644 index 000000000..fd633ff29 --- /dev/null +++ b/packages/tinybird/pipes/aggregate__icmp_7d__v0.pipe @@ -0,0 +1,24 @@ +TAGS "icmp" + +NODE aggregate +SQL > + + SELECT + toDateTime(fromUnixTimestamp64Milli(cronTimestamp)) AS time, + latency, + latencyMin, + latencyMax, + packetsSent, + packetsReceived, + error, + region, + trigger, + timestamp, + cronTimestamp, + monitorId, + requestStatus, + id + FROM icmp_response__v0 + +TYPE materialized +DATASOURCE mv__icmp_7d__v0 diff --git a/packages/tinybird/pipes/aggregate__icmp_90d__v0.pipe b/packages/tinybird/pipes/aggregate__icmp_90d__v0.pipe new file mode 100644 index 000000000..3a69d8af6 --- /dev/null +++ b/packages/tinybird/pipes/aggregate__icmp_90d__v0.pipe @@ -0,0 +1,24 @@ +TAGS "icmp" + +NODE aggregate +SQL > + + SELECT + toDateTime(fromUnixTimestamp64Milli(cronTimestamp)) AS time, + latency, + latencyMin, + latencyMax, + packetsSent, + packetsReceived, + error, + region, + trigger, + timestamp, + cronTimestamp, + monitorId, + requestStatus, + id + FROM icmp_response__v0 + +TYPE materialized +DATASOURCE mv__icmp_90d__v0 diff --git a/packages/tinybird/pipes/aggregate__icmp_full_14d__v0.pipe b/packages/tinybird/pipes/aggregate__icmp_full_14d__v0.pipe new file mode 100644 index 000000000..112c8d70c --- /dev/null +++ b/packages/tinybird/pipes/aggregate__icmp_full_14d__v0.pipe @@ -0,0 +1,16 @@ +DESCRIPTION > + Stores all the data from the icmp_response table, mainly used for accessing the data details. + + +TAGS "icmp, full" + +NODE aggregate +SQL > + + SELECT + toDateTime(fromUnixTimestamp64Milli(cronTimestamp)) AS time, + * + FROM icmp_response__v0 + +TYPE materialized +DATASOURCE mv__icmp_full_14d__v0 diff --git a/packages/tinybird/pipes/aggregate__icmp_status_45d__v0.pipe b/packages/tinybird/pipes/aggregate__icmp_status_45d__v0.pipe new file mode 100644 index 000000000..f3b10a877 --- /dev/null +++ b/packages/tinybird/pipes/aggregate__icmp_status_45d__v0.pipe @@ -0,0 +1,19 @@ +TAGS "icmp, statuspage" + +NODE aggregate +SQL > + + SELECT + toStartOfDay(toTimeZone(fromUnixTimestamp64Milli(cronTimestamp), 'UTC')) AS time, + monitorId, + countState() AS count, + countState(if(requestStatus = 'success', 1, NULL)) AS success, + countState(if(requestStatus = 'error', 1, NULL)) AS error, + countState(if(requestStatus = 'degraded', 1, NULL)) AS degraded + FROM icmp_response__v0 + GROUP BY + time, + monitorId + +TYPE materialized +DATASOURCE mv__icmp_status_45d__v0 diff --git a/packages/tinybird/pipes/aggregate__icmp_uptime_30d__v0.pipe b/packages/tinybird/pipes/aggregate__icmp_uptime_30d__v0.pipe new file mode 100644 index 000000000..fab458107 --- /dev/null +++ b/packages/tinybird/pipes/aggregate__icmp_uptime_30d__v0.pipe @@ -0,0 +1,13 @@ +NODE aggregate +SQL > + + SELECT + toDateTime(fromUnixTimestamp64Milli(cronTimestamp)) AS time, + region, + requestStatus, + monitorId, + workspaceId + FROM icmp_response__v0 + +TYPE materialized +DATASOURCE mv__icmp_uptime_30d__v0 diff --git a/packages/tinybird/pipes/aggregate__icmp_uptime_7d__v0.pipe b/packages/tinybird/pipes/aggregate__icmp_uptime_7d__v0.pipe new file mode 100644 index 000000000..9b7af2504 --- /dev/null +++ b/packages/tinybird/pipes/aggregate__icmp_uptime_7d__v0.pipe @@ -0,0 +1,13 @@ +NODE aggregate +SQL > + + SELECT + toDateTime(fromUnixTimestamp64Milli(cronTimestamp)) AS time, + region, + requestStatus, + monitorId, + workspaceId + FROM icmp_response__v0 + +TYPE materialized +DATASOURCE mv__icmp_uptime_7d__v0 diff --git a/packages/tinybird/pipes/aggregate__icmp_uptime_90d__v0.pipe b/packages/tinybird/pipes/aggregate__icmp_uptime_90d__v0.pipe new file mode 100644 index 000000000..c53a633fd --- /dev/null +++ b/packages/tinybird/pipes/aggregate__icmp_uptime_90d__v0.pipe @@ -0,0 +1,13 @@ +NODE aggregate +SQL > + + SELECT + toDateTime(fromUnixTimestamp64Milli(cronTimestamp)) AS time, + region, + requestStatus, + monitorId, + workspaceId + FROM icmp_response__v0 + +TYPE materialized +DATASOURCE mv__icmp_uptime_90d__v0 diff --git a/packages/tinybird/pipes/aggregate__icmp_workspace_30d__v0.pipe b/packages/tinybird/pipes/aggregate__icmp_workspace_30d__v0.pipe new file mode 100644 index 000000000..936b369b1 --- /dev/null +++ b/packages/tinybird/pipes/aggregate__icmp_workspace_30d__v0.pipe @@ -0,0 +1,18 @@ +TAGS "icmp" + +NODE aggregate +SQL > + + SELECT + toStartOfDay(toTimeZone(fromUnixTimestamp64Milli(cronTimestamp), 'UTC')) AS time, + workspaceId, + ifNull(trigger, 'cron') AS trigger, + countState() AS count_state + FROM icmp_response__v0 + GROUP BY + time, + workspaceId, + trigger + +TYPE materialized +DATASOURCE mv__icmp_workspace_30d__v0 diff --git a/packages/tinybird/src/client.ts b/packages/tinybird/src/client.ts index 1bff26fb1..a6f434124 100644 --- a/packages/tinybird/src/client.ts +++ b/packages/tinybird/src/client.ts @@ -22,6 +22,75 @@ const externalStatusHistoryDailyShape = { snapshot_count: z.int(), }; +const icmpMetricsShape = z.object({ + p50Latency: z.number().nullable().prefault(0), + p75Latency: z.number().nullable().prefault(0), + p90Latency: z.number().nullable().prefault(0), + p95Latency: z.number().nullable().prefault(0), + p99Latency: z.number().nullable().prefault(0), + count: z.int().prefault(0), + success: z.int().prefault(0), + degraded: z.int().prefault(0), + error: z.int().prefault(0), + lastTimestamp: z.int().nullable(), +}); + +const icmpMetricsByIntervalParameters = z.object({ + regions: z.array(z.enum(monitorRegions).or(z.string())).optional(), + interval: z.int().optional(), + monitorId: z.string(), +}); + +const icmpMetricsByIntervalShape = z.object({ + region: z.enum(monitorRegions).or(z.string()), + timestamp: z.int(), + p50Latency: z.number().nullable().prefault(0), + p75Latency: z.number().nullable().prefault(0), + p90Latency: z.number().nullable().prefault(0), + p95Latency: z.number().nullable().prefault(0), + p99Latency: z.number().nullable().prefault(0), +}); + +const icmpMetricsByRegionParameters = z.object({ + monitorId: z.string(), + regions: z.array(z.enum(monitorRegions).or(z.string())).optional(), +}); + +const icmpMetricsByRegionShape = z.object({ + region: z.enum(monitorRegions).or(z.string()), + count: z.int(), + ok: z.int(), + p50Latency: z.number().nullable().prefault(0), + p75Latency: z.number().nullable().prefault(0), + p90Latency: z.number().nullable().prefault(0), + p95Latency: z.number().nullable().prefault(0), + p99Latency: z.number().nullable().prefault(0), +}); + +const icmpMetricsLatencyShape = z.object({ + timestamp: z.int(), + p50Latency: z.int(), + p75Latency: z.int(), + p90Latency: z.int(), + p95Latency: z.int(), + p99Latency: z.int(), +}); + +const icmpUptimeParameters = z.object({ + monitorId: z.string(), + fromDate: z.string().optional(), + toDate: z.string().optional(), + regions: z.enum(monitorRegions).or(z.string()).array().optional(), + interval: z.int().optional(), +}); + +const icmpUptimeShape = z.object({ + interval: z.coerce.date(), + success: z.int(), + degraded: z.int(), + error: z.int(), +}); + export const TINYBIRD_DEFAULT_URL = "https://api.tinybird.co"; /** @@ -1178,6 +1247,457 @@ export class OSTinybird { }); } + public get icmpListDaily() { + return this.tb.buildPipe({ + pipe: "endpoint__icmp_list_1d__v0", + parameters: z.object({ + monitorId: z.string(), + fromDate: z.int().optional(), + toDate: z.int().optional(), + }), + data: z.object({ + type: z.literal("icmp").prefault("icmp"), + id: z.string().nullable(), + latency: z.int(), + latencyMin: z.int().prefault(0), + latencyMax: z.int().prefault(0), + packetsSent: z.int().prefault(0), + packetsReceived: z.int().prefault(0), + monitorId: z.coerce.string(), + requestStatus: z.enum(["error", "success", "degraded"]).nullable(), + region: z.enum(monitorRegions).or(z.string()), + cronTimestamp: z.int(), + trigger: z.enum(triggers).nullable().prefault("cron"), + timestamp: z.number(), + }), + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get icmpListWeekly() { + return this.tb.buildPipe({ + pipe: "endpoint__icmp_list_7d__v0", + parameters: z.object({ + monitorId: z.string(), + fromDate: z.int().optional(), + toDate: z.int().optional(), + }), + data: z.object({ + type: z.literal("icmp").prefault("icmp"), + id: z.string().nullable(), + latency: z.int(), + latencyMin: z.int().prefault(0), + latencyMax: z.int().prefault(0), + packetsSent: z.int().prefault(0), + packetsReceived: z.int().prefault(0), + monitorId: z.coerce.string(), + requestStatus: z.enum(["error", "success", "degraded"]).nullable(), + region: z.enum(monitorRegions).or(z.string()), + cronTimestamp: z.int(), + trigger: z.enum(triggers).nullable().prefault("cron"), + timestamp: z.number(), + }), + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get icmpListBiweekly() { + return this.tb.buildPipe({ + pipe: "endpoint__icmp_list_14d__v0", + parameters: z.object({ + monitorId: z.string(), + fromDate: z.int().optional(), + toDate: z.int().optional(), + }), + data: z.object({ + type: z.literal("icmp").prefault("icmp"), + id: z.string().nullable(), + latency: z.int(), + latencyMin: z.int().prefault(0), + latencyMax: z.int().prefault(0), + packetsSent: z.int().prefault(0), + packetsReceived: z.int().prefault(0), + monitorId: z.coerce.string(), + requestStatus: z.enum(["error", "success", "degraded"]).nullable(), + region: z.enum(monitorRegions).or(z.string()), + cronTimestamp: z.int(), + trigger: z.enum(triggers).nullable().prefault("cron"), + timestamp: z.number(), + }), + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get icmpGetBiweekly() { + return this.tb.buildPipe({ + pipe: "endpoint__icmp_get_14d__v0", + parameters: z.object({ + id: z.string().nullable(), + monitorId: z.string(), + }), + data: z.object({ + type: z.literal("icmp").prefault("icmp"), + id: z.string().nullable(), + uri: z.string(), + latency: z.int(), + latencyMin: z.int().prefault(0), + latencyMax: z.int().prefault(0), + packetsSent: z.int().prefault(0), + packetsReceived: z.int().prefault(0), + monitorId: z.coerce.string(), + error: z.coerce.boolean(), + region: z.enum(monitorRegions).or(z.string()), + cronTimestamp: z.int(), + trigger: z.enum(triggers).nullable().prefault("cron"), + timestamp: z.number(), + requestStatus: z.enum(["error", "success", "degraded"]).nullable(), + errorMessage: z.string().nullable(), + }), + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get icmpMetricsDaily() { + return this.tb.buildPipe({ + pipe: "endpoint__icmp_metrics_1d__v0", + parameters: z.object({ + interval: z.int().optional(), + regions: z.array(z.enum(monitorRegions).or(z.string())).optional(), + monitorId: z.string(), + }), + data: icmpMetricsShape, + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get icmpMetricsWeekly() { + return this.tb.buildPipe({ + pipe: "endpoint__icmp_metrics_7d__v0", + parameters: z.object({ + interval: z.int().optional(), + regions: z.array(z.enum(monitorRegions).or(z.string())).optional(), + monitorId: z.string(), + }), + data: icmpMetricsShape, + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get icmpMetricsBiweekly() { + return this.tb.buildPipe({ + pipe: "endpoint__icmp_metrics_14d__v0", + parameters: z.object({ + interval: z.int().optional(), + regions: z.array(z.enum(monitorRegions).or(z.string())).optional(), + monitorId: z.string(), + }), + data: icmpMetricsShape, + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get icmpMetrics30d() { + return this.tb.buildPipe({ + pipe: "endpoint__icmp_metrics_30d__v0", + parameters: z.object({ + interval: z.int().optional(), + regions: z.array(z.enum(monitorRegions).or(z.string())).optional(), + monitorId: z.string(), + }), + data: icmpMetricsShape, + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get icmpMetrics90d() { + return this.tb.buildPipe({ + pipe: "endpoint__icmp_metrics_90d__v0", + parameters: z.object({ + interval: z.int().optional(), + regions: z.array(z.enum(monitorRegions).or(z.string())).optional(), + monitorId: z.string(), + }), + data: icmpMetricsShape, + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get icmpMetricsByIntervalDaily() { + return this.tb.buildPipe({ + pipe: "endpoint__icmp_metrics_by_interval_1d__v0", + parameters: icmpMetricsByIntervalParameters, + data: icmpMetricsByIntervalShape, + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get icmpMetricsByIntervalWeekly() { + return this.tb.buildPipe({ + pipe: "endpoint__icmp_metrics_by_interval_7d__v0", + parameters: icmpMetricsByIntervalParameters, + data: icmpMetricsByIntervalShape, + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get icmpMetricsByIntervalBiweekly() { + return this.tb.buildPipe({ + pipe: "endpoint__icmp_metrics_by_interval_14d__v0", + parameters: icmpMetricsByIntervalParameters, + data: icmpMetricsByIntervalShape, + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get icmpMetricsByInterval30d() { + return this.tb.buildPipe({ + pipe: "endpoint__icmp_metrics_by_interval_30d__v0", + parameters: icmpMetricsByIntervalParameters, + data: icmpMetricsByIntervalShape, + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get icmpMetricsByInterval90d() { + return this.tb.buildPipe({ + pipe: "endpoint__icmp_metrics_by_interval_90d__v0", + parameters: icmpMetricsByIntervalParameters, + data: icmpMetricsByIntervalShape, + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get icmpMetricsLatency1d() { + return this.tb.buildPipe({ + pipe: "endpoint__icmp_metrics_latency_1d__v0", + parameters: z.object({ + monitorId: z.string(), + regions: z.array(z.enum(monitorRegions).or(z.string())).optional(), + fromDate: z.string().optional(), + toDate: z.string().optional(), + }), + data: icmpMetricsLatencyShape, + }); + } + + public get icmpMetricsLatency7d() { + return this.tb.buildPipe({ + pipe: "endpoint__icmp_metrics_latency_7d__v0", + parameters: z.object({ + monitorId: z.string(), + fromDate: z.string().optional(), + toDate: z.string().optional(), + }), + data: icmpMetricsLatencyShape, + }); + } + + public get icmpMetricsLatency30d() { + return this.tb.buildPipe({ + pipe: "endpoint__icmp_metrics_latency_30d__v0", + parameters: z.object({ + monitorId: z.string(), + fromDate: z.string().optional(), + toDate: z.string().optional(), + }), + data: icmpMetricsLatencyShape, + }); + } + + public get icmpMetricsLatency90d() { + return this.tb.buildPipe({ + pipe: "endpoint__icmp_metrics_latency_90d__v0", + parameters: z.object({ + monitorId: z.string(), + fromDate: z.string().optional(), + toDate: z.string().optional(), + }), + data: icmpMetricsLatencyShape, + }); + } + + public get icmpMetricsLatency1dMulti() { + return this.tb.buildPipe({ + pipe: "endpoint__icmp_metrics_latency_1d_multi__v0", + parameters: z.object({ + monitorIds: z.string().array().min(1), + fromDate: z.string().optional(), + toDate: z.string().optional(), + }), + data: z.object({ + timestamp: z.int(), + monitorId: z.coerce.string(), + p50Latency: z.int(), + p75Latency: z.int(), + p90Latency: z.int(), + p95Latency: z.int(), + p99Latency: z.int(), + }), + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get icmpStatus45d() { + return this.tb.buildPipe({ + pipe: "endpoint__icmp_status_45d__v0", + parameters: z.object({ + monitorIds: z.string().array(), + days: z.int().max(45).optional(), + }), + data: z.object({ + day: z.string().transform((val) => { + // That's a hack because clickhouse return the date in UTC but in shitty format (2021-09-01 00:00:00) + return new Date(`${val} GMT`).toISOString(); + }), + count: z.number().prefault(0), + ok: z.number().prefault(0), + degraded: z.number().prefault(0), + error: z.number().prefault(0), + monitorId: z.coerce.string(), + }), + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get icmpUptimeWeekly() { + return this.tb.buildPipe({ + pipe: "endpoint__icmp_uptime_7d__v0", + parameters: icmpUptimeParameters, + data: icmpUptimeShape, + }); + } + + public get icmpUptime30d() { + return this.tb.buildPipe({ + pipe: "endpoint__icmp_uptime_30d__v0", + parameters: icmpUptimeParameters, + data: icmpUptimeShape, + }); + } + + public get icmpUptime90d() { + return this.tb.buildPipe({ + pipe: "endpoint__icmp_uptime_90d__v0", + parameters: icmpUptimeParameters, + data: icmpUptimeShape, + }); + } + + public get icmpGlobalMetricsDaily() { + return this.tb.buildPipe({ + pipe: "endpoint__icmp_metrics_global_1d__v0", + parameters: z.object({ + monitorIds: z.string().array(), + }), + data: z.object({ + minLatency: z.int(), + maxLatency: z.int(), + p50Latency: z.int(), + p75Latency: z.int(), + p90Latency: z.int(), + p95Latency: z.int(), + p99Latency: z.int(), + lastTimestamp: z.int(), + count: z.int(), + monitorId: z.coerce.string(), + }), + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get icmpWorkspace30d() { + return this.tb.buildPipe({ + pipe: "endpoint__icmp_workspace_30d__v0", + parameters: z.object({ + workspaceId: z.string(), + }), + data: z.object({ + day: z + .string() + .transform((val) => new Date(`${val} GMT`).toISOString()), + count: z.int(), + }), + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get icmpGetMonthly() { + return this.tb.buildPipe({ + pipe: "endpoint__icmp_get_30d__v0", + parameters: z.object({ + monitorId: z.string(), + region: z.enum(monitorRegions).or(z.string()).optional(), + cronTimestamp: z.int().optional(), + }), + data: z.object({ + type: z.literal("icmp").prefault("icmp"), + id: z.string().nullable(), + uri: z.string(), + latency: z.int(), + latencyMin: z.int().prefault(0), + latencyMax: z.int().prefault(0), + packetsSent: z.int().prefault(0), + packetsReceived: z.int().prefault(0), + monitorId: z.coerce.string(), + error: z.coerce.boolean(), + region: z.enum(monitorRegions).or(z.string()), + cronTimestamp: z.int(), + trigger: z.enum(triggers).nullable().prefault("cron"), + timestamp: z.number(), + requestStatus: z.enum(["error", "success", "degraded"]).nullable(), + errorMessage: z.string().nullable(), + workspaceId: z.coerce.string(), + }), + // REMINDER: cache the result for accessing the data for a check as it won't change + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get icmpStatusWeekly() { + return this.tb.buildPipe({ + pipe: "endpoint__icmp_status_7d__v0", + parameters: z.object({ + monitorId: z.string(), + }), + data: z.object({ + day: z.string().transform((val) => { + // That's a hack because clickhouse return the date in UTC but in shitty format (2021-09-01 00:00:00) + return new Date(`${val} GMT`).toISOString(); + }), + count: z.number().prefault(0), + ok: z.number().prefault(0), + }), + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get icmpMetricsByRegionDaily() { + return this.tb.buildPipe({ + pipe: "endpoint__icmp_metrics_by_region_1d__v0", + parameters: icmpMetricsByRegionParameters, + data: icmpMetricsByRegionShape, + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get icmpMetricsByRegionWeekly() { + return this.tb.buildPipe({ + pipe: "endpoint__icmp_metrics_by_region_7d__v0", + parameters: icmpMetricsByRegionParameters, + data: icmpMetricsByRegionShape, + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get icmpMetricsByRegionBiweekly() { + return this.tb.buildPipe({ + pipe: "endpoint__icmp_metrics_by_region_14d__v0", + parameters: icmpMetricsByRegionParameters, + data: icmpMetricsByRegionShape, + opts: { next: { revalidate: REVALIDATE } }, + }); + } + /** * Region + timestamp metrics (quantiles) – aggregated by interval. * NOTE: The Tinybird pipe returns one row per region & interval with latency quantiles. diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 5561ea7dd..ec9a4400e 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -6,6 +6,8 @@ export { type TcpPayload, DNSPayloadSchema, type DNSPayload, + icmpPayloadSchema, + type IcmpPayload, } from "./payloads"; export { MONITOR_METHODS, diff --git a/packages/utils/src/payloads.ts b/packages/utils/src/payloads.ts index c1d412908..e213f4355 100644 --- a/packages/utils/src/payloads.ts +++ b/packages/utils/src/payloads.ts @@ -69,3 +69,23 @@ export const DNSPayloadSchema = z.object({ }); export type DNSPayload = z.infer; + +export const icmpPayloadSchema = z.object({ + status: z.enum(MONITOR_STATUSES), + workspaceId: z.string(), + uri: z.string(), + monitorId: z.string(), + cronTimestamp: z.number(), + timeout: z.number().prefault(45000), + degradedAfter: z.number().nullable(), + trigger: z.enum(["cron", "api"]).optional().nullable().prefault("cron"), + otelConfig: z + .object({ + endpoint: z.string(), + headers: z.record(z.string(), z.string()), + }) + .optional(), + retry: z.number().prefault(3), +}); + +export type IcmpPayload = z.infer;