diff --git a/apps/checker/checker/grpc.go b/apps/checker/checker/grpc.go new file mode 100644 index 00000000..b4f0028b --- /dev/null +++ b/apps/checker/checker/grpc.go @@ -0,0 +1,349 @@ +package checker + +import ( + "context" + "crypto/tls" + "fmt" + "net" + "strings" + "sync" + "time" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/health/grpc_health_v1" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/stats" + "google.golang.org/grpc/status" +) + +type GRPCTLSMode string + +const ( + GRPCTLSModeTLS GRPCTLSMode = "tls" + GRPCTLSModePlaintext GRPCTLSMode = "plaintext" + GRPCTLSModeTLSInsecure GRPCTLSMode = "tls_insecure" +) + +const ( + // Applied when a caller omits the timeout, matching the monitor column default. + grpcDefaultTimeout = 45_000 + + ServingStatusServing = "SERVING" + ServingStatusNotServing = "NOT_SERVING" + ServingStatusServiceUnknown = "SERVICE_UNKNOWN" + ServingStatusUnknown = "UNKNOWN" +) + +// GRPCResponseTiming is HTTP's phase shape verbatim: gRPC is HTTP/2, and reusing +// it lets calculateTiming and the dashboard waterfall work with no new code. +type GRPCResponseTiming = Timing + +func ParseGRPCTLSMode(value string) GRPCTLSMode { + switch value { + case string(GRPCTLSModePlaintext): + return GRPCTLSModePlaintext + case string(GRPCTLSModeTLSInsecure): + return GRPCTLSModeTLSInsecure + default: + return GRPCTLSModeTLS + } +} + +type GRPCResult struct { + Timing GRPCResponseTiming + ServingStatus string + Message string + Latency int64 + GRPCCode int64 + // Completed reports that the server answered. It cannot be derived from the + // error flag: NOT_SERVING is a failed check that completed perfectly well. + Completed bool + Healthy bool +} + +type GRPCResponse struct { + Region string `json:"region"` + ErrorMessage string `json:"errorMessage"` + JobType string `json:"jobType"` + ServingStatus string `json:"servingStatus"` + Service string `json:"service"` + RequestId int64 `json:"requestId,omitempty"` + WorkspaceID int64 `json:"workspaceId"` + MonitorID int64 `json:"monitorId"` + Timestamp int64 `json:"timestamp"` + Latency int64 `json:"latency"` + GRPCCode int64 `json:"grpcCode"` + Timing GRPCResponseTiming `json:"timing"` + Completed bool `json:"completed"` + Error uint8 `json:"error,omitempty"` +} + +func grpcNow() int64 { + return time.Now().UTC().UnixMilli() +} + +// grpcTimer guards the phase struct: grpc-go dials and reads on its own +// goroutines, so the probe goroutine cannot write it unsynchronised. +type grpcTimer struct { + mu sync.Mutex + timing GRPCResponseTiming +} + +func (g *grpcTimer) set(apply func(t *GRPCResponseTiming)) { + g.mu.Lock() + defer g.mu.Unlock() + apply(&g.timing) +} + +func (g *grpcTimer) snapshot() GRPCResponseTiming { + g.mu.Lock() + defer g.mu.Unlock() + return g.timing +} + +// CheckGRPC calls grpc.health.v1.Health/Check on target. A non-nil error means +// the RPC never completed; a completed call with a bad answer is reported +// through the result instead. +func CheckGRPC(timeoutMs int64, target, service string, mode GRPCTLSMode, md map[string]string) (GRPCResult, error) { + if timeoutMs <= 0 { + timeoutMs = grpcDefaultTimeout + } + + host, _, err := net.SplitHostPort(target) + if err != nil { + return GRPCResult{}, fmt.Errorf("invalid target %q: expected host:port", target) + } + + // One deadline for resolve, dial, handshake and call. Splitting them would + // let a stalled DNS lookup run past the timeout the user configured. + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutMs)*time.Millisecond) + defer cancel() + + timer := &grpcTimer{} + + conn, err := grpc.NewClient(target, + grpc.WithTransportCredentials(grpcCredentials(mode, host, timer)), + grpc.WithContextDialer(grpcDialer(timer)), + grpc.WithStatsHandler(&grpcStatsHandler{timer: timer}), + ) + if err != nil { + return GRPCResult{Timing: timer.snapshot()}, fmt.Errorf("dial error: %w", err) + } + defer conn.Close() + + callCtx := ctx + if len(md) > 0 { + callCtx = metadata.NewOutgoingContext(ctx, metadata.New(md)) + } + + start := time.Now() + res, err := grpc_health_v1.NewHealthClient(conn).Check( + callCtx, + &grpc_health_v1.HealthCheckRequest{Service: service}, + grpc.WaitForReady(false), + ) + latency := time.Since(start).Milliseconds() + timing := timer.snapshot() + + if err != nil { + code := status.Code(err) + result := GRPCResult{ + Timing: timing, + Latency: latency, + GRPCCode: int64(code), + } + + switch code { + case codes.Unimplemented: + // The server is up and talking gRPC; it just has no health service. + // Reporting this as "down" sends people hunting the wrong problem. + result.Completed = true + result.Message = "server does not implement grpc.health.v1.Health" + return result, nil + case codes.NotFound: + // grpc-go's reference health server answers an unregistered service + // with NOT_FOUND rather than the SERVICE_UNKNOWN enum value. + result.Completed = true + result.ServingStatus = ServingStatusServiceUnknown + result.Message = grpcUnknownServiceMessage(service) + return result, nil + } + + result.Latency = 0 + return result, fmt.Errorf("%s", grpcTransportMessage(code, err, timeoutMs)) + } + + servingStatus := grpcServingStatusName(res.GetStatus()) + result := GRPCResult{ + Timing: timing, + Latency: latency, + ServingStatus: servingStatus, + GRPCCode: int64(codes.OK), + Completed: true, + Healthy: servingStatus == ServingStatusServing, + } + + switch servingStatus { + case ServingStatusServing: + result.Message = fmt.Sprintf("Health check passed for %s", target) + case ServingStatusNotServing: + result.Message = "service reports NOT_SERVING" + case ServingStatusServiceUnknown: + result.Message = grpcUnknownServiceMessage(service) + default: + result.Message = "service reports UNKNOWN" + } + + return result, nil +} + +func grpcUnknownServiceMessage(service string) string { + if service == "" { + return "server does not know the requested service" + } + return fmt.Sprintf("server does not know service %q", service) +} + +func grpcServingStatusName(s grpc_health_v1.HealthCheckResponse_ServingStatus) string { + switch s { + case grpc_health_v1.HealthCheckResponse_SERVING: + return ServingStatusServing + case grpc_health_v1.HealthCheckResponse_NOT_SERVING: + return ServingStatusNotServing + case grpc_health_v1.HealthCheckResponse_SERVICE_UNKNOWN: + return ServingStatusServiceUnknown + default: + return ServingStatusUnknown + } +} + +// grpcTransportMessage maps a failed dial onto a fixed string. The raw error is +// never returned: a certificate failure quotes the peer's subject and chain, +// which would hand an internal service's identity back to the caller. +func grpcTransportMessage(code codes.Code, err error, timeoutMs int64) string { + if code == codes.DeadlineExceeded { + return fmt.Sprintf("timeout after %d ms", timeoutMs) + } + + raw := err.Error() + switch { + case isTLSFailure(raw): + return "certificate verification failed" + case strings.Contains(raw, "connection refused"): + return "connection refused" + case strings.Contains(raw, "no such host"): + return "resolve error" + case code == codes.Unavailable: + return "dial error" + } + + return fmt.Sprintf("grpc check failed with code %s", code) +} + +func isTLSFailure(message string) bool { + return strings.Contains(message, "x509:") || + strings.Contains(message, "tls:") || + strings.Contains(message, "certificate") +} + +func grpcCredentials(mode GRPCTLSMode, host string, timer *grpcTimer) credentials.TransportCredentials { + switch mode { + case GRPCTLSModePlaintext: + return insecure.NewCredentials() + case GRPCTLSModeTLSInsecure: + return &timingCredentials{ + //nolint:gosec // the tls_insecure mode exists so operators can probe + // self-signed internal services; it is opt-in per monitor. + TransportCredentials: credentials.NewTLS(&tls.Config{InsecureSkipVerify: true}), + timer: timer, + } + default: + return &timingCredentials{ + TransportCredentials: credentials.NewTLS(&tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}), + timer: timer, + } + } +} + +type timingCredentials struct { + credentials.TransportCredentials + timer *grpcTimer +} + +func (c *timingCredentials) ClientHandshake(ctx context.Context, authority string, raw net.Conn) (net.Conn, credentials.AuthInfo, error) { + c.timer.set(func(t *GRPCResponseTiming) { t.TlsHandshakeStart = grpcNow() }) + conn, info, err := c.TransportCredentials.ClientHandshake(ctx, authority, raw) + if err == nil { + c.timer.set(func(t *GRPCResponseTiming) { t.TlsHandshakeDone = grpcNow() }) + } + + return conn, info, err +} + +func (c *timingCredentials) Clone() credentials.TransportCredentials { + return &timingCredentials{TransportCredentials: c.TransportCredentials.Clone(), timer: c.timer} +} + +// grpcDialer resolves and connects by hand so the two phases can be timed +// apart, the way httptrace splits them for HTTP. +func grpcDialer(timer *grpcTimer) func(context.Context, string) (net.Conn, error) { + return func(ctx context.Context, addr string) (net.Conn, error) { + host, port, err := net.SplitHostPort(addr) + if err != nil { + return nil, err + } + + timer.set(func(t *GRPCResponseTiming) { t.DnsStart = grpcNow() }) + ips, err := net.DefaultResolver.LookupIPAddr(ctx, host) + if err != nil { + return nil, err + } + if len(ips) == 0 { + return nil, fmt.Errorf("no such host %s", host) + } + timer.set(func(t *GRPCResponseTiming) { t.DnsDone = grpcNow() }) + + timer.set(func(t *GRPCResponseTiming) { t.ConnectStart = grpcNow() }) + var dialer net.Dialer + conn, err := dialer.DialContext(ctx, "tcp", net.JoinHostPort(ips[0].String(), port)) + if err != nil { + return nil, err + } + timer.set(func(t *GRPCResponseTiming) { t.ConnectDone = grpcNow() }) + + return conn, nil + } +} + +// grpcStatsHandler times the call itself: headers back is the first byte, the +// message that follows is the transfer. +type grpcStatsHandler struct { + timer *grpcTimer +} + +func (h *grpcStatsHandler) TagRPC(ctx context.Context, _ *stats.RPCTagInfo) context.Context { + return ctx +} + +func (h *grpcStatsHandler) HandleRPC(_ context.Context, s stats.RPCStats) { + switch s.(type) { + case *stats.OutHeader: + h.timer.set(func(t *GRPCResponseTiming) { t.FirstByteStart = grpcNow() }) + case *stats.InHeader: + h.timer.set(func(t *GRPCResponseTiming) { + t.FirstByteDone = grpcNow() + t.TransferStart = grpcNow() + }) + case *stats.InPayload: + h.timer.set(func(t *GRPCResponseTiming) { t.TransferDone = grpcNow() }) + } +} + +func (h *grpcStatsHandler) TagConn(ctx context.Context, _ *stats.ConnTagInfo) context.Context { + return ctx +} + +func (h *grpcStatsHandler) HandleConn(context.Context, stats.ConnStats) {} diff --git a/apps/checker/checker/grpc_internal_test.go b/apps/checker/checker/grpc_internal_test.go new file mode 100644 index 00000000..4bf45b0e --- /dev/null +++ b/apps/checker/checker/grpc_internal_test.go @@ -0,0 +1,109 @@ +package checker + +import ( + "errors" + "testing" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +func TestParseGRPCTLSMode(t *testing.T) { + cases := map[string]GRPCTLSMode{ + "plaintext": GRPCTLSModePlaintext, + "tls_insecure": GRPCTLSModeTLSInsecure, + "tls": GRPCTLSModeTLS, + "": GRPCTLSModeTLS, + "nonsense": GRPCTLSModeTLS, + } + + for input, want := range cases { + if got := ParseGRPCTLSMode(input); got != want { + t.Errorf("ParseGRPCTLSMode(%q) = %q, want %q", input, got, want) + } + } +} + +func TestGRPCTransportMessageNeverEchoesTheRawError(t *testing.T) { + cases := []struct { + name string + code codes.Code + err error + want string + }{ + { + name: "certificate", + code: codes.Unavailable, + err: status.Error(codes.Unavailable, `x509: certificate signed by unknown authority (subject CN=internal-billing)`), + want: "certificate verification failed", + }, + { + name: "tls record", + code: codes.Unavailable, + err: status.Error(codes.Unavailable, `tls: first record does not look like a TLS handshake`), + want: "certificate verification failed", + }, + { + name: "refused", + code: codes.Unavailable, + err: status.Error(codes.Unavailable, "connection error: connection refused"), + want: "connection refused", + }, + { + name: "resolve", + code: codes.Unavailable, + err: status.Error(codes.Unavailable, `lookup nope.invalid: no such host`), + want: "resolve error", + }, + { + name: "deadline", + code: codes.DeadlineExceeded, + err: status.Error(codes.DeadlineExceeded, "context deadline exceeded"), + want: "timeout after 1500 ms", + }, + { + name: "other", + code: codes.Internal, + err: errors.New("boom"), + want: "grpc check failed with code Internal", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := grpcTransportMessage(tc.code, tc.err, 1500) + if got != tc.want { + t.Fatalf("got %q, want %q", got, tc.want) + } + if got == tc.err.Error() { + t.Fatal("the raw error must never be returned verbatim") + } + }) + } +} + +func TestGRPCUnknownServiceMessage(t *testing.T) { + if got := grpcUnknownServiceMessage(""); got != "server does not know the requested service" { + t.Fatalf("unexpected message %q", got) + } + if got := grpcUnknownServiceMessage("pkg.Svc"); got != `server does not know service "pkg.Svc"` { + t.Fatalf("unexpected message %q", got) + } +} + +func TestGRPCTimerIsSafeForConcurrentPhases(t *testing.T) { + timer := &grpcTimer{} + + done := make(chan struct{}) + go func() { + timer.set(func(t *GRPCResponseTiming) { t.DnsStart = 1 }) + close(done) + }() + timer.set(func(t *GRPCResponseTiming) { t.ConnectStart = 2 }) + <-done + + snapshot := timer.snapshot() + if snapshot.DnsStart != 1 || snapshot.ConnectStart != 2 { + t.Fatalf("unexpected snapshot %+v", snapshot) + } +} diff --git a/apps/checker/checker/grpc_test.go b/apps/checker/checker/grpc_test.go new file mode 100644 index 00000000..fbc6160e --- /dev/null +++ b/apps/checker/checker/grpc_test.go @@ -0,0 +1,248 @@ +package checker_test + +import ( + "strings" + "testing" + "time" + + "github.com/openstatushq/openstatus/apps/checker/checker" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/health/grpc_health_v1" +) + +func TestCheckGRPCServing(t *testing.T) { + target := newHealthServer(t, healthServerOptions{ + statuses: map[string]grpc_health_v1.HealthCheckResponse_ServingStatus{ + "": grpc_health_v1.HealthCheckResponse_SERVING, + }, + }) + + res, err := checker.CheckGRPC(5000, target, "", checker.GRPCTLSModePlaintext, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !res.Completed || !res.Healthy { + t.Fatalf("expected a healthy completed check, got %+v", res) + } + if res.ServingStatus != checker.ServingStatusServing { + t.Fatalf("expected SERVING, got %q", res.ServingStatus) + } + if res.GRPCCode != int64(codes.OK) { + t.Fatalf("expected code OK, got %d", res.GRPCCode) + } + if res.Timing.FirstByteStart == 0 || res.Timing.FirstByteDone == 0 { + t.Fatalf("expected the call phase to be timed, got %+v", res.Timing) + } + if res.Timing.TlsHandshakeStart != 0 || res.Timing.TlsHandshakeDone != 0 { + t.Fatalf("plaintext must not record a TLS phase, got %+v", res.Timing) + } +} + +func TestCheckGRPCNamedService(t *testing.T) { + target := newHealthServer(t, healthServerOptions{ + statuses: map[string]grpc_health_v1.HealthCheckResponse_ServingStatus{ + "checkout.v1.CheckoutService": grpc_health_v1.HealthCheckResponse_SERVING, + }, + }) + + res, err := checker.CheckGRPC(5000, target, "checkout.v1.CheckoutService", checker.GRPCTLSModePlaintext, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !res.Healthy { + t.Fatalf("expected the named service to be healthy, got %+v", res) + } +} + +func TestCheckGRPCNotServing(t *testing.T) { + // A loopback call rounds to 0 ms, which cannot be told apart from a latency + // that was discarded. The delay makes the assertion mean something. + target := newHealthServer(t, healthServerOptions{ + statuses: map[string]grpc_health_v1.HealthCheckResponse_ServingStatus{ + "": grpc_health_v1.HealthCheckResponse_NOT_SERVING, + }, + delay: 30 * time.Millisecond, + }) + + res, err := checker.CheckGRPC(5000, target, "", checker.GRPCTLSModePlaintext, nil) + if err != nil { + t.Fatalf("NOT_SERVING is an answer, not a transport failure: %v", err) + } + if !res.Completed { + t.Fatal("expected Completed to be true") + } + if res.Healthy { + t.Fatal("expected Healthy to be false") + } + if res.ServingStatus != checker.ServingStatusNotServing { + t.Fatalf("expected NOT_SERVING, got %q", res.ServingStatus) + } + if res.Latency < 20 { + t.Fatalf("a completed call must keep its measured latency, got %d", res.Latency) + } + if res.Timing.FirstByteDone == 0 { + t.Fatalf("a completed call must keep its timing, got %+v", res.Timing) + } + if res.Message != "service reports NOT_SERVING" { + t.Fatalf("unexpected message %q", res.Message) + } +} + +func TestCheckGRPCServiceUnknown(t *testing.T) { + target := newHealthServer(t, healthServerOptions{ + statuses: map[string]grpc_health_v1.HealthCheckResponse_ServingStatus{ + "": grpc_health_v1.HealthCheckResponse_SERVING, + }, + }) + + res, err := checker.CheckGRPC(5000, target, "missing.Service", checker.GRPCTLSModePlaintext, nil) + if err != nil { + t.Fatalf("an unknown service is an answer, not a transport failure: %v", err) + } + if !res.Completed || res.Healthy { + t.Fatalf("expected a completed unhealthy check, got %+v", res) + } + if res.ServingStatus != checker.ServingStatusServiceUnknown { + t.Fatalf("expected SERVICE_UNKNOWN, got %q", res.ServingStatus) + } + if !strings.Contains(res.Message, "missing.Service") { + t.Fatalf("expected the message to name the service, got %q", res.Message) + } +} + +func TestCheckGRPCUnimplemented(t *testing.T) { + target := newHealthServer(t, healthServerOptions{omitHealthService: true}) + + res, err := checker.CheckGRPC(5000, target, "", checker.GRPCTLSModePlaintext, nil) + if err != nil { + t.Fatalf("a reachable server without the health service still completed: %v", err) + } + if !res.Completed || res.Healthy { + t.Fatalf("expected a completed unhealthy check, got %+v", res) + } + if res.GRPCCode != int64(codes.Unimplemented) { + t.Fatalf("expected UNIMPLEMENTED, got code %d", res.GRPCCode) + } + if res.Message != "server does not implement grpc.health.v1.Health" { + t.Fatalf("unexpected message %q", res.Message) + } + if res.ServingStatus != "" { + t.Fatalf("no serving status can be known here, got %q", res.ServingStatus) + } +} + +func TestCheckGRPCConnectionRefused(t *testing.T) { + res, err := checker.CheckGRPC(5000, closedPort(t), "", checker.GRPCTLSModePlaintext, nil) + if err == nil { + t.Fatal("expected a transport failure") + } + if res.Completed { + t.Fatal("expected Completed to be false") + } + if res.Latency != 0 { + t.Fatalf("a call that never completed has no latency, got %d", res.Latency) + } + if err.Error() != "connection refused" { + t.Fatalf("unexpected message %q", err.Error()) + } +} + +func TestCheckGRPCTimeout(t *testing.T) { + target := newHealthServer(t, healthServerOptions{ + statuses: map[string]grpc_health_v1.HealthCheckResponse_ServingStatus{ + "": grpc_health_v1.HealthCheckResponse_SERVING, + }, + delay: 2 * time.Second, + }) + + res, err := checker.CheckGRPC(200, target, "", checker.GRPCTLSModePlaintext, nil) + if err == nil { + t.Fatal("expected a deadline failure") + } + if res.Completed { + t.Fatal("expected Completed to be false") + } + if !strings.HasPrefix(err.Error(), "timeout after") { + t.Fatalf("unexpected message %q", err.Error()) + } +} + +func TestCheckGRPCInvalidTarget(t *testing.T) { + if _, err := checker.CheckGRPC(5000, "api.example.com", "", checker.GRPCTLSModeTLS, nil); err == nil { + t.Fatal("expected a portless target to be rejected") + } +} + +func TestCheckGRPCTLSInsecureAcceptsSelfSigned(t *testing.T) { + cert := newSelfSignedCert(t) + target := newHealthServer(t, healthServerOptions{ + statuses: map[string]grpc_health_v1.HealthCheckResponse_ServingStatus{ + "": grpc_health_v1.HealthCheckResponse_SERVING, + }, + cert: &cert, + }) + + res, err := checker.CheckGRPC(5000, target, "", checker.GRPCTLSModeTLSInsecure, nil) + if err != nil { + t.Fatalf("tls_insecure must accept a self-signed certificate: %v", err) + } + if !res.Healthy { + t.Fatalf("expected SERVING, got %+v", res) + } + if res.Timing.TlsHandshakeStart == 0 || res.Timing.TlsHandshakeDone == 0 { + t.Fatalf("expected the TLS phase to be timed, got %+v", res.Timing) + } +} + +func TestCheckGRPCTLSRejectsSelfSigned(t *testing.T) { + cert := newSelfSignedCert(t) + target := newHealthServer(t, healthServerOptions{ + statuses: map[string]grpc_health_v1.HealthCheckResponse_ServingStatus{ + "": grpc_health_v1.HealthCheckResponse_SERVING, + }, + cert: &cert, + }) + + res, err := checker.CheckGRPC(5000, target, "", checker.GRPCTLSModeTLS, nil) + if err == nil { + t.Fatal("tls mode must reject a self-signed certificate") + } + if res.Completed { + t.Fatal("expected Completed to be false") + } + if err.Error() != "certificate verification failed" { + t.Fatalf("unexpected message %q", err.Error()) + } + if strings.Contains(err.Error(), "openstatus-test") || strings.Contains(err.Error(), "x509") { + t.Fatalf("the certificate subject must never reach the caller: %q", err.Error()) + } +} + +func TestCheckGRPCTLSAgainstPlaintextServer(t *testing.T) { + target := newHealthServer(t, healthServerOptions{ + statuses: map[string]grpc_health_v1.HealthCheckResponse_ServingStatus{ + "": grpc_health_v1.HealthCheckResponse_SERVING, + }, + }) + + if _, err := checker.CheckGRPC(2000, target, "", checker.GRPCTLSModeTLS, nil); err == nil { + t.Fatal("expected TLS against a plaintext server to fail") + } +} + +func TestCheckGRPCMetadataReachesTheServer(t *testing.T) { + target := newHealthServer(t, healthServerOptions{ + statuses: map[string]grpc_health_v1.HealthCheckResponse_ServingStatus{ + "": grpc_health_v1.HealthCheckResponse_SERVING, + }, + }) + + res, err := checker.CheckGRPC(5000, target, "", checker.GRPCTLSModePlaintext, + map[string]string{"authorization": "Bearer token"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !res.Healthy { + t.Fatalf("expected SERVING, got %+v", res) + } +} diff --git a/apps/checker/checker/testserver_test.go b/apps/checker/checker/testserver_test.go new file mode 100644 index 00000000..f765dd90 --- /dev/null +++ b/apps/checker/checker/testserver_test.go @@ -0,0 +1,123 @@ +package checker_test + +import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "math/big" + "net" + "testing" + "time" + + "google.golang.org/grpc" + "google.golang.org/grpc/credentials" + "google.golang.org/grpc/health" + "google.golang.org/grpc/health/grpc_health_v1" +) + +// newSelfSignedCert mints a certificate for localhost. Nothing in the Go tier +// ships a TLS fixture, and the tls/tls_insecure modes cannot be exercised +// without a server presenting a certificate the system store will reject. +func newSelfSignedCert(t *testing.T) tls.Certificate { + t.Helper() + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatalf("generate key: %v", err) + } + + template := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "openstatus-test"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + DNSNames: []string{"localhost"}, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")}, + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + + der, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key) + if err != nil { + t.Fatalf("create certificate: %v", err) + } + + return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key} +} + +type healthServerOptions struct { + // statuses registers a serving status per service name. The empty key is + // the overall server status. + statuses map[string]grpc_health_v1.HealthCheckResponse_ServingStatus + // omitHealthService starts a gRPC server with no health service registered, + // which is what a real server missing the registration looks like. + omitHealthService bool + cert *tls.Certificate + delay time.Duration +} + +// newHealthServer starts a health server on a loopback port and returns its +// host:port. The listener is closed when the test finishes. +func newHealthServer(t *testing.T, opts healthServerOptions) string { + t.Helper() + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + + var serverOpts []grpc.ServerOption + if opts.cert != nil { + serverOpts = append(serverOpts, grpc.Creds(credentials.NewServerTLSFromCert(opts.cert))) + } + if opts.delay > 0 { + delay := opts.delay + serverOpts = append(serverOpts, grpc.UnaryInterceptor( + func(ctx context.Context, req any, _ *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) { + select { + case <-time.After(delay): + case <-ctx.Done(): + return nil, ctx.Err() + } + return handler(ctx, req) + }, + )) + } + + server := grpc.NewServer(serverOpts...) + + if !opts.omitHealthService { + healthServer := health.NewServer() + for service, status := range opts.statuses { + healthServer.SetServingStatus(service, status) + } + grpc_health_v1.RegisterHealthServer(server, healthServer) + } + + go func() { + _ = server.Serve(listener) + }() + t.Cleanup(server.Stop) + + return listener.Addr().String() +} + +// closedPort returns a host:port nothing is listening on. +func closedPort(t *testing.T) string { + t.Helper() + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + addr := listener.Addr().String() + if err := listener.Close(); err != nil { + t.Fatalf("close: %v", err) + } + + return addr +} diff --git a/apps/checker/cmd/server/main.go b/apps/checker/cmd/server/main.go index ed17e4ac..60ef72dc 100644 --- a/apps/checker/cmd/server/main.go +++ b/apps/checker/cmd/server/main.go @@ -21,9 +21,9 @@ import ( "github.com/rs/zerolog/log" "go.opentelemetry.io/contrib/bridges/otelslog" // otelz "go.opentelemetry.io/contrib/bridges/otelzerolog" - "go.opentelemetry.io/otel/log/global" "go.opentelemetry.io/otel/attribute" otlploghttp "go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp" + "go.opentelemetry.io/otel/log/global" sdklog "go.opentelemetry.io/otel/sdk/log" "go.opentelemetry.io/otel/sdk/resource" semconv "go.opentelemetry.io/otel/semconv/v1.39.0" @@ -144,7 +144,7 @@ func Logger() gin.HandlerFunc { if shouldSample(event) { attrs := MapToAttrs(event) - slog.LogAttrs(c.Request.Context(),slog.LevelInfo, "request done", attrs...) + slog.LogAttrs(c.Request.Context(), slog.LevelInfo, "request done", attrs...) } log.Debug(). @@ -221,7 +221,6 @@ func main() { logProvider := sdklog.NewLoggerProvider( sdklog.WithResource(res), sdklog.WithProcessor(sdklog.NewBatchProcessor(exporter)), - ) defer logProvider.Shutdown(ctx) @@ -250,10 +249,12 @@ func main() { router.POST("/checker/tcp", h.TCPHandler) router.POST("/checker/dns", h.DNSHandler) router.POST("/checker/icmp", h.ICMPHandler) + router.POST("/checker/grpc", h.GRPCHandler) 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.POST("/grpc/:region", h.GRPCHandlerRegion) 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 46ca4305..6a2cd744 100644 --- a/apps/checker/go.mod +++ b/apps/checker/go.mod @@ -24,6 +24,7 @@ require ( 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/grpc v1.79.1 google.golang.org/protobuf v1.36.11 ) @@ -81,6 +82,5 @@ require ( google.golang.org/genproto v0.0.0-20260226221140-a57be14db171 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect - google.golang.org/grpc v1.79.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/apps/checker/handlers/grpc.go b/apps/checker/handlers/grpc.go new file mode 100644 index 00000000..97e8de4c --- /dev/null +++ b/apps/checker/handlers/grpc.go @@ -0,0 +1,395 @@ +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 GRPCData 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"` + Service string `json:"service,omitempty"` + ServingStatus string `json:"servingStatus,omitempty"` + 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"` + CronTimestamp int64 `json:"cronTimestamp"` + GRPCCode int64 `json:"grpcCode"` + + Error uint8 `json:"error"` +} + +func grpcCheck(req request.GRPCCheckerRequest) (checker.GRPCResult, error) { + return checker.CheckGRPC( + req.Timeout, + req.URI, + req.Service, + checker.ParseGRPCTLSMode(req.TLS), + req.Headers, + ) +} + +func (h Handler) GRPCHandler(c *gin.Context) { + ctx := c.Request.Context() + dataSourceName := "grpc_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.GRPCCheckerRequest + 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": "grpc", + } + c.Set("event", t) + } + + var response checker.GRPCResponse + + var retry int + if req.Retry != 0 { + retry = int(req.Retry) + } else { + retry = 3 + } + + op := func() error { + res, err := grpcCheck(req) + if err != nil { + // Only a call that never reached the server is worth repeating. A + // server answering NOT_SERVING will answer the same three more times. + return fmt.Errorf("unable to check grpc %s", err) + } + + timingAsString, err := json.Marshal(res.Timing) + if err != nil { + return fmt.Errorf("error while parsing timing data %s: %w", req.URI, err) + } + + id, err := uuid.NewV7() + if err != nil { + return fmt.Errorf("error while generating uuid %w", err) + } + + timestamp := time.Now().UTC().UnixMilli() + degraded := res.Healthy && req.DegradedAfter > 0 && res.Latency > req.DegradedAfter + + requestStatus := "success" + switch { + case !res.Healthy: + requestStatus = "error" + case degraded: + requestStatus = "degraded" + } + + errorFlag := uint8(0) + errorMessage := "" + if !res.Healthy { + errorFlag = 1 + errorMessage = res.Message + } + + data := GRPCData{ + ID: id.String(), + WorkspaceID: workspaceId, + Timestamp: timestamp, + Error: errorFlag, + ErrorMessage: errorMessage, + Region: h.Region, + MonitorID: monitorId, + Timing: string(timingAsString), + Latency: res.Latency, + GRPCCode: res.GRPCCode, + ServingStatus: res.ServingStatus, + Service: req.Service, + CronTimestamp: req.CronTimestamp, + Trigger: trigger, + URI: req.URI, + RequestStatus: requestStatus, + } + + response = checker.GRPCResponse{ + Timestamp: timestamp, + Timing: res.Timing, + Latency: res.Latency, + GRPCCode: res.GRPCCode, + ServingStatus: res.ServingStatus, + Service: req.Service, + ErrorMessage: errorMessage, + Completed: res.Completed, + Error: errorFlag, + Region: h.Region, + JobType: "grpc", + } + + switch { + case !res.Healthy && req.Status != "error": + checker.UpdateStatus(ctx, checker.UpdateData{ + MonitorId: req.MonitorID, + Status: "error", + Message: res.Message, + Region: h.Region, + CronTimestamp: req.CronTimestamp, + Latency: res.Latency, + }) + case degraded && req.Status != "degraded": + checker.UpdateStatus(ctx, checker.UpdateData{ + MonitorId: req.MonitorID, + Status: "degraded", + Region: h.Region, + CronTimestamp: req.CronTimestamp, + Latency: res.Latency, + }) + case res.Healthy && !degraded && req.Status != "active": + checker.UpdateStatus(ctx, checker.UpdateData{ + MonitorId: req.MonitorID, + Status: "active", + Region: h.Region, + CronTimestamp: req.CronTimestamp, + Latency: res.Latency, + }) + } + + 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 := GRPCData{ + ID: id.String(), + WorkspaceID: workspaceId, + CronTimestamp: req.CronTimestamp, + Timestamp: time.Now().UTC().UnixMilli(), + ErrorMessage: err.Error(), + Region: h.Region, + MonitorID: monitorId, + Error: 1, + Trigger: trigger, + URI: req.URI, + Service: req.Service, + 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 = "grpc" + response.Region = h.Region + response.ErrorMessage = err.Error() + response.Error = 1 + } + + if req.OtelConfig.Endpoint != "" { + otelOS.RecordGRPCMetrics(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) GRPCHandlerRegion(c *gin.Context) { + ctx := c.Request.Context() + dataSourceName := "check_grpc_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.GRPCCheckerRequest + 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.GRPCResponse + + op := func() error { + timestamp := time.Now().UTC().UnixMilli() + res, err := grpcCheck(req) + if err != nil { + return fmt.Errorf("unable to check grpc %s", err) + } + + errorFlag := uint8(0) + errorMessage := "" + if !res.Healthy { + errorFlag = 1 + errorMessage = res.Message + } + + response = checker.GRPCResponse{ + Timestamp: timestamp, + Timing: res.Timing, + Latency: res.Latency, + GRPCCode: res.GRPCCode, + ServingStatus: res.ServingStatus, + Service: req.Service, + ErrorMessage: errorMessage, + Completed: res.Completed, + Error: errorFlag, + Region: h.Region, + JobType: "grpc", + } + + timingAsString, err := json.Marshal(res.Timing) + if err != nil { + return fmt.Errorf("error while parsing timing data %s: %w", req.URI, err) + } + + data := GRPCData{ + CronTimestamp: req.CronTimestamp, + Timestamp: timestamp, + Error: errorFlag, + ErrorMessage: errorMessage, + Region: h.Region, + Timing: string(timingAsString), + Latency: res.Latency, + GRPCCode: res.GRPCCode, + ServingStatus: res.ServingStatus, + Service: req.Service, + 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.JobType = "grpc" + response.Region = h.Region + response.ErrorMessage = err.Error() + response.Error = 1 + } + + if req.OtelConfig.Endpoint != "" { + otelOS.RecordGRPCMetrics(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/grpc_test.go b/apps/checker/handlers/grpc_test.go new file mode 100644 index 00000000..80dbf5c3 --- /dev/null +++ b/apps/checker/handlers/grpc_test.go @@ -0,0 +1,181 @@ +package handlers_test + +import ( + "encoding/json" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/openstatushq/openstatus/apps/checker/handlers" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/health" + "google.golang.org/grpc/health/grpc_health_v1" +) + +// grpcTestServer starts a plaintext health server and returns its host:port. +func grpcTestServer(t *testing.T, status grpc_health_v1.HealthCheckResponse_ServingStatus) string { + t.Helper() + + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + server := grpc.NewServer() + healthServer := health.NewServer() + healthServer.SetServingStatus("", status) + grpc_health_v1.RegisterHealthServer(server, healthServer) + + go func() { + _ = server.Serve(listener) + }() + t.Cleanup(server.Stop) + + return listener.Addr().String() +} + +func TestGRPCHandler_RejectsUnauthorized(t *testing.T) { + h := handlers.Handler{TbClient: testTinybird(t), Secret: "test", Region: "local"} + router := gin.New() + router.POST("/checker/grpc", h.GRPCHandler) + + w := httptest.NewRecorder() + r, _ := http.NewRequest(http.MethodPost, "/checker/grpc", strings.NewReader(`{"uri":"127.0.0.1:1"}`)) + r.Header.Set("Authorization", "Basic wrong") + router.ServeHTTP(w, r) + + assert.Equal(t, http.StatusUnauthorized, w.Code) +} + +func TestGRPCHandler_RejectsBadPayload(t *testing.T) { + h := handlers.Handler{TbClient: testTinybird(t), Secret: "test", Region: "local"} + router := gin.New() + router.POST("/checker/grpc", h.GRPCHandler) + + w := httptest.NewRecorder() + r, _ := http.NewRequest(http.MethodPost, "/checker/grpc", strings.NewReader(`{not json`)) + r.Header.Set("Authorization", "Basic test") + router.ServeHTTP(w, r) + + assert.Equal(t, http.StatusBadRequest, w.Code) +} + +func TestGRPCHandlerRegion_RejectsUnauthorized(t *testing.T) { + h := handlers.Handler{TbClient: testTinybird(t), Secret: "test", Region: "local"} + router := gin.New() + router.POST("/grpc/:region", h.GRPCHandlerRegion) + + w := httptest.NewRecorder() + r, _ := http.NewRequest(http.MethodPost, "/grpc/local", strings.NewReader(`{"uri":"127.0.0.1:1"}`)) + r.Header.Set("Authorization", "Basic wrong") + router.ServeHTTP(w, r) + + assert.Equal(t, http.StatusUnauthorized, w.Code) +} + +func TestGRPCHandler_ServingResponse(t *testing.T) { + target := grpcTestServer(t, grpc_health_v1.HealthCheckResponse_SERVING) + + h := handlers.Handler{TbClient: testTinybird(t), Secret: "test", Region: "local"} + router := gin.New() + router.POST("/checker/grpc", h.GRPCHandler) + + body := `{"uri":"` + target + `","tls":"plaintext","timeout":5000,"retry":1,"status":"active","workspaceId":"1","monitorId":"1"}` + w := httptest.NewRecorder() + r, _ := http.NewRequest(http.MethodPost, "/checker/grpc?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 + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &res)) + + assert.Equal(t, "grpc", res["jobType"]) + assert.Equal(t, "local", res["region"]) + assert.Equal(t, "SERVING", res["servingStatus"]) + assert.Equal(t, true, res["completed"]) + assert.NotContains(t, res, "error") +} + +// A NOT_SERVING answer is a failed check that completed. It must keep its +// serving status and its measured timing, and must not be retried. +func TestGRPCHandler_NotServingResponse(t *testing.T) { + target := grpcTestServer(t, grpc_health_v1.HealthCheckResponse_NOT_SERVING) + + h := handlers.Handler{TbClient: testTinybird(t), Secret: "test", Region: "local"} + router := gin.New() + router.POST("/checker/grpc", h.GRPCHandler) + + body := `{"uri":"` + target + `","tls":"plaintext","timeout":5000,"retry":1,"status":"error","workspaceId":"1","monitorId":"1"}` + w := httptest.NewRecorder() + r, _ := http.NewRequest(http.MethodPost, "/checker/grpc?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 + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &res)) + + assert.Equal(t, "grpc", res["jobType"]) + assert.Equal(t, "NOT_SERVING", res["servingStatus"]) + assert.Equal(t, true, res["completed"]) + assert.Equal(t, float64(1), res["error"]) + assert.Equal(t, "service reports NOT_SERVING", res["errorMessage"]) +} + +// 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 TestGRPCHandler_FailureResponseKeepsJobTypeAndRegion(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + closed := listener.Addr().String() + require.NoError(t, listener.Close()) + + h := handlers.Handler{TbClient: testTinybird(t), Secret: "test", Region: "local"} + router := gin.New() + router.POST("/checker/grpc", h.GRPCHandler) + + body := `{"uri":"` + closed + `","tls":"plaintext","timeout":500,"retry":1,"status":"error","workspaceId":"1","monitorId":"1"}` + w := httptest.NewRecorder() + r, _ := http.NewRequest(http.MethodPost, "/checker/grpc?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 + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &res)) + + assert.Equal(t, "grpc", res["jobType"], "jobType is the discriminator callers match on") + assert.Equal(t, "local", res["region"]) + assert.Equal(t, float64(1), res["error"]) + assert.Equal(t, false, res["completed"]) +} + +func TestGRPCHandlerRegion_ReturnsResult(t *testing.T) { + target := grpcTestServer(t, grpc_health_v1.HealthCheckResponse_SERVING) + + h := handlers.Handler{TbClient: testTinybird(t), Secret: "test", Region: "local"} + router := gin.New() + router.POST("/grpc/:region", h.GRPCHandlerRegion) + + body := `{"uri":"` + target + `","tls":"plaintext","timeout":5000}` + w := httptest.NewRecorder() + r, _ := http.NewRequest(http.MethodPost, "/grpc/local", 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 + require.NoError(t, json.Unmarshal(w.Body.Bytes(), &res)) + + assert.Equal(t, "grpc", res["jobType"]) + assert.Equal(t, "SERVING", res["servingStatus"]) +} diff --git a/apps/checker/handlers/otel_wiring_test.go b/apps/checker/handlers/otel_wiring_test.go index 4bbbf03e..8c37dddf 100644 --- a/apps/checker/handlers/otel_wiring_test.go +++ b/apps/checker/handlers/otel_wiring_test.go @@ -198,3 +198,33 @@ func TestDNSHandlerRegion_ExportsOTLPOnFailure(t *testing.T) { assert.Eventually(t, func() bool { return atomic.LoadInt64(count) > 0 }, 10*time.Second, 50*time.Millisecond, "expected an OTLP export on DNS failure") } + +func TestGRPCHandlerRegion_ExportsOTLPOnFailure(t *testing.T) { + otlp, count := countingOTLPServer(t) + + h := handlers.Handler{ + TbClient: testTinybird(t), + Secret: "test", + Region: "local", + } + router := gin.New() + router.POST("/grpc/:region", h.GRPCHandlerRegion) + + req := request.GRPCCheckerRequest{ + URI: "127.0.0.1:1", // connection refused + TLS: "plaintext", + Status: "active", + Timeout: 1000, + } + req.OtelConfig.Endpoint = otlp.URL + body, _ := json.Marshal(req) + + w := httptest.NewRecorder() + r, _ := http.NewRequest(http.MethodPost, "/grpc/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 gRPC failure") +} diff --git a/apps/checker/pkg/job/grpc_job.go b/apps/checker/pkg/job/grpc_job.go new file mode 100644 index 00000000..ee592d99 --- /dev/null +++ b/apps/checker/pkg/job/grpc_job.go @@ -0,0 +1,194 @@ +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" +) + +// GRPCPrivateRegionData represents the result of a gRPC monitor check +type GRPCPrivateRegionData struct { + ID string `json:"id"` + URI string `json:"uri"` + Service string `json:"service"` + ServingStatus string `json:"serving_status"` + RequestStatus string `json:"request_status"` + Message string `json:"message"` + Latency int64 `json:"latency"` + GRPCCode int64 `json:"grpc_code"` + Timestamp int64 `json:"timestamp"` + CronTimestamp int64 `json:"cron_timestamp"` + Error int `json:"error"` + Timing string `json:"timing"` +} + +func (jobRunner) GRPCJob(ctx context.Context, monitor *v1.GRPCMonitor, region string) (*GRPCPrivateRegionData, error) { + retry := monitor.Retry + if retry == 0 { + retry = 3 + } + + var degradedAfter int64 + if monitor.DegradedAt != nil { + degradedAfter = *monitor.DegradedAt + } + + req := grpcCheckerRequest(monitor) + + var called int + var lastResult checker.GRPCResponse + + op := func() (*GRPCPrivateRegionData, error) { + called++ + start := time.Now().UTC().UnixMilli() + + res, err := checker.CheckGRPC( + monitor.Timeout, + monitor.Uri, + monitor.Service, + checker.ParseGRPCTLSMode(monitor.TlsMode), + headersToMap(monitor.GetMetadata()), + ) + if err != nil { + // Only a call that never reached the server is worth repeating. + if called < int(retry) { + return nil, fmt.Errorf("gRPC check failed: %w", err) + } + + data, dataErr := newGRPCData(monitor.Uri, monitor.Service, start) + if dataErr != nil { + return nil, dataErr + } + + lastResult = checker.GRPCResponse{Error: 1, GRPCCode: res.GRPCCode} + + data.RequestStatus = "error" + data.Error = 1 + data.GRPCCode = res.GRPCCode + data.Message = err.Error() + data.Timing = marshalGRPCTiming(res.Timing) + + return data, nil + } + + // The server answered. Whatever it said, asking again cannot change it, + // so this path never returns an error to the retry loop. + errorFlag := 0 + if !res.Healthy { + errorFlag = 1 + } + + lastResult = checker.GRPCResponse{ + Latency: res.Latency, + Timing: res.Timing, + ServingStatus: res.ServingStatus, + GRPCCode: res.GRPCCode, + Completed: true, + Error: uint8(errorFlag), + } + + // "success", not "active": the Tinybird gRPC status and uptime pipes + // count `requestStatus = 'success'`, and the other jobs report it that way. + requestStatus := "success" + switch { + case !res.Healthy: + requestStatus = "error" + case degradedAfter > 0 && res.Latency > degradedAfter: + requestStatus = "degraded" + } + + data, err := newGRPCData(monitor.Uri, monitor.Service, start) + if err != nil { + return nil, err + } + + data.Latency = res.Latency + data.GRPCCode = res.GRPCCode + data.ServingStatus = res.ServingStatus + data.RequestStatus = requestStatus + data.Error = errorFlag + data.Message = res.Message + data.Timing = marshalGRPCTiming(res.Timing) + + return data, nil + } + + resp, err := backoff.Retry(ctx, op, + backoff.WithMaxTries(uint(retry)), + backoff.WithBackOff(backoff.NewExponentialBackOff()), + ) + + recordGRPCOtel(ctx, req, lastResult, region, err != nil) + + if err != nil { + return nil, fmt.Errorf("gRPC job failed after %d retries: %w", retry, err) + } + + return resp, nil +} + +// newGRPCData stamps the fields every result must carry regardless of outcome. +// `Timestamp`/`CronTimestamp` are required: ValidateIngestGRPCRequest rejects a +// non-positive timestamp, so a result missing them is dropped at ingest. +func newGRPCData(uri, service string, start int64) (*GRPCPrivateRegionData, error) { + id, err := uuid.NewV7() + if err != nil { + return nil, fmt.Errorf("failed to generate UUID: %w", err) + } + + return &GRPCPrivateRegionData{ + ID: id.String(), + URI: uri, + Service: service, + Timestamp: start, + CronTimestamp: start, + }, nil +} + +// marshalGRPCTiming keeps whatever phases completed. A transport failure still +// carries the ones it got through, which is what separates a DNS failure from a +// TLS one once the row is in Tinybird. +func marshalGRPCTiming(timing checker.GRPCResponseTiming) string { + encoded, err := json.Marshal(timing) + if err != nil { + return "" + } + + return string(encoded) +} + +func grpcCheckerRequest(monitor *v1.GRPCMonitor) request.GRPCCheckerRequest { + req := request.GRPCCheckerRequest{ + URI: monitor.Uri, + Service: monitor.Service, + TLS: monitor.TlsMode, + Headers: headersToMap(monitor.GetMetadata()), + } + if otelCfg := monitor.GetOtelConfig(); otelCfg.GetEndpoint() != "" { + req.OtelConfig.Endpoint = otelCfg.GetEndpoint() + req.OtelConfig.Headers = headersToMap(otelCfg.GetHeaders()) + } + + return req +} + +func recordGRPCOtel(ctx context.Context, req request.GRPCCheckerRequest, result checker.GRPCResponse, region string, failed bool) { + if req.OtelConfig.Endpoint == "" { + return + } + + if failed { + result.Error = 1 + result.Completed = false + } + + otel.RecordGRPCMetrics(ctx, req, result, region) +} diff --git a/apps/checker/pkg/job/grpc_job_test.go b/apps/checker/pkg/job/grpc_job_test.go new file mode 100644 index 00000000..88b19f54 --- /dev/null +++ b/apps/checker/pkg/job/grpc_job_test.go @@ -0,0 +1,223 @@ +package job_test + +import ( + "context" + "encoding/json" + "net" + "testing" + "time" + + "github.com/openstatushq/openstatus/apps/checker/pkg/job" + v1 "github.com/openstatushq/openstatus/apps/checker/proto/private_location/v1" + "google.golang.org/grpc" + "google.golang.org/grpc/health" + "google.golang.org/grpc/health/grpc_health_v1" +) + +func grpcJobServer(t *testing.T, status grpc_health_v1.HealthCheckResponse_ServingStatus, delay time.Duration) (string, func() int) { + t.Helper() + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + + calls := 0 + server := grpc.NewServer(grpc.UnaryInterceptor( + func(ctx context.Context, req any, _ *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) { + calls++ + if delay > 0 { + select { + case <-time.After(delay): + case <-ctx.Done(): + return nil, ctx.Err() + } + } + return handler(ctx, req) + }, + )) + + healthServer := health.NewServer() + healthServer.SetServingStatus("", status) + grpc_health_v1.RegisterHealthServer(server, healthServer) + + go func() { + _ = server.Serve(listener) + }() + t.Cleanup(server.Stop) + + return listener.Addr().String(), func() int { return calls } +} + +func closedGRPCPort(t *testing.T) string { + t.Helper() + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + addr := listener.Addr().String() + if err := listener.Close(); err != nil { + t.Fatalf("close: %v", err) + } + + return addr +} + +func TestGRPCJobServing(t *testing.T) { + target, _ := grpcJobServer(t, grpc_health_v1.HealthCheckResponse_SERVING, 0) + + data, err := job.NewJobRunner().GRPCJob(context.Background(), &v1.GRPCMonitor{ + Id: "1", + Uri: target, + TlsMode: "plaintext", + Timeout: 5000, + Retry: 3, + }, "ams") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if data.RequestStatus != "success" { + t.Fatalf(`expected "success", got %q`, data.RequestStatus) + } + if data.ServingStatus != "SERVING" { + t.Fatalf("expected SERVING, got %q", data.ServingStatus) + } + if data.Error != 0 { + t.Fatalf("expected error 0, got %d", data.Error) + } + assertStamped(t, data) +} + +// ValidateIngestGRPCRequest rejects a non-positive timestamp, so a result that +// forgets to stamp them is silently dropped at ingest. +func assertStamped(t *testing.T, data *job.GRPCPrivateRegionData) { + t.Helper() + + if data.Timestamp <= 0 { + t.Fatalf("Timestamp must be stamped, got %d", data.Timestamp) + } + if data.CronTimestamp <= 0 { + t.Fatalf("CronTimestamp must be stamped, got %d", data.CronTimestamp) + } + if data.ID == "" { + t.Fatal("ID must be stamped") + } +} + +func TestGRPCJobNotServingIsProbedOnce(t *testing.T) { + target, calls := grpcJobServer(t, grpc_health_v1.HealthCheckResponse_NOT_SERVING, 0) + + data, err := job.NewJobRunner().GRPCJob(context.Background(), &v1.GRPCMonitor{ + Id: "1", + Uri: target, + TlsMode: "plaintext", + Timeout: 5000, + Retry: 3, + }, "ams") + if err != nil { + t.Fatalf("NOT_SERVING is an answer, not a job failure: %v", err) + } + + if data.RequestStatus != "error" { + t.Fatalf(`expected "error", got %q`, data.RequestStatus) + } + if data.Error != 1 { + t.Fatalf("expected error 1, got %d", data.Error) + } + if got := calls(); got != 1 { + t.Fatalf("a definitive answer must be probed once, got %d probes", got) + } + assertStamped(t, data) +} + +func TestGRPCJobDegraded(t *testing.T) { + // Loopback rounds to 0 ms, so the threshold can only be crossed by a server + // that actually takes time to answer. + target, _ := grpcJobServer(t, grpc_health_v1.HealthCheckResponse_SERVING, 40*time.Millisecond) + + degradedAt := int64(10) + data, err := job.NewJobRunner().GRPCJob(context.Background(), &v1.GRPCMonitor{ + Id: "1", + Uri: target, + TlsMode: "plaintext", + Timeout: 5000, + Retry: 3, + DegradedAt: °radedAt, + }, "ams") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if data.RequestStatus != "degraded" { + t.Fatalf(`expected "degraded", got %q`, data.RequestStatus) + } + if data.Error != 0 { + t.Fatalf("a degraded check is not an error, got %d", data.Error) + } +} + +// A transport failure that exhausts its retries still has to produce a stamped +// row: returning (nil, err) would leave the scheduler nothing to forward. +func TestGRPCJobTransportFailureStillStamps(t *testing.T) { + data, err := job.NewJobRunner().GRPCJob(context.Background(), &v1.GRPCMonitor{ + Id: "1", + Uri: closedGRPCPort(t), + TlsMode: "plaintext", + Timeout: 300, + Retry: 1, + }, "ams") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if data.RequestStatus != "error" { + t.Fatalf(`expected "error", got %q`, data.RequestStatus) + } + if data.Error != 1 { + t.Fatalf("expected error 1, got %d", data.Error) + } + if data.Message == "" { + t.Fatal("a failed check must carry its diagnosis") + } + assertStamped(t, data) +} + +// The phases that completed before the failure are what separate a DNS problem +// from a TLS one once the row is in Tinybird. +func TestGRPCJobKeepsPartialTiming(t *testing.T) { + data, err := job.NewJobRunner().GRPCJob(context.Background(), &v1.GRPCMonitor{ + Id: "1", + Uri: closedGRPCPort(t), + TlsMode: "plaintext", + Timeout: 300, + Retry: 1, + }, "ams") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var timing map[string]int64 + if err := json.Unmarshal([]byte(data.Timing), &timing); err != nil { + t.Fatalf("timing must be valid json, got %q: %v", data.Timing, err) + } + if _, ok := timing["dnsStart"]; !ok { + t.Fatalf("expected the HTTP phase shape, got %v", timing) + } +} + +func TestGRPCJobRespectsContextCancellation(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) + defer cancel() + + if _, err := job.NewJobRunner().GRPCJob(ctx, &v1.GRPCMonitor{ + Id: "1", + Uri: closedGRPCPort(t), + TlsMode: "plaintext", + Timeout: 5000, + Retry: 5, + }, "ams"); err == nil { + t.Fatal("expected the cancelled context to end the job") + } +} diff --git a/apps/checker/pkg/job/job.go b/apps/checker/pkg/job/job.go index 74518d41..f768bca6 100644 --- a/apps/checker/pkg/job/job.go +++ b/apps/checker/pkg/job/job.go @@ -32,6 +32,7 @@ type JobRunner interface { 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) + GRPCJob(ctx context.Context, monitor *v1.GRPCMonitor, region string) (*GRPCPrivateRegionData, error) } type jobRunner struct{} diff --git a/apps/checker/pkg/otel/otel.go b/apps/checker/pkg/otel/otel.go index c4938d36..dfce446e 100644 --- a/apps/checker/pkg/otel/otel.go +++ b/apps/checker/pkg/otel/otel.go @@ -215,6 +215,70 @@ func recordICMPInstruments(ctx context.Context, meter metric.Meter, result check } } +func RecordGRPCMetrics(ctx context.Context, req request.GRPCCheckerRequest, result checker.GRPCResponse, 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), + ) + recordGRPCInstruments(ctx, meter, result, att) + }) +} + +// recordGRPCInstruments branches on whether the RPC completed, not on the error +// flag: a NOT_SERVING answer sets the flag but did complete, and gating the +// gauges on the flag would leave openstatus.grpc.serving_status able to emit +// only 1. Split out of RecordGRPCMetrics so tests can supply a collectable meter. +func recordGRPCInstruments(ctx context.Context, meter metric.Meter, result checker.GRPCResponse, att metric.MeasurementOption) { + if !result.Completed { + recordErrorCounter(ctx, meter, att) + return + } + + gauges := []struct { + name string + description string + value float64 + }{ + {"openstatus.grpc.request.duration", "Duration of the check", float64(result.Latency)}, + {"openstatus.grpc.dns.duration", "Duration of the DNS lookup", grpcPhase(result.Timing.DnsStart, result.Timing.DnsDone)}, + {"openstatus.grpc.connection.duration", "Duration of the connection", grpcPhase(result.Timing.ConnectStart, result.Timing.ConnectDone)}, + {"openstatus.grpc.tls.duration", "Duration of the TLS handshake", grpcPhase(result.Timing.TlsHandshakeStart, result.Timing.TlsHandshakeDone)}, + {"openstatus.grpc.ttfb.duration", "Duration of the TTFB", grpcPhase(result.Timing.FirstByteStart, result.Timing.FirstByteDone)}, + } + + for _, g := range gauges { + if err := recordGauge(ctx, meter, g.name, g.description, g.value, att); err != nil { + log.Ctx(ctx).Error().Err(err).Str("metric", g.name).Msg("Error creating gauge") + } + } + + serving := float64(0) + if result.ServingStatus == checker.ServingStatusServing { + serving = 1 + } + + if err := recordGauge(ctx, meter, "openstatus.grpc.serving_status", "Serving status of the target", serving, att); err != nil { + log.Ctx(ctx).Error().Err(err).Str("metric", "openstatus.grpc.serving_status").Msg("Error creating gauge") + } + + if serving == 1 { + recordStatusCounter(ctx, meter, att) + } else { + recordErrorCounter(ctx, meter, att) + } +} + +// grpcPhase mirrors calculateTiming: a phase whose hook never fired leaves a +// zero behind, and subtracting absolute epoch stamps would report a huge value. +func grpcPhase(start, done int64) float64 { + if start == 0 || done == 0 { + return 0 + } + + return float64(done - start) +} + 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 c3428119..f86bc91b 100644 --- a/apps/checker/pkg/otel/otel_test.go +++ b/apps/checker/pkg/otel/otel_test.go @@ -416,3 +416,102 @@ func TestRecordDNSMetrics_SetupFailure(t *testing.T) { // Must not panic — same nil pointer guard as HTTP. RecordDNSMetrics(context.Background(), req, 30, false, "us-east-1") } + +// grpcMetricValues collects every gauge and counter one recordGRPCInstruments +// call produced, keyed by metric name. +func grpcMetricValues(t *testing.T, reader *sdkMetrics.ManualReader) (map[string]float64, map[string]int64) { + t.Helper() + + gauges := map[string]float64{} + counters := map[string]int64{} + for _, sm := range collectMetrics(t, reader).ScopeMetrics { + for _, m := range sm.Metrics { + if gauge, ok := m.Data.(metricdata.Gauge[float64]); ok && len(gauge.DataPoints) == 1 { + gauges[m.Name] = gauge.DataPoints[0].Value + } + if sum, ok := m.Data.(metricdata.Sum[int64]); ok && len(sum.DataPoints) == 1 { + counters[m.Name] = sum.DataPoints[0].Value + } + } + } + + return gauges, counters +} + +func TestRecordGRPCInstrumentsServing(t *testing.T) { + meter, reader := newTestMeter(t) + att := metric.WithAttributes(attribute.String("openstatus.probes", "ams")) + + recordGRPCInstruments(context.Background(), meter, checker.GRPCResponse{ + Latency: 120, + Completed: true, + ServingStatus: checker.ServingStatusServing, + Timing: checker.GRPCResponseTiming{ + DnsStart: 100, DnsDone: 104, + ConnectStart: 104, ConnectDone: 114, + TlsHandshakeStart: 114, TlsHandshakeDone: 150, + FirstByteStart: 150, FirstByteDone: 220, + }, + }, att) + + gauges, counters := grpcMetricValues(t, reader) + + assert.Equal(t, float64(120), gauges["openstatus.grpc.request.duration"]) + assert.Equal(t, float64(4), gauges["openstatus.grpc.dns.duration"]) + assert.Equal(t, float64(10), gauges["openstatus.grpc.connection.duration"]) + assert.Equal(t, float64(36), gauges["openstatus.grpc.tls.duration"]) + assert.Equal(t, float64(70), gauges["openstatus.grpc.ttfb.duration"]) + assert.Equal(t, float64(1), gauges["openstatus.grpc.serving_status"]) + + assert.Equal(t, int64(1), counters["openstatus.status"]) + assert.NotContains(t, counters, "openstatus.error") +} + +// A NOT_SERVING check sets the error flag but completed, so it must still +// report its real durations and a serving_status of 0. Gating on the error flag +// the way the ICMP path does would leave this gauge only ever emitting 1. +func TestRecordGRPCInstrumentsNotServing(t *testing.T) { + meter, reader := newTestMeter(t) + att := metric.WithAttributes(attribute.String("openstatus.probes", "ams")) + + recordGRPCInstruments(context.Background(), meter, checker.GRPCResponse{ + Latency: 90, + Completed: true, + Error: 1, + ServingStatus: checker.ServingStatusNotServing, + Timing: checker.GRPCResponseTiming{ + FirstByteStart: 10, FirstByteDone: 100, + }, + }, att) + + gauges, counters := grpcMetricValues(t, reader) + + assert.Equal(t, float64(0), gauges["openstatus.grpc.serving_status"]) + assert.Equal(t, float64(90), gauges["openstatus.grpc.request.duration"]) + assert.Equal(t, float64(90), gauges["openstatus.grpc.ttfb.duration"]) + assert.Equal(t, int64(1), counters["openstatus.error"]) + assert.NotContains(t, counters, "openstatus.status") +} + +func TestRecordGRPCInstrumentsTransportFailure(t *testing.T) { + meter, reader := newTestMeter(t) + att := metric.WithAttributes(attribute.String("openstatus.probes", "ams")) + + recordGRPCInstruments(context.Background(), meter, checker.GRPCResponse{ + Completed: false, + Error: 1, + }, att) + + gauges, counters := grpcMetricValues(t, reader) + + assert.Equal(t, int64(1), counters["openstatus.error"]) + assert.Empty(t, gauges, "a call that never completed has no durations to report") +} + +// A phase whose hook never fired leaves a zero, and subtracting absolute epoch +// stamps from it would report a value in the trillions. +func TestGRPCPhaseIgnoresUnfiredHooks(t *testing.T) { + assert.Equal(t, float64(0), grpcPhase(0, 1761000000000)) + assert.Equal(t, float64(0), grpcPhase(1761000000000, 0)) + assert.Equal(t, float64(25), grpcPhase(100, 125)) +} diff --git a/apps/checker/pkg/scheduler/scheduler.go b/apps/checker/pkg/scheduler/scheduler.go index 87cab525..21ab0c02 100644 --- a/apps/checker/pkg/scheduler/scheduler.go +++ b/apps/checker/pkg/scheduler/scheduler.go @@ -298,6 +298,60 @@ func (mm *MonitorManager) UpdateMonitors(ctx context.Context) { } } + for _, m := range res.Msg.GrpcMonitors { + 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 gRPC job for monitor %s (%s)", monitor.Id, monitor.Uri) + data, err := mm.JobRunner.GRPCJob(c, monitor, res.Msg.Region) + if err != nil { + log.Printf("gRPC monitor check failed for %s (%s): %v", monitor.Id, monitor.Uri, err) + return err + } + resp, ingestErr := mm.Client.IngestGRPC(c, &connect.Request[v1.IngestGRPCRequest]{ + Msg: &v1.IngestGRPCRequest{ + MonitorId: monitor.Id, + Id: data.ID, + Uri: monitor.Uri, + Service: data.Service, + ServingStatus: data.ServingStatus, + GrpcCode: data.GRPCCode, + Message: data.Message, + Latency: data.Latency, + RequestStatus: data.RequestStatus, + Error: int64(data.Error), + CronTimestamp: data.CronTimestamp, + Timestamp: data.Timestamp, + Timing: data.Timing, + }, + }) + if ingestErr != nil { + log.Printf("Failed to ingest gRPC result for %s (%s): %v", monitor.Id, monitor.Uri, ingestErr) + return ingestErr + } + log.Printf("gRPC 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 gRPC monitor job for %s (%s): %v", m.Id, m.Uri, err) + continue + } + log.Printf("Started gRPC 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 ba1013a5..65afde2d 100644 --- a/apps/checker/pkg/scheduler/scheduler_test.go +++ b/apps/checker/pkg/scheduler/scheduler_test.go @@ -2,6 +2,7 @@ package scheduler_test import ( "context" + "errors" "sync/atomic" "sync" @@ -20,6 +21,8 @@ type mockJobRunner struct { HTTPJobCalled atomic.Bool TCPJobCalled atomic.Bool DNSJobCalled atomic.Bool + GRPCJobCalled atomic.Bool + GRPCJobErr error mu sync.Mutex httpRegion string tcpRegion string @@ -78,6 +81,14 @@ func (m *mockJobRunner) ICMPJob(ctx context.Context, monitor *v1.ICMPMonitor, re return &job.ICMPPrivateRegionData{}, nil } +func (m *mockJobRunner) GRPCJob(ctx context.Context, monitor *v1.GRPCMonitor, region string) (*job.GRPCPrivateRegionData, error) { + m.GRPCJobCalled.Store(true) + if m.GRPCJobErr != nil { + return nil, m.GRPCJobErr + } + return &job.GRPCPrivateRegionData{ID: "grpc-result", Timestamp: 1, CronTimestamp: 1}, 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) @@ -85,6 +96,7 @@ type mockClient struct { 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) + IngestGRPCFunc func(ctx context.Context, req *connect.Request[v1.IngestGRPCRequest]) (*connect.Response[v1.IngestGRPCResponse], error) } func (m *mockClient) Monitors(ctx context.Context, req *connect.Request[v1.MonitorsRequest]) (*connect.Response[v1.MonitorsResponse], error) { @@ -102,6 +114,9 @@ func (m *mockClient) IngestDNS(ctx context.Context, req *connect.Request[v1.Inge func (m *mockClient) IngestICMP(ctx context.Context, req *connect.Request[v1.IngestICMPRequest]) (*connect.Response[v1.IngestICMPResponse], error) { return m.IngestICMPFunc(ctx, req) } +func (m *mockClient) IngestGRPC(ctx context.Context, req *connect.Request[v1.IngestGRPCRequest]) (*connect.Response[v1.IngestGRPCResponse], error) { + return m.IngestGRPCFunc(ctx, req) +} func TestMonitorManager_StartAndStopJobs_WithJobRunner(t *testing.T) { ctx := t.Context() @@ -318,3 +333,87 @@ func TestMonitorManager_IngestsDNSResult(t *testing.T) { t.Errorf("expected the A records to be forwarded, got %v", got) } } + +func TestMonitorManager_SchedulesGRPCMonitors(t *testing.T) { + ctx := t.Context() + + grpcMonitor := &v1.GRPCMonitor{Id: "grpc1", Uri: "api.example.com:443", Periodicity: "10s", TlsMode: "tls"} + + var ingested atomic.Bool + var ingestedID atomic.Value + + client := &mockClient{ + MonitorsFunc: func(ctx context.Context, req *connect.Request[v1.MonitorsRequest]) (*connect.Response[v1.MonitorsResponse], error) { + return connect.NewResponse(&v1.MonitorsResponse{ + GrpcMonitors: []*v1.GRPCMonitor{grpcMonitor}, + Region: "frankfurt-dc1", + }), nil + }, + IngestGRPCFunc: func(ctx context.Context, req *connect.Request[v1.IngestGRPCRequest]) (*connect.Response[v1.IngestGRPCResponse], error) { + ingested.Store(true) + ingestedID.Store(req.Msg.Id) + return connect.NewResponse(&v1.IngestGRPCResponse{}), nil + }, + } + + jobRunner := &mockJobRunner{} + s := tasks.New() + defer s.Stop() + + mm := &scheduler.MonitorManager{Client: client, JobRunner: jobRunner, Scheduler: s} + mm.UpdateMonitors(ctx) + + runScheduledTask(t, mm.Scheduler, "grpc1") + + if !jobRunner.GRPCJobCalled.Load() { + t.Error("expected GRPCJob to be called") + } + if !ingested.Load() { + t.Error("expected IngestGRPC to be called") + } + if got := ingestedID.Load(); got != "grpc-result" { + t.Errorf("expected the job result to be forwarded, got %v", got) + } +} + +// GRPCJob returns (nil, err) when the retry loop itself fails. The task must +// stop there: reading data.ID after only logging the error would panic. +func TestMonitorManager_GRPCJobErrorSkipsIngest(t *testing.T) { + ctx := t.Context() + + grpcMonitor := &v1.GRPCMonitor{Id: "grpc-fail", Uri: "api.example.com:443", Periodicity: "10s"} + + var ingested atomic.Bool + + client := &mockClient{ + MonitorsFunc: func(ctx context.Context, req *connect.Request[v1.MonitorsRequest]) (*connect.Response[v1.MonitorsResponse], error) { + return connect.NewResponse(&v1.MonitorsResponse{ + GrpcMonitors: []*v1.GRPCMonitor{grpcMonitor}, + Region: "frankfurt-dc1", + }), nil + }, + IngestGRPCFunc: func(ctx context.Context, req *connect.Request[v1.IngestGRPCRequest]) (*connect.Response[v1.IngestGRPCResponse], error) { + ingested.Store(true) + return connect.NewResponse(&v1.IngestGRPCResponse{}), nil + }, + } + + jobRunner := &mockJobRunner{GRPCJobErr: errors.New("job failed")} + s := tasks.New() + defer s.Stop() + + mm := &scheduler.MonitorManager{Client: client, JobRunner: jobRunner, Scheduler: s} + mm.UpdateMonitors(ctx) + + task, err := mm.Scheduler.Lookup("grpc-fail") + if err != nil { + t.Fatalf("expected a scheduled task: %v", err) + } + if err := task.FuncWithTaskContext(tasks.TaskContext{}); err == nil { + t.Error("expected the task to surface the job error") + } + + if ingested.Load() { + t.Error("a failed job must not be forwarded to IngestGRPC") + } +} diff --git a/apps/checker/proto/private_location/v1/grpc_monitor.pb.go b/apps/checker/proto/private_location/v1/grpc_monitor.pb.go new file mode 100644 index 00000000..6784cdc9 --- /dev/null +++ b/apps/checker/proto/private_location/v1/grpc_monitor.pb.go @@ -0,0 +1,213 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: private_location/v1/grpc_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 GRPCMonitor 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"` + Service string `protobuf:"bytes,7,opt,name=service,proto3" json:"service,omitempty"` + TlsMode string `protobuf:"bytes,8,opt,name=tls_mode,json=tlsMode,proto3" json:"tls_mode,omitempty"` + Metadata []*Headers `protobuf:"bytes,10,rep,name=metadata,proto3" json:"metadata,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 *GRPCMonitor) Reset() { + *x = GRPCMonitor{} + mi := &file_private_location_v1_grpc_monitor_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GRPCMonitor) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GRPCMonitor) ProtoMessage() {} + +func (x *GRPCMonitor) ProtoReflect() protoreflect.Message { + mi := &file_private_location_v1_grpc_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 GRPCMonitor.ProtoReflect.Descriptor instead. +func (*GRPCMonitor) Descriptor() ([]byte, []int) { + return file_private_location_v1_grpc_monitor_proto_rawDescGZIP(), []int{0} +} + +func (x *GRPCMonitor) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *GRPCMonitor) GetUri() string { + if x != nil { + return x.Uri + } + return "" +} + +func (x *GRPCMonitor) GetTimeout() int64 { + if x != nil { + return x.Timeout + } + return 0 +} + +func (x *GRPCMonitor) GetDegradedAt() int64 { + if x != nil && x.DegradedAt != nil { + return *x.DegradedAt + } + return 0 +} + +func (x *GRPCMonitor) GetPeriodicity() string { + if x != nil { + return x.Periodicity + } + return "" +} + +func (x *GRPCMonitor) GetRetry() int64 { + if x != nil { + return x.Retry + } + return 0 +} + +func (x *GRPCMonitor) GetService() string { + if x != nil { + return x.Service + } + return "" +} + +func (x *GRPCMonitor) GetTlsMode() string { + if x != nil { + return x.TlsMode + } + return "" +} + +func (x *GRPCMonitor) GetMetadata() []*Headers { + if x != nil { + return x.Metadata + } + return nil +} + +func (x *GRPCMonitor) GetOtelConfig() *OtelConfig { + if x != nil { + return x.OtelConfig + } + return nil +} + +var File_private_location_v1_grpc_monitor_proto protoreflect.FileDescriptor + +const file_private_location_v1_grpc_monitor_proto_rawDesc = "" + + "\n" + + "&private_location/v1/grpc_monitor.proto\x12\x13private_location.v1\x1a\x1eprivate_location/v1/otel.proto\"\xe8\x02\n" + + "\vGRPCMonitor\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\x18\n" + + "\aservice\x18\a \x01(\tR\aservice\x12\x19\n" + + "\btls_mode\x18\b \x01(\tR\atlsMode\x128\n" + + "\bmetadata\x18\n" + + " \x03(\v2\x1c.private_location.v1.HeadersR\bmetadata\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_grpc_monitor_proto_rawDescOnce sync.Once + file_private_location_v1_grpc_monitor_proto_rawDescData []byte +) + +func file_private_location_v1_grpc_monitor_proto_rawDescGZIP() []byte { + file_private_location_v1_grpc_monitor_proto_rawDescOnce.Do(func() { + file_private_location_v1_grpc_monitor_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_private_location_v1_grpc_monitor_proto_rawDesc), len(file_private_location_v1_grpc_monitor_proto_rawDesc))) + }) + return file_private_location_v1_grpc_monitor_proto_rawDescData +} + +var file_private_location_v1_grpc_monitor_proto_msgTypes = make([]protoimpl.MessageInfo, 1) +var file_private_location_v1_grpc_monitor_proto_goTypes = []any{ + (*GRPCMonitor)(nil), // 0: private_location.v1.GRPCMonitor + (*Headers)(nil), // 1: private_location.v1.Headers + (*OtelConfig)(nil), // 2: private_location.v1.OtelConfig +} +var file_private_location_v1_grpc_monitor_proto_depIdxs = []int32{ + 1, // 0: private_location.v1.GRPCMonitor.metadata:type_name -> private_location.v1.Headers + 2, // 1: private_location.v1.GRPCMonitor.otel_config:type_name -> private_location.v1.OtelConfig + 2, // [2:2] is the sub-list for method output_type + 2, // [2:2] is the sub-list for method input_type + 2, // [2:2] is the sub-list for extension type_name + 2, // [2:2] is the sub-list for extension extendee + 0, // [0:2] is the sub-list for field type_name +} + +func init() { file_private_location_v1_grpc_monitor_proto_init() } +func file_private_location_v1_grpc_monitor_proto_init() { + if File_private_location_v1_grpc_monitor_proto != nil { + return + } + file_private_location_v1_otel_proto_init() + file_private_location_v1_grpc_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_grpc_monitor_proto_rawDesc), len(file_private_location_v1_grpc_monitor_proto_rawDesc)), + NumEnums: 0, + NumMessages: 1, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_private_location_v1_grpc_monitor_proto_goTypes, + DependencyIndexes: file_private_location_v1_grpc_monitor_proto_depIdxs, + MessageInfos: file_private_location_v1_grpc_monitor_proto_msgTypes, + }.Build() + File_private_location_v1_grpc_monitor_proto = out.File + file_private_location_v1_grpc_monitor_proto_goTypes = nil + file_private_location_v1_grpc_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 8138ab7c..e52f9503 100644 --- a/apps/checker/proto/private_location/v1/private_location.connect.go +++ b/apps/checker/proto/private_location/v1/private_location.connect.go @@ -47,6 +47,9 @@ const ( // PrivateLocationServiceIngestICMPProcedure is the fully-qualified name of the // PrivateLocationService's IngestICMP RPC. PrivateLocationServiceIngestICMPProcedure = "/private_location.v1.PrivateLocationService/IngestICMP" + // PrivateLocationServiceIngestGRPCProcedure is the fully-qualified name of the + // PrivateLocationService's IngestGRPC RPC. + PrivateLocationServiceIngestGRPCProcedure = "/private_location.v1.PrivateLocationService/IngestGRPC" ) // PrivateLocationServiceClient is a client for the private_location.v1.PrivateLocationService @@ -57,6 +60,7 @@ type PrivateLocationServiceClient interface { 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) + IngestGRPC(context.Context, *connect.Request[IngestGRPCRequest]) (*connect.Response[IngestGRPCResponse], error) } // NewPrivateLocationServiceClient constructs a client for the @@ -100,6 +104,12 @@ func NewPrivateLocationServiceClient(httpClient connect.HTTPClient, baseURL stri connect.WithSchema(privateLocationServiceMethods.ByName("IngestICMP")), connect.WithClientOptions(opts...), ), + ingestGRPC: connect.NewClient[IngestGRPCRequest, IngestGRPCResponse]( + httpClient, + baseURL+PrivateLocationServiceIngestGRPCProcedure, + connect.WithSchema(privateLocationServiceMethods.ByName("IngestGRPC")), + connect.WithClientOptions(opts...), + ), } } @@ -110,6 +120,7 @@ type privateLocationServiceClient struct { ingestHTTP *connect.Client[IngestHTTPRequest, IngestHTTPResponse] ingestDNS *connect.Client[IngestDNSRequest, IngestDNSResponse] ingestICMP *connect.Client[IngestICMPRequest, IngestICMPResponse] + ingestGRPC *connect.Client[IngestGRPCRequest, IngestGRPCResponse] } // Monitors calls private_location.v1.PrivateLocationService.Monitors. @@ -137,6 +148,11 @@ func (c *privateLocationServiceClient) IngestICMP(ctx context.Context, req *conn return c.ingestICMP.CallUnary(ctx, req) } +// IngestGRPC calls private_location.v1.PrivateLocationService.IngestGRPC. +func (c *privateLocationServiceClient) IngestGRPC(ctx context.Context, req *connect.Request[IngestGRPCRequest]) (*connect.Response[IngestGRPCResponse], error) { + return c.ingestGRPC.CallUnary(ctx, req) +} + // PrivateLocationServiceHandler is an implementation of the // private_location.v1.PrivateLocationService service. type PrivateLocationServiceHandler interface { @@ -145,6 +161,7 @@ type PrivateLocationServiceHandler interface { 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) + IngestGRPC(context.Context, *connect.Request[IngestGRPCRequest]) (*connect.Response[IngestGRPCResponse], error) } // NewPrivateLocationServiceHandler builds an HTTP handler from the service implementation. It @@ -184,6 +201,12 @@ func NewPrivateLocationServiceHandler(svc PrivateLocationServiceHandler, opts .. connect.WithSchema(privateLocationServiceMethods.ByName("IngestICMP")), connect.WithHandlerOptions(opts...), ) + privateLocationServiceIngestGRPCHandler := connect.NewUnaryHandler( + PrivateLocationServiceIngestGRPCProcedure, + svc.IngestGRPC, + connect.WithSchema(privateLocationServiceMethods.ByName("IngestGRPC")), + connect.WithHandlerOptions(opts...), + ) return "/private_location.v1.PrivateLocationService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case PrivateLocationServiceMonitorsProcedure: @@ -196,6 +219,8 @@ func NewPrivateLocationServiceHandler(svc PrivateLocationServiceHandler, opts .. privateLocationServiceIngestDNSHandler.ServeHTTP(w, r) case PrivateLocationServiceIngestICMPProcedure: privateLocationServiceIngestICMPHandler.ServeHTTP(w, r) + case PrivateLocationServiceIngestGRPCProcedure: + privateLocationServiceIngestGRPCHandler.ServeHTTP(w, r) default: http.NotFound(w, r) } @@ -224,3 +249,7 @@ func (UnimplementedPrivateLocationServiceHandler) IngestDNS(context.Context, *co 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")) } + +func (UnimplementedPrivateLocationServiceHandler) IngestGRPC(context.Context, *connect.Request[IngestGRPCRequest]) (*connect.Response[IngestGRPCResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("private_location.v1.PrivateLocationService.IngestGRPC 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 a28112bc..6cded061 100644 --- a/apps/checker/proto/private_location/v1/private_location.pb.go +++ b/apps/checker/proto/private_location/v1/private_location.pb.go @@ -63,6 +63,7 @@ type MonitorsResponse struct { 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"` + GrpcMonitors []*GRPCMonitor `protobuf:"bytes,6,rep,name=grpc_monitors,json=grpcMonitors,proto3" json:"grpc_monitors,omitempty"` Region string `protobuf:"bytes,4,opt,name=region,proto3" json:"region,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -126,6 +127,13 @@ func (x *MonitorsResponse) GetIcmpMonitors() []*ICMPMonitor { return nil } +func (x *MonitorsResponse) GetGrpcMonitors() []*GRPCMonitor { + if x != nil { + return x.GrpcMonitors + } + return nil +} + func (x *MonitorsResponse) GetRegion() string { if x != nil { return x.Region @@ -849,17 +857,194 @@ func (*IngestICMPResponse) Descriptor() ([]byte, []int) { return file_private_location_v1_private_location_proto_rawDescGZIP(), []int{10} } +type IngestGRPCRequest 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"` + Timestamp int64 `protobuf:"varint,4,opt,name=timestamp,proto3" json:"timestamp,omitempty"` + CronTimestamp int64 `protobuf:"varint,5,opt,name=cronTimestamp,proto3" json:"cronTimestamp,omitempty"` + Uri string `protobuf:"bytes,6,opt,name=uri,proto3" json:"uri,omitempty"` + Service string `protobuf:"bytes,7,opt,name=service,proto3" json:"service,omitempty"` + ServingStatus string `protobuf:"bytes,8,opt,name=servingStatus,proto3" json:"servingStatus,omitempty"` + GrpcCode int64 `protobuf:"varint,9,opt,name=grpcCode,proto3" json:"grpcCode,omitempty"` + Message string `protobuf:"bytes,10,opt,name=message,proto3" json:"message,omitempty"` + RequestStatus string `protobuf:"bytes,11,opt,name=requestStatus,proto3" json:"requestStatus,omitempty"` + Error int64 `protobuf:"varint,12,opt,name=error,proto3" json:"error,omitempty"` + Timing string `protobuf:"bytes,13,opt,name=timing,proto3" json:"timing,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *IngestGRPCRequest) Reset() { + *x = IngestGRPCRequest{} + mi := &file_private_location_v1_private_location_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *IngestGRPCRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*IngestGRPCRequest) ProtoMessage() {} + +func (x *IngestGRPCRequest) ProtoReflect() protoreflect.Message { + mi := &file_private_location_v1_private_location_proto_msgTypes[11] + 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 IngestGRPCRequest.ProtoReflect.Descriptor instead. +func (*IngestGRPCRequest) Descriptor() ([]byte, []int) { + return file_private_location_v1_private_location_proto_rawDescGZIP(), []int{11} +} + +func (x *IngestGRPCRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *IngestGRPCRequest) GetMonitorId() string { + if x != nil { + return x.MonitorId + } + return "" +} + +func (x *IngestGRPCRequest) GetLatency() int64 { + if x != nil { + return x.Latency + } + return 0 +} + +func (x *IngestGRPCRequest) GetTimestamp() int64 { + if x != nil { + return x.Timestamp + } + return 0 +} + +func (x *IngestGRPCRequest) GetCronTimestamp() int64 { + if x != nil { + return x.CronTimestamp + } + return 0 +} + +func (x *IngestGRPCRequest) GetUri() string { + if x != nil { + return x.Uri + } + return "" +} + +func (x *IngestGRPCRequest) GetService() string { + if x != nil { + return x.Service + } + return "" +} + +func (x *IngestGRPCRequest) GetServingStatus() string { + if x != nil { + return x.ServingStatus + } + return "" +} + +func (x *IngestGRPCRequest) GetGrpcCode() int64 { + if x != nil { + return x.GrpcCode + } + return 0 +} + +func (x *IngestGRPCRequest) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *IngestGRPCRequest) GetRequestStatus() string { + if x != nil { + return x.RequestStatus + } + return "" +} + +func (x *IngestGRPCRequest) GetError() int64 { + if x != nil { + return x.Error + } + return 0 +} + +func (x *IngestGRPCRequest) GetTiming() string { + if x != nil { + return x.Timing + } + return "" +} + +type IngestGRPCResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *IngestGRPCResponse) Reset() { + *x = IngestGRPCResponse{} + mi := &file_private_location_v1_private_location_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *IngestGRPCResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*IngestGRPCResponse) ProtoMessage() {} + +func (x *IngestGRPCResponse) ProtoReflect() protoreflect.Message { + mi := &file_private_location_v1_private_location_proto_msgTypes[12] + 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 IngestGRPCResponse.ProtoReflect.Descriptor instead. +func (*IngestGRPCResponse) Descriptor() ([]byte, []int) { + return file_private_location_v1_private_location_proto_rawDescGZIP(), []int{12} +} + 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/icmp_monitor.proto\x1a%private_location/v1/tcp_monitor.proto\"\x11\n" + - "\x0fMonitorsRequest\"\xc0\x02\n" + + "*private_location/v1/private_location.proto\x12\x13private_location.v1\x1a%private_location/v1/dns_monitor.proto\x1a&private_location/v1/grpc_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\"\x87\x03\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\x12E\n" + - "\ricmp_monitors\x18\x05 \x03(\v2 .private_location.v1.ICMPMonitorR\ficmpMonitors\x12\x16\n" + + "\ricmp_monitors\x18\x05 \x03(\v2 .private_location.v1.ICMPMonitorR\ficmpMonitors\x12E\n" + + "\rgrpc_monitors\x18\x06 \x03(\v2 .private_location.v1.GRPCMonitorR\fgrpcMonitors\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" + @@ -931,7 +1116,23 @@ const file_private_location_v1_private_location_proto_rawDesc = "" + "\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" + + "\x12IngestICMPResponse\"\xfb\x02\n" + + "\x11IngestGRPCRequest\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\x1c\n" + + "\ttimestamp\x18\x04 \x01(\x03R\ttimestamp\x12$\n" + + "\rcronTimestamp\x18\x05 \x01(\x03R\rcronTimestamp\x12\x10\n" + + "\x03uri\x18\x06 \x01(\tR\x03uri\x12\x18\n" + + "\aservice\x18\a \x01(\tR\aservice\x12$\n" + + "\rservingStatus\x18\b \x01(\tR\rservingStatus\x12\x1a\n" + + "\bgrpcCode\x18\t \x01(\x03R\bgrpcCode\x12\x18\n" + + "\amessage\x18\n" + + " \x01(\tR\amessage\x12$\n" + + "\rrequestStatus\x18\v \x01(\tR\rrequestStatus\x12\x14\n" + + "\x05error\x18\f \x01(\x03R\x05error\x12\x16\n" + + "\x06timing\x18\r \x01(\tR\x06timing\"\x14\n" + + "\x12IngestGRPCResponse2\xd2\x04\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" + @@ -939,7 +1140,9 @@ const file_private_location_v1_private_location_proto_rawDesc = "" + "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\"\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" + "IngestICMP\x12&.private_location.v1.IngestICMPRequest\x1a'.private_location.v1.IngestICMPResponse\"\x00\x12_\n" + + "\n" + + "IngestGRPC\x12&.private_location.v1.IngestGRPCRequest\x1a'.private_location.v1.IngestGRPCResponse\"\x00BJZHgithub.com/openstatushq/openstatus/packages/proto/private_location/v1;v1b\x06proto3" var ( file_private_location_v1_private_location_proto_rawDescOnce sync.Once @@ -953,7 +1156,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, 12) +var file_private_location_v1_private_location_proto_msgTypes = make([]protoimpl.MessageInfo, 14) var file_private_location_v1_private_location_proto_goTypes = []any{ (*MonitorsRequest)(nil), // 0: private_location.v1.MonitorsRequest (*MonitorsResponse)(nil), // 1: private_location.v1.MonitorsResponse @@ -966,34 +1169,40 @@ var file_private_location_v1_private_location_proto_goTypes = []any{ (*IngestDNSResponse)(nil), // 8: private_location.v1.IngestDNSResponse (*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 + (*IngestGRPCRequest)(nil), // 11: private_location.v1.IngestGRPCRequest + (*IngestGRPCResponse)(nil), // 12: private_location.v1.IngestGRPCResponse + nil, // 13: private_location.v1.IngestDNSRequest.RecordsEntry + (*HTTPMonitor)(nil), // 14: private_location.v1.HTTPMonitor + (*TCPMonitor)(nil), // 15: private_location.v1.TCPMonitor + (*DNSMonitor)(nil), // 16: private_location.v1.DNSMonitor + (*ICMPMonitor)(nil), // 17: private_location.v1.ICMPMonitor + (*GRPCMonitor)(nil), // 18: private_location.v1.GRPCMonitor } var file_private_location_v1_private_location_proto_depIdxs = []int32{ - 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 + 14, // 0: private_location.v1.MonitorsResponse.http_monitors:type_name -> private_location.v1.HTTPMonitor + 15, // 1: private_location.v1.MonitorsResponse.tcp_monitors:type_name -> private_location.v1.TCPMonitor + 16, // 2: private_location.v1.MonitorsResponse.dns_monitors:type_name -> private_location.v1.DNSMonitor + 17, // 3: private_location.v1.MonitorsResponse.icmp_monitors:type_name -> private_location.v1.ICMPMonitor + 18, // 4: private_location.v1.MonitorsResponse.grpc_monitors:type_name -> private_location.v1.GRPCMonitor + 13, // 5: private_location.v1.IngestDNSRequest.records:type_name -> private_location.v1.IngestDNSRequest.RecordsEntry + 6, // 6: private_location.v1.IngestDNSRequest.RecordsEntry.value:type_name -> private_location.v1.Records + 0, // 7: private_location.v1.PrivateLocationService.Monitors:input_type -> private_location.v1.MonitorsRequest + 2, // 8: private_location.v1.PrivateLocationService.IngestTCP:input_type -> private_location.v1.IngestTCPRequest + 4, // 9: private_location.v1.PrivateLocationService.IngestHTTP:input_type -> private_location.v1.IngestHTTPRequest + 7, // 10: private_location.v1.PrivateLocationService.IngestDNS:input_type -> private_location.v1.IngestDNSRequest + 9, // 11: private_location.v1.PrivateLocationService.IngestICMP:input_type -> private_location.v1.IngestICMPRequest + 11, // 12: private_location.v1.PrivateLocationService.IngestGRPC:input_type -> private_location.v1.IngestGRPCRequest + 1, // 13: private_location.v1.PrivateLocationService.Monitors:output_type -> private_location.v1.MonitorsResponse + 3, // 14: private_location.v1.PrivateLocationService.IngestTCP:output_type -> private_location.v1.IngestTCPResponse + 5, // 15: private_location.v1.PrivateLocationService.IngestHTTP:output_type -> private_location.v1.IngestHTTPResponse + 8, // 16: private_location.v1.PrivateLocationService.IngestDNS:output_type -> private_location.v1.IngestDNSResponse + 10, // 17: private_location.v1.PrivateLocationService.IngestICMP:output_type -> private_location.v1.IngestICMPResponse + 12, // 18: private_location.v1.PrivateLocationService.IngestGRPC:output_type -> private_location.v1.IngestGRPCResponse + 13, // [13:19] is the sub-list for method output_type + 7, // [7:13] is the sub-list for method input_type + 7, // [7:7] is the sub-list for extension type_name + 7, // [7:7] is the sub-list for extension extendee + 0, // [0:7] is the sub-list for field type_name } func init() { file_private_location_v1_private_location_proto_init() } @@ -1002,6 +1211,7 @@ func file_private_location_v1_private_location_proto_init() { return } file_private_location_v1_dns_monitor_proto_init() + file_private_location_v1_grpc_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() @@ -1011,7 +1221,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: 12, + NumMessages: 14, NumExtensions: 0, NumServices: 1, }, diff --git a/apps/checker/request/request.go b/apps/checker/request/request.go index ac5b9626..290fdd0b 100644 --- a/apps/checker/request/request.go +++ b/apps/checker/request/request.go @@ -125,6 +125,26 @@ type ICMPCheckerRequest struct { } `json:"otelConfig"` } +type GRPCCheckerRequest struct { + Status string `json:"status"` + WorkspaceID string `json:"workspaceId"` + URI string `json:"uri"` + MonitorID string `json:"monitorId"` + Service string `json:"service,omitempty"` + TLS string `json:"tls,omitempty"` + Trigger string `json:"trigger,omitempty"` + Headers map[string]string `json:"headers,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 26680f14..79df5d76 100644 --- a/apps/dashboard/src/app/(dashboard)/monitors/[id]/logs/client.tsx +++ b/apps/dashboard/src/app/(dashboard)/monitors/[id]/logs/client.tsx @@ -131,11 +131,15 @@ export function Client() { setPagination={setPagination} paginationComponent={DataTablePagination} defaultColumnVisibility={ - monitor.jobType === "tcp" || - monitor.jobType === "dns" || - monitor.jobType === "icmp" - ? { timing: false, statusCode: false } - : {} + // gRPC carries HTTP's phase timings, so only its status code + // column is meaningless. + monitor.jobType === "grpc" + ? { statusCode: false } + : monitor.jobType === "tcp" || + monitor.jobType === "dns" || + monitor.jobType === "icmp" + ? { timing: false, statusCode: false } + : {} } // NOTE: required to control the pagination autoResetPageIndex={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 7197d2c8..84ac57a4 100644 --- a/apps/dashboard/src/app/(dashboard)/monitors/[id]/nav-actions.tsx +++ b/apps/dashboard/src/app/(dashboard)/monitors/[id]/nav-actions.tsx @@ -27,11 +27,12 @@ type TestTCP = RouterOutputs["checker"]["testTcp"]; type TestHTTP = RouterOutputs["checker"]["testHttp"]; type TestDNS = RouterOutputs["checker"]["testDns"]; type TestICMP = RouterOutputs["checker"]["testIcmp"]; +type TestGRPC = RouterOutputs["checker"]["testGrpc"]; export function NavActions() { const { id } = useParams<{ id: string }>(); const [test, setTest] = useState< - TestTCP | TestHTTP | TestDNS | TestICMP | null + TestTCP | TestHTTP | TestDNS | TestICMP | TestGRPC | null >(null); const queryClient = useQueryClient(); const trpc = useTRPC(); @@ -70,6 +71,7 @@ export function NavActions() { const testTcpMutation = useMutation(trpc.checker.testTcp.mutationOptions()); const testDnsMutation = useMutation(trpc.checker.testDns.mutationOptions()); const testIcmpMutation = useMutation(trpc.checker.testIcmp.mutationOptions()); + const testGrpcMutation = useMutation(trpc.checker.testGrpc.mutationOptions()); // curl only speaks HTTP — the action is hidden for tcp/dns monitors const curlCommand = @@ -180,6 +182,27 @@ export function NavActions() { return "ICMP test failed"; }, }); + } else if (monitor?.jobType === "grpc") { + const promise = testGrpcMutation.mutateAsync({ + url: monitor.url, + service: monitor.grpcService ?? undefined, + tls: monitor.grpcTls ?? "tls", + headers: monitor.headers ?? [], + }); + + toast.promise(promise, { + loading: "Testing gRPC request...", + success: (data) => { + setTest(data); + return "gRPC test completed successfully"; + }, + error: (error) => { + if (isTRPCClientError(error)) { + return error.message; + } + return "gRPC 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 35e9c6ad..eaecd0af 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,12 @@ export function Client() { ...trpc.tinybird.metricsRegions.queryOptions({ monitorId: id, period: effectivePeriod, - type: (monitor?.jobType ?? "http") as "http" | "tcp" | "dns" | "icmp", + type: (monitor?.jobType ?? "http") as + | "http" + | "tcp" + | "dns" + | "icmp" + | "grpc", regions: selectedRegions, // bucket by period (daily at 30d/90d) to keep payload + chart readable interval: periodToInterval[effectivePeriod], @@ -144,7 +149,7 @@ export function Client() { @@ -158,7 +163,7 @@ export function Client() { @@ -197,7 +202,7 @@ export function Client() { monitorId={id} percentile={percentile} degradedAfter={monitor.degradedAfter} - type={monitor.jobType as "http" | "tcp" | "dns" | "icmp"} + type={monitor.jobType as "http" | "tcp" | "dns" | "icmp" | "grpc"} period={effectivePeriod} regions={selectedRegions} /> diff --git a/apps/dashboard/src/app/(dashboard)/monitors/[id]/sidebar.tsx b/apps/dashboard/src/app/(dashboard)/monitors/[id]/sidebar.tsx index 655f3b2b..97b7fdbd 100644 --- a/apps/dashboard/src/app/(dashboard)/monitors/[id]/sidebar.tsx +++ b/apps/dashboard/src/app/(dashboard)/monitors/[id]/sidebar.tsx @@ -57,7 +57,7 @@ export function Sidebar() { label: "Type", value: type ? ( - {type.label} + {type.label} ) : ( diff --git a/apps/dashboard/src/app/(dashboard)/monitors/create/page.tsx b/apps/dashboard/src/app/(dashboard)/monitors/create/page.tsx index 88240aa0..6d62bc99 100644 --- a/apps/dashboard/src/app/(dashboard)/monitors/create/page.tsx +++ b/apps/dashboard/src/app/(dashboard)/monitors/create/page.tsx @@ -104,6 +104,8 @@ export default function Page() { body: data.body, active: data.active, assertions: data.assertions, + grpcService: data.grpcService, + grpcTls: data.grpcTls, saveCheck: data.saveCheck, skipCheck: data.skipCheck, }); diff --git a/apps/dashboard/src/components/chart/chart-area-latency.tsx b/apps/dashboard/src/components/chart/chart-area-latency.tsx index b1dfc888..c76fe07a 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" | "dns" | "icmp"; + type: "http" | "tcp" | "dns" | "icmp" | "grpc"; 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 ac4672c7..eeba847d 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" | "dns" | "icmp"; + type: "http" | "tcp" | "dns" | "icmp" | "grpc"; 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 cfbb1f7e..a0d726a8 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" | "dns" | "icmp"; + type: "http" | "tcp" | "dns" | "icmp" | "grpc"; 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 41f23c05..6642dea2 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 @@ -55,6 +55,11 @@ export function DataTableBasics({ ); } + if (data.type === "grpc") { + return ( + + ); + } return null; } @@ -336,21 +341,18 @@ export function DataTableBasicsHTTP({ ); } -export function DataTableBasicsTCP({ - 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, - }); +type BasicsRequestFields = { + id?: string | null; + requestStatus?: string | null; + cronTimestamp: number; +}; + +type BasicsLocationFields = { + region: string; + trigger?: "cron" | "api" | "test" | null; +}; + +function BasicsTable({ children }: { children: React.ReactNode }) { return ( @@ -361,278 +363,218 @@ export function DataTableBasicsTCP({ Request - - - Result - - {/* TODO: add colored square like list (see columns) */} - -
-
-
- {data?.requestStatus ?? "unknown"} -
-
- - - {data.id ? ( - - - ID - - - {data.id} - - - ) : null} - - - Timestamp - - - - - - - - URI - - - {data.uri} - - - - - Latency - - - - - - - - Region - - - {regionConfig?.flag} {regionConfig?.code}{" "} - - {regionConfig?.location} - - - - - - Cloud Provider - - - - - {regionConfig?.provider} - - - - {data.trigger ? ( - - - Trigger - - - {data?.trigger} - - - ) : null} - {data?.errorMessage ? ( - <> - - Error Message - - - -
-                  {data.errorMessage}
-                
-
-
- - ) : null} + {children}
); } -export function DataTableBasicsICMP({ +function BasicsRow({ + label, + children, + cellClassName, +}: { + label: string; + children: React.ReactNode; + cellClassName?: string; +}) { + return ( + + + {label} + + + {children} + + + ); +} + +function BasicsRequestRows({ data }: { data: BasicsRequestFields }) { + return ( + <> + {/* TODO: add colored square like list (see columns) */} + +
+
+
{data?.requestStatus ?? "unknown"}
+
+ + {data.id ? {data.id} : null} + + + + + ); +} + +function BasicsLocationRows({ data, privateLocations, }: { - data: Extract & { - trigger?: "cron" | "api" | "test" | null; - }; + data: BasicsLocationFields; privateLocations?: PrivateLocation[]; }) { - const privateLocataion = privateLocations?.find( + const privateLocation = privateLocations?.find( (location) => String(location.id) === String(data.region), ); const regionConfig = getRegionInfo(data.region, { - location: privateLocataion?.name, + location: privateLocation?.name, }); + + return ( + <> + + {regionConfig?.flag} {regionConfig?.code}{" "} + {regionConfig?.location} + + + + + {regionConfig?.provider} + + + {data.trigger ? ( + {data?.trigger} + ) : null} + + ); +} + +function BasicsErrorMessageRows({ + errorMessage, +}: { + errorMessage?: string | null; +}) { + if (!errorMessage) return null; + + return ( + <> + + Error Message + + + +
+            {errorMessage}
+          
+
+
+ + ); +} + +export function DataTableBasicsTCP({ + data, + privateLocations, +}: { + data: Extract & { + trigger?: "cron" | "api" | "test" | null; + }; + privateLocations?: PrivateLocation[]; +}) { + return ( + + + {data.uri} + + + + + + + ); +} + +export function DataTableBasicsICMP({ + data, + privateLocations, +}: { + data: Extract & { + trigger?: "cron" | "api" | "test" | null; + }; + privateLocations?: PrivateLocation[]; +}) { 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} - -
+ + + {data.uri} + + + + + {formatMilliseconds(data.latencyMin)} /{" "} + {formatMilliseconds(data.latencyMax)} + + + {data.packetsReceived} / {data.packetsSent} received + + {formatPercentage(packetLoss)} + + + + ); +} + +export function DataTableBasicsGRPC({ + data, + privateLocations, +}: { + data: Extract & { + trigger?: "cron" | "api" | "test" | null; + }; + privateLocations?: PrivateLocation[]; +}) { + return ( + + + {data.uri} + + {data.service ? ( + data.service + ) : ( + overall server health + )} + + + {data.servingStatus ?? ( + no answer + )} + + + {data.grpcCode ?? N/A} + + + + + + + ); } @@ -645,202 +587,99 @@ export function DataTableBasicsDNS({ }; privateLocations?: PrivateLocation[]; }) { - const privateLocataion = privateLocations?.find( - (location) => String(location.id) === String(data.region), - ); - const regionConfig = getRegionInfo(data.region, { - location: privateLocataion?.name, - }); return ( - - - - - - - - Request - - - - Result - - {/* TODO: add colored square like list (see columns) */} - -
-
-
- {data?.requestStatus ?? "unknown"} -
-
- - - {data.id ? ( - - - ID - - - {data.id} + + + {data.uri} + + + + + {data?.records ? ( + <> + + Records + + + + + + + + + + + + + +
+ + + + + + {Object.entries(data?.records ?? {}).map( + ([key, value]) => ( + + + {key.toUpperCase()} + + + {Array.isArray(value) ? value.join(", ") : value} + + + ), + )} + +
+ + +
+                    {JSON.stringify(data?.records, null, 2)}
+                  
+
+ - ) : null} - - - Timestamp - - - - - - - - URI - - - {data.uri} - - - - - Latency - - - - - - - - Region - - - {regionConfig?.flag} {regionConfig?.code}{" "} - - {regionConfig?.location} - - - - - - Cloud Provider - - - - - {regionConfig?.provider} - - - - {data.trigger ? ( - - - Trigger - - - {data?.trigger} + + ) : null} + {data?.errorMessage ? ( + <> + + Error Message + + + +
+                {data.errorMessage}
+              
- ) : null} - {data?.records ? ( - <> - - Records - - - - - - - - - - - - - - - - - - - - {Object.entries(data?.records ?? {}).map( - ([key, value]) => ( - - - {key.toUpperCase()} - - - {Array.isArray(value) - ? value.join(", ") - : value} - - - ), - )} - -
-
- -
-                      {JSON.stringify(data?.records, null, 2)}
-                    
-
-
-
-
- - ) : null} - {data?.errorMessage ? ( - <> - - Error Message - - - + + ) : null} + {data.assertions ? ( + <> + + Assertions + + + + {!data.assertions || data.assertions === "[]" ? ( +
+ No assertions +
+ ) : (
-                  {data.errorMessage}
+                  {JSON.stringify(data.assertions, null, 2)}
                 
-
-
- - ) : null} - {data.assertions ? ( - <> - - Assertions - - - - {!data.assertions || data.assertions === "[]" ? ( -
- No assertions -
- ) : ( -
-                    {JSON.stringify(data.assertions, null, 2)}
-                  
- )} -
-
- - ) : null} - - + )} +
+
+ + ) : null} + ); } 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 71ecdfdd..e65dcb39 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 @@ -16,6 +16,7 @@ type TestTCP = RouterOutputs["checker"]["testTcp"]; type TestHTTP = RouterOutputs["checker"]["testHttp"]; type TestDNS = RouterOutputs["checker"]["testDns"]; type TestICMP = RouterOutputs["checker"]["testIcmp"]; +type TestGRPC = RouterOutputs["checker"]["testGrpc"]; type Monitor = NonNullable; export function DataTableSheetTest({ @@ -23,7 +24,7 @@ export function DataTableSheetTest({ monitor, onClose, }: { - data: TestTCP | TestHTTP | TestDNS | TestICMP | null; + data: TestTCP | TestHTTP | TestDNS | TestICMP | TestGRPC | null; monitor: Monitor; onClose: () => void; }) { @@ -47,7 +48,7 @@ export function DataTableSheetTest({ } function mapping( - data: TestTCP | TestHTTP | TestDNS | TestICMP, + data: TestTCP | TestHTTP | TestDNS | TestICMP | TestGRPC, monitor: Monitor, ) { switch (data.type) { @@ -126,6 +127,26 @@ function mapping( errorMessage: null, assertions: null, } as const; + case "grpc": + return { + id: null, + trigger: null, + timestamp: data.timestamp, + cronTimestamp: data.timestamp, + region: data.region, + type: data.type, + requestStatus: data.servingStatus === "SERVING" ? "success" : "error", + error: data.servingStatus !== "SERVING", + latency: data.latency ?? 0, + servingStatus: data.servingStatus ?? null, + grpcCode: data.grpcCode ?? null, + service: data.service ?? null, + timing: calculateTiming(data.timing), + uri: monitor.url, + monitorId: String(monitor.id), + errorMessage: data.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 5233f2d6..81464a00 100644 --- a/apps/dashboard/src/components/forms/monitor/form-general.tsx +++ b/apps/dashboard/src/components/forms/monitor/form-general.tsx @@ -12,8 +12,19 @@ import { stringCompareDictionary, textBodyAssertion, } from "@openstatus/assertions"; -import { monitorMethods } from "@openstatus/db/src/schema/monitors/constants"; -import { Globe, Network, Add, Speed, Server, Close } from "@openstatus/icons"; +import { + grpcTlsModes, + monitorMethods, +} from "@openstatus/db/src/schema/monitors/constants"; +import { + Add, + Api, + Close, + Globe, + Network, + Server, + Speed, +} from "@openstatus/icons"; import { AlertDialog, AlertDialogAction, @@ -72,7 +83,12 @@ import { FormCardTitle, } from "@/components/forms/form-card"; -const TYPES = ["http", "tcp", "dns", "icmp"] as const; +const TYPES = ["http", "tcp", "dns", "icmp", "grpc"] as const; +const GRPC_TLS_LABELS = { + tls: "TLS (verify certificate)", + tls_insecure: "TLS (skip verification)", + plaintext: "Plaintext (h2c)", +} as const; const HTTP_ASSERTION_TYPES = ["status", "header", "textBody"] as const; const DNS_ASSERTION_TYPES = dnsRecords; @@ -98,6 +114,8 @@ const schema = z.object({ ]), ), body: z.string().optional(), + grpcService: z.string().optional(), + grpcTls: z.enum(grpcTlsModes).optional().prefault("tls"), skipCheck: z.boolean().optional().prefault(false), saveCheck: z.boolean().optional().prefault(false), }); @@ -126,6 +144,8 @@ export function FormGeneral({ headers: [], body: "", assertions: [], + grpcService: "", + grpcTls: "tls", skipCheck: false, saveCheck: false, }, @@ -281,6 +301,7 @@ export function FormGeneral({ { value: "tcp", icon: Network, label: "TCP" }, { value: "dns", icon: Server, label: "DNS" }, { value: "icmp", icon: Speed, label: "ICMP" }, + { value: "grpc", icon: Api, label: "gRPC" }, ].map((type) => { return ( @@ -767,6 +788,145 @@ export function FormGeneral({
)} + {watchType === "grpc" && ( + + ( + + Host:Port + + + + + + The gRPC target. A port is required; bracket IPv6 + addresses. + + + )} + /> + ( + + TLS + + + + )} + /> + ( + + Service + + + + + + The service name passed to grpc.health.v1.Health/Check. + Leave empty to check overall server health. + + + )} + /> + ( + + Metadata + {field.value.map((header, index) => ( +
+ { + const newHeaders = [...field.value]; + newHeaders[index] = { + ...newHeaders[index], + key: e.target.value, + }; + field.onChange(newHeaders); + }} + /> + { + const newHeaders = [...field.value]; + newHeaders[index] = { + ...newHeaders[index], + value: e.target.value, + }; + field.onChange(newHeaders); + }} + /> + +
+ ))} +
+ +
+ + Sent with the health check request, commonly for + authentication. + +
+ )} + /> +
+ )} {watchType === "dns" && ( <> diff --git a/apps/dashboard/src/components/forms/monitor/update.tsx b/apps/dashboard/src/components/forms/monitor/update.tsx index 071607f4..0e2a08a3 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() { a.schema) : [], + grpcService: monitor.grpcService ?? "", + grpcTls: monitor.grpcTls ?? "tls", skipCheck: false, saveCheck: false, }} @@ -143,6 +145,8 @@ export function FormMonitorUpdate() { headers: values.headers, body: values.body, assertions: values.assertions, + grpcService: values.grpcService, + grpcTls: values.grpcTls, skipCheck: values.skipCheck, saveCheck: values.saveCheck, active: values.active, diff --git a/apps/dashboard/src/components/metric/global-uptime/section.tsx b/apps/dashboard/src/components/metric/global-uptime/section.tsx index 807d7367..7c91eac6 100644 --- a/apps/dashboard/src/components/metric/global-uptime/section.tsx +++ b/apps/dashboard/src/components/metric/global-uptime/section.tsx @@ -37,7 +37,7 @@ export function GlobalUptimeSection({ regions, }: { monitorId: string; - jobType: "http" | "tcp" | "dns" | "icmp"; + jobType: "http" | "tcp" | "dns" | "icmp" | "grpc"; period: (typeof PERIODS)[number]; regions: string[] | undefined; }) { diff --git a/apps/dashboard/src/data/monitors.client.ts b/apps/dashboard/src/data/monitors.client.ts index e59e8dbb..970fcf03 100644 --- a/apps/dashboard/src/data/monitors.client.ts +++ b/apps/dashboard/src/data/monitors.client.ts @@ -1,4 +1,5 @@ import { + Api, Settings, Copy, Duplicate, @@ -31,6 +32,11 @@ export const monitorTypes = [ label: "ICMP", icon: Speed, }, + { + id: "grpc", + label: "gRPC", + icon: Api, + }, ] as const; export const actions = [ diff --git a/apps/private-location/README.md b/apps/private-location/README.md index 7c1eb9a4..3c0d578e 100644 --- a/apps/private-location/README.md +++ b/apps/private-location/README.md @@ -16,3 +16,20 @@ needs one of the following on the host running the agent: sockets are unavailable. Without either, ICMP checks fail to open a socket and are reported as errors. + +## gRPC monitors + +gRPC monitors call `grpc.health.v1.Health/Check` on the target. Unlike ICMP they +open an ordinary TCP connection, so the agent needs no elevated capabilities and +no `ping_group_range` change. + +The monitor's TLS mode decides how the connection is secured: + +- `plaintext` — h2c, for a service behind a mesh or load balancer that has + already terminated TLS. +- `tls` — verify the certificate against the host's trust store. +- `tls_insecure` — use TLS but skip certificate verification, for internal + services presenting a self-signed or mesh-issued certificate. + +A server that is reachable but has not registered the health service answers +`UNIMPLEMENTED`, which is reported with its own message rather than as "down". diff --git a/apps/private-location/internal/database/models.go b/apps/private-location/internal/database/models.go index 9334198c..6627ea15 100644 --- a/apps/private-location/internal/database/models.go +++ b/apps/private-location/internal/database/models.go @@ -11,6 +11,7 @@ const ( JobTypeHTTP JobType = "http" JobTypeDNS JobType = "dns" JobTypeICMP JobType = "icmp" + JobTypeGRPC JobType = "grpc" ) type Monitor struct { @@ -39,6 +40,8 @@ type Monitor struct { Regions string `db:"regions" json:"-"` Status string `db:"status" json:"-"` Public bool `db:"public" json:"-"` + GrpcService sql.NullString `db:"grpc_service" json:"-"` + GrpcTls sql.NullString `db:"grpc_tls" json:"-"` } type PrivateLocation struct { diff --git a/apps/private-location/internal/server/db_testdata b/apps/private-location/internal/server/db_testdata index 57366385..b174cc57 100644 --- a/apps/private-location/internal/server/db_testdata +++ b/apps/private-location/internal/server/db_testdata @@ -87,7 +87,7 @@ CREATE TABLE "monitor" ( `headers` text DEFAULT '', `body` text DEFAULT '', `method` text(5) DEFAULT 'GET', - `created_at` integer DEFAULT (strftime('%s', 'now')), `regions` text DEFAULT '' NOT NULL, `updated_at` integer, `status` text(2) DEFAULT 'active' NOT NULL, `assertions` text, `deleted_at` integer, `public` integer DEFAULT false, `timeout` integer DEFAULT 45000 NOT NULL, `degraded_after` integer, `otel_endpoint` text, `otel_headers` text, `retry` integer DEFAULT 3, `follow_redirects` integer DEFAULT true, + `created_at` integer DEFAULT (strftime('%s', 'now')), `regions` text DEFAULT '' NOT NULL, `updated_at` integer, `status` text(2) DEFAULT 'active' NOT NULL, `assertions` text, `deleted_at` integer, `public` integer DEFAULT false, `timeout` integer DEFAULT 45000 NOT NULL, `degraded_after` integer, `otel_endpoint` text, `otel_headers` text, `retry` integer DEFAULT 3, `follow_redirects` integer DEFAULT true, `grpc_service` text, `grpc_tls` text DEFAULT 'tls', FOREIGN KEY (`workspace_id`) REFERENCES `workspace`(`id`) ON UPDATE no action ON DELETE no action ); diff --git a/apps/private-location/internal/server/ingest_grpc.go b/apps/private-location/internal/server/ingest_grpc.go new file mode 100644 index 00000000..479436e3 --- /dev/null +++ b/apps/private-location/internal/server/ingest_grpc.go @@ -0,0 +1,89 @@ +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 GRPCData 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"` + Service string `json:"service,omitempty"` + ServingStatus string `json:"servingStatus,omitempty"` + 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"` + CronTimestamp int64 `json:"cronTimestamp"` + GRPCCode int64 `json:"grpcCode"` + + Error uint8 `json:"error"` +} + +func (h *privateLocationHandler) IngestGRPC(ctx context.Context, req *connect.Request[private_locationv1.IngestGRPCRequest]) (*connect.Response[private_locationv1.IngestGRPCResponse], error) { + token := req.Header().Get("openstatus-token") + if token == "" { + return nil, connect.NewError(connect.CodeUnauthenticated, ErrMissingToken) + } + + if err := ValidateIngestGRPCRequest(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.DatasourceGRPC, + } + } + + data := GRPCData{ + ID: req.Msg.Id, + WorkspaceID: int64(ic.Monitor.WorkspaceID), + Timestamp: req.Msg.Timestamp, + Error: uint8(req.Msg.Error), + ErrorMessage: req.Msg.Message, + Region: strconv.Itoa(ic.Region.ID), + MonitorID: int64(ic.Monitor.ID), + Timing: req.Msg.Timing, + Latency: req.Msg.Latency, + GRPCCode: req.Msg.GrpcCode, + ServingStatus: req.Msg.ServingStatus, + Service: req.Msg.Service, + CronTimestamp: req.Msg.CronTimestamp, + Trigger: "cron", + URI: req.Msg.Uri, + RequestStatus: req.Msg.RequestStatus, + } + + h.sendEventAndUpdateLastSeen(ctx, data, tinybird.DatasourceGRPC, 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.IngestGRPCResponse{}), nil +} diff --git a/apps/private-location/internal/server/monitors.go b/apps/private-location/internal/server/monitors.go index 04cece34..29d4f47d 100644 --- a/apps/private-location/internal/server/monitors.go +++ b/apps/private-location/internal/server/monitors.go @@ -5,6 +5,7 @@ import ( "database/sql" "encoding/json" "errors" + "fmt" "strconv" "connectrpc.com/connect" @@ -191,11 +192,11 @@ func (h *privateLocationHandler) Monitors(ctx context.Context, req *connect.Requ } var monitors []database.Monitor - err := h.db.Select(&monitors, "SELECT monitor.id, monitor.job_type, monitor.url, monitor.periodicity, monitor.method, monitor.body, monitor.timeout, monitor.degraded_after, monitor.follow_redirects, monitor.headers, monitor.assertions, monitor.workspace_id, monitor.retry, monitor.otel_endpoint, monitor.otel_headers FROM monitor JOIN private_location_to_monitor a ON monitor.id = a.monitor_id JOIN private_location b ON a.private_location_id = b.id WHERE b.token = ? AND monitor.deleted_at IS NULL and monitor.active = 1", token) + err := h.db.Select(&monitors, "SELECT monitor.id, monitor.job_type, monitor.url, monitor.periodicity, monitor.method, monitor.body, monitor.timeout, monitor.degraded_after, monitor.follow_redirects, monitor.headers, monitor.assertions, monitor.workspace_id, monitor.retry, monitor.otel_endpoint, monitor.otel_headers, monitor.grpc_service, monitor.grpc_tls FROM monitor JOIN private_location_to_monitor a ON monitor.id = a.monitor_id JOIN private_location b ON a.private_location_id = b.id WHERE b.token = ? AND monitor.deleted_at IS NULL and monitor.active = 1", token) if err != nil { return nil, connect.NewError(connect.CodeInternal, err) } - httpMonitors, tcpMonitors, dnsMonitors, icmpMonitors, workspaceId := mapMonitors(ctx, monitors) + httpMonitors, tcpMonitors, dnsMonitors, icmpMonitors, grpcMonitors, workspaceId := mapMonitors(ctx, monitors) // Enrich wide event with monitor counts if holder := GetEvent(ctx); holder != nil { @@ -205,6 +206,7 @@ func (h *privateLocationHandler) Monitors(ctx context.Context, req *connect.Requ "tcp_monitors": len(tcpMonitors), "dns_monitors": len(dnsMonitors), "icmp_monitors": len(icmpMonitors), + "grpc_monitors": len(grpcMonitors), "total_monitors": len(monitors), } } @@ -214,6 +216,7 @@ func (h *privateLocationHandler) Monitors(ctx context.Context, req *connect.Requ TcpMonitors: tcpMonitors, DnsMonitors: dnsMonitors, IcmpMonitors: icmpMonitors, + GrpcMonitors: grpcMonitors, Region: location.Name, }), nil } @@ -223,6 +226,7 @@ func mapMonitors(ctx context.Context, monitors []database.Monitor) ( []*private_locationv1.TCPMonitor, []*private_locationv1.DNSMonitor, []*private_locationv1.ICMPMonitor, + []*private_locationv1.GRPCMonitor, int, ) { var workspaceId int @@ -230,6 +234,7 @@ func mapMonitors(ctx context.Context, monitors []database.Monitor) ( var tcpMonitors []*private_locationv1.TCPMonitor var dnsMonitors []*private_locationv1.DNSMonitor var icmpMonitors []*private_locationv1.ICMPMonitor + var grpcMonitors []*private_locationv1.GRPCMonitor for _, monitor := range monitors { if workspaceId == 0 { workspaceId = monitor.WorkspaceID @@ -244,10 +249,16 @@ func mapMonitors(ctx context.Context, monitors []database.Monitor) ( dnsMonitors = append(dnsMonitors, toDNSMonitor(ctx, monitor)) case database.JobTypeICMP: icmpMonitors = append(icmpMonitors, toICMPMonitor(ctx, monitor)) + case database.JobTypeGRPC: + grpcMonitors = append(grpcMonitors, toGRPCMonitor(ctx, monitor)) + default: + // Without this a job type the checker does not know is dropped in + // silence: no row, no log, and a monitor that reads as "no data yet". + addParseError(ctx, "unsupported_job_type", fmt.Errorf("monitor %d has job type %q", monitor.ID, monitor.JobType)) } } - return httpMonitors, tcpMonitors, dnsMonitors, icmpMonitors, workspaceId + return httpMonitors, tcpMonitors, dnsMonitors, icmpMonitors, grpcMonitors, workspaceId } func toHTTPMonitor(ctx context.Context, monitor database.Monitor) *private_locationv1.HTTPMonitor { @@ -301,6 +312,34 @@ func toICMPMonitor(ctx context.Context, monitor database.Monitor) *private_locat } } +func toGRPCMonitor(ctx context.Context, monitor database.Monitor) *private_locationv1.GRPCMonitor { + var metadata []*private_locationv1.Headers + if monitor.Headers != "" { + if err := json.Unmarshal([]byte(monitor.Headers), &metadata); err != nil { + addParseError(ctx, "metadata_unmarshal", err) + metadata = nil + } + } + + tlsMode := monitor.GrpcTls.String + if tlsMode == "" { + tlsMode = "tls" + } + + return &private_locationv1.GRPCMonitor{ + Id: strconv.Itoa(monitor.ID), + Uri: monitor.URL, + Timeout: monitor.Timeout, + DegradedAt: &monitor.DegradedAfter.Int64, + Periodicity: monitor.Periodicity, + Retry: int64(monitor.Retry), + Service: monitor.GrpcService.String, + TlsMode: tlsMode, + Metadata: metadata, + 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 cf9f11f2..f6ef6914 100644 --- a/apps/private-location/internal/server/validation.go +++ b/apps/private-location/internal/server/validation.go @@ -10,64 +10,64 @@ import ( // Validation errors var ( - ErrEmptyMonitorID = errors.New("monitor_id is required") - ErrEmptyID = errors.New("id is required") - ErrInvalidLatency = errors.New("latency must be non-negative") + ErrEmptyMonitorID = errors.New("monitor_id is required") + ErrEmptyID = errors.New("id is required") + ErrInvalidLatency = errors.New("latency must be non-negative") ErrInvalidTimestamp = errors.New("timestamp must be positive") + ErrInvalidGRPCCode = errors.New("grpc_code must be a canonical gRPC status code") + ErrInvalidErrorFlag = errors.New("error must be 0 or 1") ) -// ValidateIngestHTTPRequest validates an HTTP ingest request -func ValidateIngestHTTPRequest(req *private_locationv1.IngestHTTPRequest) error { - if req.MonitorId == "" { +// maxGRPCStatusCode is UNAUTHENTICATED, the highest canonical code. +const maxGRPCStatusCode = 16 + +// validateIngestCommon holds the rules every ingest request shares. Keeping one +// copy stops the per-type validators drifting as the rules change. +func validateIngestCommon(monitorID string, latency, timestamp int64) error { + if monitorID == "" { return ErrEmptyMonitorID } - if req.Latency < 0 { + if latency < 0 { return ErrInvalidLatency } - if req.Timestamp <= 0 { + if timestamp <= 0 { return ErrInvalidTimestamp } return nil } +// ValidateIngestHTTPRequest validates an HTTP ingest request +func ValidateIngestHTTPRequest(req *private_locationv1.IngestHTTPRequest) error { + return validateIngestCommon(req.MonitorId, req.Latency, req.Timestamp) +} + // ValidateIngestTCPRequest validates a TCP ingest request func ValidateIngestTCPRequest(req *private_locationv1.IngestTCPRequest) error { - if req.MonitorId == "" { - return ErrEmptyMonitorID - } - if req.Latency < 0 { - return ErrInvalidLatency - } - if req.Timestamp <= 0 { - return ErrInvalidTimestamp - } - return nil + return validateIngestCommon(req.MonitorId, req.Latency, req.Timestamp) } // ValidateIngestDNSRequest validates a DNS ingest request func ValidateIngestDNSRequest(req *private_locationv1.IngestDNSRequest) error { - if req.MonitorId == "" { - return ErrEmptyMonitorID - } - if req.Latency < 0 { - return ErrInvalidLatency - } - if req.Timestamp <= 0 { - return ErrInvalidTimestamp - } - return nil + return validateIngestCommon(req.MonitorId, req.Latency, req.Timestamp) } // ValidateIngestICMPRequest validates an ICMP ingest request func ValidateIngestICMPRequest(req *private_locationv1.IngestICMPRequest) error { - if req.MonitorId == "" { - return ErrEmptyMonitorID + return validateIngestCommon(req.MonitorId, req.Latency, req.Timestamp) +} + +// ValidateIngestGRPCRequest validates a gRPC ingest request. The two numeric +// fields are narrowed on the way into the Tinybird row, so they are bounded +// here rather than wrapping silently. +func ValidateIngestGRPCRequest(req *private_locationv1.IngestGRPCRequest) error { + if err := validateIngestCommon(req.MonitorId, req.Latency, req.Timestamp); err != nil { + return err } - if req.Latency < 0 { - return ErrInvalidLatency + if req.GrpcCode < 0 || req.GrpcCode > maxGRPCStatusCode { + return ErrInvalidGRPCCode } - if req.Timestamp <= 0 { - return ErrInvalidTimestamp + if req.Error < 0 || req.Error > 1 { + return ErrInvalidErrorFlag } return nil } diff --git a/apps/private-location/internal/server/validation_test.go b/apps/private-location/internal/server/validation_test.go index d83f80ab..a4b19a16 100644 --- a/apps/private-location/internal/server/validation_test.go +++ b/apps/private-location/internal/server/validation_test.go @@ -1,271 +1,94 @@ -package server_test +package server import ( + "errors" "testing" - "connectrpc.com/connect" - "github.com/openstatushq/openstatus/apps/private-location/internal/server" private_locationv1 "github.com/openstatushq/openstatus/apps/private-location/proto/private_location/v1" ) -func TestValidateIngestHTTPRequest(t *testing.T) { - tests := []struct { - name string - req *private_locationv1.IngestHTTPRequest - wantErr error +// The four pre-existing validators now share one helper, so this covers the +// rules once rather than four times. +func TestValidateIngestCommon(t *testing.T) { + cases := []struct { + name string + monitorID string + latency int64 + timestamp int64 + want error }{ - { - name: "valid request", - req: &private_locationv1.IngestHTTPRequest{ - MonitorId: "monitor-123", - Latency: 100, - Timestamp: 1234567890, - }, - wantErr: nil, - }, - { - name: "valid request with zero latency", - req: &private_locationv1.IngestHTTPRequest{ - MonitorId: "monitor-123", - Latency: 0, - Timestamp: 1234567890, - }, - wantErr: nil, - }, - { - name: "empty monitor_id", - req: &private_locationv1.IngestHTTPRequest{ - MonitorId: "", - Latency: 100, - Timestamp: 1234567890, - }, - wantErr: server.ErrEmptyMonitorID, - }, - { - name: "negative latency", - req: &private_locationv1.IngestHTTPRequest{ - MonitorId: "monitor-123", - Latency: -1, - Timestamp: 1234567890, - }, - wantErr: server.ErrInvalidLatency, - }, - { - name: "zero timestamp", - req: &private_locationv1.IngestHTTPRequest{ - MonitorId: "monitor-123", - Latency: 100, - Timestamp: 0, - }, - wantErr: server.ErrInvalidTimestamp, - }, - { - name: "negative timestamp", - req: &private_locationv1.IngestHTTPRequest{ - MonitorId: "monitor-123", - Latency: 100, - Timestamp: -1, - }, - wantErr: server.ErrInvalidTimestamp, - }, + {name: "valid", monitorID: "1", latency: 10, timestamp: 1761000000000}, + {name: "zero latency is fine", monitorID: "1", latency: 0, timestamp: 1761000000000}, + {name: "missing monitor id", monitorID: "", latency: 10, timestamp: 1, want: ErrEmptyMonitorID}, + {name: "negative latency", monitorID: "1", latency: -1, timestamp: 1, want: ErrInvalidLatency}, + {name: "zero timestamp", monitorID: "1", latency: 10, timestamp: 0, want: ErrInvalidTimestamp}, + {name: "negative timestamp", monitorID: "1", latency: 10, timestamp: -5, want: ErrInvalidTimestamp}, } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := server.ValidateIngestHTTPRequest(tt.req) - if err != tt.wantErr { - t.Errorf("ValidateIngestHTTPRequest() error = %v, wantErr %v", err, tt.wantErr) + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := validateIngestCommon(tc.monitorID, tc.latency, tc.timestamp) + if !errors.Is(err, tc.want) { + t.Fatalf("got %v, want %v", err, tc.want) } }) } } -func TestValidateIngestTCPRequest(t *testing.T) { - tests := []struct { - name string - req *private_locationv1.IngestTCPRequest - wantErr error - }{ - { - name: "valid request", - req: &private_locationv1.IngestTCPRequest{ - MonitorId: "monitor-123", - Latency: 100, - Timestamp: 1234567890, - }, - wantErr: nil, - }, - { - name: "valid request with zero latency", - req: &private_locationv1.IngestTCPRequest{ - MonitorId: "monitor-123", - Latency: 0, - Timestamp: 1234567890, - }, - wantErr: nil, - }, - { - name: "empty monitor_id", - req: &private_locationv1.IngestTCPRequest{ - MonitorId: "", - Latency: 100, - Timestamp: 1234567890, - }, - wantErr: server.ErrEmptyMonitorID, - }, - { - name: "negative latency", - req: &private_locationv1.IngestTCPRequest{ - MonitorId: "monitor-123", - Latency: -1, - Timestamp: 1234567890, - }, - wantErr: server.ErrInvalidLatency, - }, - { - name: "zero timestamp", - req: &private_locationv1.IngestTCPRequest{ - MonitorId: "monitor-123", - Latency: 100, - Timestamp: 0, - }, - wantErr: server.ErrInvalidTimestamp, - }, - { - name: "negative timestamp", - req: &private_locationv1.IngestTCPRequest{ - MonitorId: "monitor-123", - Latency: 100, - Timestamp: -1, - }, - wantErr: server.ErrInvalidTimestamp, - }, +func TestValidateIngestGRPCRequest(t *testing.T) { + valid := func() *private_locationv1.IngestGRPCRequest { + return &private_locationv1.IngestGRPCRequest{ + Id: "01", + MonitorId: "1", + Latency: 120, + Timestamp: 1761000000000, + CronTimestamp: 1761000000000, + GrpcCode: 0, + Error: 0, + } } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := server.ValidateIngestTCPRequest(tt.req) - if err != tt.wantErr { - t.Errorf("ValidateIngestTCPRequest() error = %v, wantErr %v", err, tt.wantErr) - } - }) + if err := ValidateIngestGRPCRequest(valid()); err != nil { + t.Fatalf("expected a valid request to pass, got %v", err) } -} -func TestValidateIngestDNSRequest(t *testing.T) { - tests := []struct { - name string - req *private_locationv1.IngestDNSRequest - wantErr error - }{ - { - name: "valid request", - req: &private_locationv1.IngestDNSRequest{ - MonitorId: "monitor-123", - Latency: 100, - Timestamp: 1234567890, - }, - wantErr: nil, - }, - { - name: "valid request with zero latency", - req: &private_locationv1.IngestDNSRequest{ - MonitorId: "monitor-123", - Latency: 0, - Timestamp: 1234567890, - }, - wantErr: nil, - }, - { - name: "empty monitor_id", - req: &private_locationv1.IngestDNSRequest{ - MonitorId: "", - Latency: 100, - Timestamp: 1234567890, - }, - wantErr: server.ErrEmptyMonitorID, - }, - { - name: "negative latency", - req: &private_locationv1.IngestDNSRequest{ - MonitorId: "monitor-123", - Latency: -1, - Timestamp: 1234567890, - }, - wantErr: server.ErrInvalidLatency, - }, - { - name: "zero timestamp", - req: &private_locationv1.IngestDNSRequest{ - MonitorId: "monitor-123", - Latency: 100, - Timestamp: 0, - }, - wantErr: server.ErrInvalidTimestamp, - }, - { - name: "negative timestamp", - req: &private_locationv1.IngestDNSRequest{ - MonitorId: "monitor-123", - Latency: 100, - Timestamp: -1, - }, - wantErr: server.ErrInvalidTimestamp, - }, - } + t.Run("inherits the shared rules", func(t *testing.T) { + req := valid() + req.Timestamp = 0 + if err := ValidateIngestGRPCRequest(req); !errors.Is(err, ErrInvalidTimestamp) { + t.Fatalf("got %v, want %v", err, ErrInvalidTimestamp) + } + }) - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := server.ValidateIngestDNSRequest(tt.req) - if err != tt.wantErr { - t.Errorf("ValidateIngestDNSRequest() error = %v, wantErr %v", err, tt.wantErr) + // Both fields are narrowed on the way into the Tinybird row, so an + // out-of-range value would wrap into a plausible-looking one. + t.Run("rejects an out of range status code", func(t *testing.T) { + for _, code := range []int64{-1, 17, 300} { + req := valid() + req.GrpcCode = code + if err := ValidateIngestGRPCRequest(req); !errors.Is(err, ErrInvalidGRPCCode) { + t.Fatalf("code %d: got %v, want %v", code, err, ErrInvalidGRPCCode) } - }) - } -} - -func TestNewValidationError(t *testing.T) { - tests := []struct { - name string - err error - wantCode connect.Code - wantContains string - }{ - { - name: "empty monitor id error", - err: server.ErrEmptyMonitorID, - wantCode: connect.CodeInvalidArgument, - wantContains: "monitor_id is required", - }, - { - name: "empty id error", - err: server.ErrEmptyID, - wantCode: connect.CodeInvalidArgument, - wantContains: "id is required", - }, - { - name: "invalid latency error", - err: server.ErrInvalidLatency, - wantCode: connect.CodeInvalidArgument, - wantContains: "latency must be non-negative", - }, - { - name: "invalid timestamp error", - err: server.ErrInvalidTimestamp, - wantCode: connect.CodeInvalidArgument, - wantContains: "timestamp must be positive", - }, - } + } + }) - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - connErr := server.NewValidationError(tt.err) - if connErr.Code() != tt.wantCode { - t.Errorf("NewValidationError() code = %v, want %v", connErr.Code(), tt.wantCode) + t.Run("accepts every canonical status code", func(t *testing.T) { + for code := int64(0); code <= maxGRPCStatusCode; code++ { + req := valid() + req.GrpcCode = code + if err := ValidateIngestGRPCRequest(req); err != nil { + t.Fatalf("code %d should be valid, got %v", code, err) } - if connErr.Message() == "" { - t.Error("NewValidationError() message should not be empty") + } + }) + + t.Run("rejects an out of range error flag", func(t *testing.T) { + for _, flag := range []int64{-1, 2, 256} { + req := valid() + req.Error = flag + if err := ValidateIngestGRPCRequest(req); !errors.Is(err, ErrInvalidErrorFlag) { + t.Fatalf("flag %d: got %v, want %v", flag, err, ErrInvalidErrorFlag) } - }) - } + } + }) } diff --git a/apps/private-location/internal/tinybird/client.go b/apps/private-location/internal/tinybird/client.go index b50fd538..27382b7f 100644 --- a/apps/private-location/internal/tinybird/client.go +++ b/apps/private-location/internal/tinybird/client.go @@ -16,6 +16,7 @@ const ( DatasourceTCP = "tcp_response__v0" DatasourceDNS = "dns_response__v0" DatasourceICMP = "icmp_response__v0" + DatasourceGRPC = "grpc_response__v0" ) func getBaseURL() string { diff --git a/apps/private-location/proto/private_location/v1/grpc_monitor.pb.go b/apps/private-location/proto/private_location/v1/grpc_monitor.pb.go new file mode 100644 index 00000000..6784cdc9 --- /dev/null +++ b/apps/private-location/proto/private_location/v1/grpc_monitor.pb.go @@ -0,0 +1,213 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: private_location/v1/grpc_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 GRPCMonitor 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"` + Service string `protobuf:"bytes,7,opt,name=service,proto3" json:"service,omitempty"` + TlsMode string `protobuf:"bytes,8,opt,name=tls_mode,json=tlsMode,proto3" json:"tls_mode,omitempty"` + Metadata []*Headers `protobuf:"bytes,10,rep,name=metadata,proto3" json:"metadata,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 *GRPCMonitor) Reset() { + *x = GRPCMonitor{} + mi := &file_private_location_v1_grpc_monitor_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GRPCMonitor) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GRPCMonitor) ProtoMessage() {} + +func (x *GRPCMonitor) ProtoReflect() protoreflect.Message { + mi := &file_private_location_v1_grpc_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 GRPCMonitor.ProtoReflect.Descriptor instead. +func (*GRPCMonitor) Descriptor() ([]byte, []int) { + return file_private_location_v1_grpc_monitor_proto_rawDescGZIP(), []int{0} +} + +func (x *GRPCMonitor) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *GRPCMonitor) GetUri() string { + if x != nil { + return x.Uri + } + return "" +} + +func (x *GRPCMonitor) GetTimeout() int64 { + if x != nil { + return x.Timeout + } + return 0 +} + +func (x *GRPCMonitor) GetDegradedAt() int64 { + if x != nil && x.DegradedAt != nil { + return *x.DegradedAt + } + return 0 +} + +func (x *GRPCMonitor) GetPeriodicity() string { + if x != nil { + return x.Periodicity + } + return "" +} + +func (x *GRPCMonitor) GetRetry() int64 { + if x != nil { + return x.Retry + } + return 0 +} + +func (x *GRPCMonitor) GetService() string { + if x != nil { + return x.Service + } + return "" +} + +func (x *GRPCMonitor) GetTlsMode() string { + if x != nil { + return x.TlsMode + } + return "" +} + +func (x *GRPCMonitor) GetMetadata() []*Headers { + if x != nil { + return x.Metadata + } + return nil +} + +func (x *GRPCMonitor) GetOtelConfig() *OtelConfig { + if x != nil { + return x.OtelConfig + } + return nil +} + +var File_private_location_v1_grpc_monitor_proto protoreflect.FileDescriptor + +const file_private_location_v1_grpc_monitor_proto_rawDesc = "" + + "\n" + + "&private_location/v1/grpc_monitor.proto\x12\x13private_location.v1\x1a\x1eprivate_location/v1/otel.proto\"\xe8\x02\n" + + "\vGRPCMonitor\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\x18\n" + + "\aservice\x18\a \x01(\tR\aservice\x12\x19\n" + + "\btls_mode\x18\b \x01(\tR\atlsMode\x128\n" + + "\bmetadata\x18\n" + + " \x03(\v2\x1c.private_location.v1.HeadersR\bmetadata\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_grpc_monitor_proto_rawDescOnce sync.Once + file_private_location_v1_grpc_monitor_proto_rawDescData []byte +) + +func file_private_location_v1_grpc_monitor_proto_rawDescGZIP() []byte { + file_private_location_v1_grpc_monitor_proto_rawDescOnce.Do(func() { + file_private_location_v1_grpc_monitor_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_private_location_v1_grpc_monitor_proto_rawDesc), len(file_private_location_v1_grpc_monitor_proto_rawDesc))) + }) + return file_private_location_v1_grpc_monitor_proto_rawDescData +} + +var file_private_location_v1_grpc_monitor_proto_msgTypes = make([]protoimpl.MessageInfo, 1) +var file_private_location_v1_grpc_monitor_proto_goTypes = []any{ + (*GRPCMonitor)(nil), // 0: private_location.v1.GRPCMonitor + (*Headers)(nil), // 1: private_location.v1.Headers + (*OtelConfig)(nil), // 2: private_location.v1.OtelConfig +} +var file_private_location_v1_grpc_monitor_proto_depIdxs = []int32{ + 1, // 0: private_location.v1.GRPCMonitor.metadata:type_name -> private_location.v1.Headers + 2, // 1: private_location.v1.GRPCMonitor.otel_config:type_name -> private_location.v1.OtelConfig + 2, // [2:2] is the sub-list for method output_type + 2, // [2:2] is the sub-list for method input_type + 2, // [2:2] is the sub-list for extension type_name + 2, // [2:2] is the sub-list for extension extendee + 0, // [0:2] is the sub-list for field type_name +} + +func init() { file_private_location_v1_grpc_monitor_proto_init() } +func file_private_location_v1_grpc_monitor_proto_init() { + if File_private_location_v1_grpc_monitor_proto != nil { + return + } + file_private_location_v1_otel_proto_init() + file_private_location_v1_grpc_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_grpc_monitor_proto_rawDesc), len(file_private_location_v1_grpc_monitor_proto_rawDesc)), + NumEnums: 0, + NumMessages: 1, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_private_location_v1_grpc_monitor_proto_goTypes, + DependencyIndexes: file_private_location_v1_grpc_monitor_proto_depIdxs, + MessageInfos: file_private_location_v1_grpc_monitor_proto_msgTypes, + }.Build() + File_private_location_v1_grpc_monitor_proto = out.File + file_private_location_v1_grpc_monitor_proto_goTypes = nil + file_private_location_v1_grpc_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 8138ab7c..e52f9503 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 @@ -47,6 +47,9 @@ const ( // PrivateLocationServiceIngestICMPProcedure is the fully-qualified name of the // PrivateLocationService's IngestICMP RPC. PrivateLocationServiceIngestICMPProcedure = "/private_location.v1.PrivateLocationService/IngestICMP" + // PrivateLocationServiceIngestGRPCProcedure is the fully-qualified name of the + // PrivateLocationService's IngestGRPC RPC. + PrivateLocationServiceIngestGRPCProcedure = "/private_location.v1.PrivateLocationService/IngestGRPC" ) // PrivateLocationServiceClient is a client for the private_location.v1.PrivateLocationService @@ -57,6 +60,7 @@ type PrivateLocationServiceClient interface { 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) + IngestGRPC(context.Context, *connect.Request[IngestGRPCRequest]) (*connect.Response[IngestGRPCResponse], error) } // NewPrivateLocationServiceClient constructs a client for the @@ -100,6 +104,12 @@ func NewPrivateLocationServiceClient(httpClient connect.HTTPClient, baseURL stri connect.WithSchema(privateLocationServiceMethods.ByName("IngestICMP")), connect.WithClientOptions(opts...), ), + ingestGRPC: connect.NewClient[IngestGRPCRequest, IngestGRPCResponse]( + httpClient, + baseURL+PrivateLocationServiceIngestGRPCProcedure, + connect.WithSchema(privateLocationServiceMethods.ByName("IngestGRPC")), + connect.WithClientOptions(opts...), + ), } } @@ -110,6 +120,7 @@ type privateLocationServiceClient struct { ingestHTTP *connect.Client[IngestHTTPRequest, IngestHTTPResponse] ingestDNS *connect.Client[IngestDNSRequest, IngestDNSResponse] ingestICMP *connect.Client[IngestICMPRequest, IngestICMPResponse] + ingestGRPC *connect.Client[IngestGRPCRequest, IngestGRPCResponse] } // Monitors calls private_location.v1.PrivateLocationService.Monitors. @@ -137,6 +148,11 @@ func (c *privateLocationServiceClient) IngestICMP(ctx context.Context, req *conn return c.ingestICMP.CallUnary(ctx, req) } +// IngestGRPC calls private_location.v1.PrivateLocationService.IngestGRPC. +func (c *privateLocationServiceClient) IngestGRPC(ctx context.Context, req *connect.Request[IngestGRPCRequest]) (*connect.Response[IngestGRPCResponse], error) { + return c.ingestGRPC.CallUnary(ctx, req) +} + // PrivateLocationServiceHandler is an implementation of the // private_location.v1.PrivateLocationService service. type PrivateLocationServiceHandler interface { @@ -145,6 +161,7 @@ type PrivateLocationServiceHandler interface { 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) + IngestGRPC(context.Context, *connect.Request[IngestGRPCRequest]) (*connect.Response[IngestGRPCResponse], error) } // NewPrivateLocationServiceHandler builds an HTTP handler from the service implementation. It @@ -184,6 +201,12 @@ func NewPrivateLocationServiceHandler(svc PrivateLocationServiceHandler, opts .. connect.WithSchema(privateLocationServiceMethods.ByName("IngestICMP")), connect.WithHandlerOptions(opts...), ) + privateLocationServiceIngestGRPCHandler := connect.NewUnaryHandler( + PrivateLocationServiceIngestGRPCProcedure, + svc.IngestGRPC, + connect.WithSchema(privateLocationServiceMethods.ByName("IngestGRPC")), + connect.WithHandlerOptions(opts...), + ) return "/private_location.v1.PrivateLocationService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case PrivateLocationServiceMonitorsProcedure: @@ -196,6 +219,8 @@ func NewPrivateLocationServiceHandler(svc PrivateLocationServiceHandler, opts .. privateLocationServiceIngestDNSHandler.ServeHTTP(w, r) case PrivateLocationServiceIngestICMPProcedure: privateLocationServiceIngestICMPHandler.ServeHTTP(w, r) + case PrivateLocationServiceIngestGRPCProcedure: + privateLocationServiceIngestGRPCHandler.ServeHTTP(w, r) default: http.NotFound(w, r) } @@ -224,3 +249,7 @@ func (UnimplementedPrivateLocationServiceHandler) IngestDNS(context.Context, *co 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")) } + +func (UnimplementedPrivateLocationServiceHandler) IngestGRPC(context.Context, *connect.Request[IngestGRPCRequest]) (*connect.Response[IngestGRPCResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("private_location.v1.PrivateLocationService.IngestGRPC 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 a28112bc..6cded061 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 @@ -63,6 +63,7 @@ type MonitorsResponse struct { 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"` + GrpcMonitors []*GRPCMonitor `protobuf:"bytes,6,rep,name=grpc_monitors,json=grpcMonitors,proto3" json:"grpc_monitors,omitempty"` Region string `protobuf:"bytes,4,opt,name=region,proto3" json:"region,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache @@ -126,6 +127,13 @@ func (x *MonitorsResponse) GetIcmpMonitors() []*ICMPMonitor { return nil } +func (x *MonitorsResponse) GetGrpcMonitors() []*GRPCMonitor { + if x != nil { + return x.GrpcMonitors + } + return nil +} + func (x *MonitorsResponse) GetRegion() string { if x != nil { return x.Region @@ -849,17 +857,194 @@ func (*IngestICMPResponse) Descriptor() ([]byte, []int) { return file_private_location_v1_private_location_proto_rawDescGZIP(), []int{10} } +type IngestGRPCRequest 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"` + Timestamp int64 `protobuf:"varint,4,opt,name=timestamp,proto3" json:"timestamp,omitempty"` + CronTimestamp int64 `protobuf:"varint,5,opt,name=cronTimestamp,proto3" json:"cronTimestamp,omitempty"` + Uri string `protobuf:"bytes,6,opt,name=uri,proto3" json:"uri,omitempty"` + Service string `protobuf:"bytes,7,opt,name=service,proto3" json:"service,omitempty"` + ServingStatus string `protobuf:"bytes,8,opt,name=servingStatus,proto3" json:"servingStatus,omitempty"` + GrpcCode int64 `protobuf:"varint,9,opt,name=grpcCode,proto3" json:"grpcCode,omitempty"` + Message string `protobuf:"bytes,10,opt,name=message,proto3" json:"message,omitempty"` + RequestStatus string `protobuf:"bytes,11,opt,name=requestStatus,proto3" json:"requestStatus,omitempty"` + Error int64 `protobuf:"varint,12,opt,name=error,proto3" json:"error,omitempty"` + Timing string `protobuf:"bytes,13,opt,name=timing,proto3" json:"timing,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *IngestGRPCRequest) Reset() { + *x = IngestGRPCRequest{} + mi := &file_private_location_v1_private_location_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *IngestGRPCRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*IngestGRPCRequest) ProtoMessage() {} + +func (x *IngestGRPCRequest) ProtoReflect() protoreflect.Message { + mi := &file_private_location_v1_private_location_proto_msgTypes[11] + 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 IngestGRPCRequest.ProtoReflect.Descriptor instead. +func (*IngestGRPCRequest) Descriptor() ([]byte, []int) { + return file_private_location_v1_private_location_proto_rawDescGZIP(), []int{11} +} + +func (x *IngestGRPCRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *IngestGRPCRequest) GetMonitorId() string { + if x != nil { + return x.MonitorId + } + return "" +} + +func (x *IngestGRPCRequest) GetLatency() int64 { + if x != nil { + return x.Latency + } + return 0 +} + +func (x *IngestGRPCRequest) GetTimestamp() int64 { + if x != nil { + return x.Timestamp + } + return 0 +} + +func (x *IngestGRPCRequest) GetCronTimestamp() int64 { + if x != nil { + return x.CronTimestamp + } + return 0 +} + +func (x *IngestGRPCRequest) GetUri() string { + if x != nil { + return x.Uri + } + return "" +} + +func (x *IngestGRPCRequest) GetService() string { + if x != nil { + return x.Service + } + return "" +} + +func (x *IngestGRPCRequest) GetServingStatus() string { + if x != nil { + return x.ServingStatus + } + return "" +} + +func (x *IngestGRPCRequest) GetGrpcCode() int64 { + if x != nil { + return x.GrpcCode + } + return 0 +} + +func (x *IngestGRPCRequest) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *IngestGRPCRequest) GetRequestStatus() string { + if x != nil { + return x.RequestStatus + } + return "" +} + +func (x *IngestGRPCRequest) GetError() int64 { + if x != nil { + return x.Error + } + return 0 +} + +func (x *IngestGRPCRequest) GetTiming() string { + if x != nil { + return x.Timing + } + return "" +} + +type IngestGRPCResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *IngestGRPCResponse) Reset() { + *x = IngestGRPCResponse{} + mi := &file_private_location_v1_private_location_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *IngestGRPCResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*IngestGRPCResponse) ProtoMessage() {} + +func (x *IngestGRPCResponse) ProtoReflect() protoreflect.Message { + mi := &file_private_location_v1_private_location_proto_msgTypes[12] + 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 IngestGRPCResponse.ProtoReflect.Descriptor instead. +func (*IngestGRPCResponse) Descriptor() ([]byte, []int) { + return file_private_location_v1_private_location_proto_rawDescGZIP(), []int{12} +} + 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/icmp_monitor.proto\x1a%private_location/v1/tcp_monitor.proto\"\x11\n" + - "\x0fMonitorsRequest\"\xc0\x02\n" + + "*private_location/v1/private_location.proto\x12\x13private_location.v1\x1a%private_location/v1/dns_monitor.proto\x1a&private_location/v1/grpc_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\"\x87\x03\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\x12E\n" + - "\ricmp_monitors\x18\x05 \x03(\v2 .private_location.v1.ICMPMonitorR\ficmpMonitors\x12\x16\n" + + "\ricmp_monitors\x18\x05 \x03(\v2 .private_location.v1.ICMPMonitorR\ficmpMonitors\x12E\n" + + "\rgrpc_monitors\x18\x06 \x03(\v2 .private_location.v1.GRPCMonitorR\fgrpcMonitors\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" + @@ -931,7 +1116,23 @@ const file_private_location_v1_private_location_proto_rawDesc = "" + "\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" + + "\x12IngestICMPResponse\"\xfb\x02\n" + + "\x11IngestGRPCRequest\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\x1c\n" + + "\ttimestamp\x18\x04 \x01(\x03R\ttimestamp\x12$\n" + + "\rcronTimestamp\x18\x05 \x01(\x03R\rcronTimestamp\x12\x10\n" + + "\x03uri\x18\x06 \x01(\tR\x03uri\x12\x18\n" + + "\aservice\x18\a \x01(\tR\aservice\x12$\n" + + "\rservingStatus\x18\b \x01(\tR\rservingStatus\x12\x1a\n" + + "\bgrpcCode\x18\t \x01(\x03R\bgrpcCode\x12\x18\n" + + "\amessage\x18\n" + + " \x01(\tR\amessage\x12$\n" + + "\rrequestStatus\x18\v \x01(\tR\rrequestStatus\x12\x14\n" + + "\x05error\x18\f \x01(\x03R\x05error\x12\x16\n" + + "\x06timing\x18\r \x01(\tR\x06timing\"\x14\n" + + "\x12IngestGRPCResponse2\xd2\x04\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" + @@ -939,7 +1140,9 @@ const file_private_location_v1_private_location_proto_rawDesc = "" + "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\"\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" + "IngestICMP\x12&.private_location.v1.IngestICMPRequest\x1a'.private_location.v1.IngestICMPResponse\"\x00\x12_\n" + + "\n" + + "IngestGRPC\x12&.private_location.v1.IngestGRPCRequest\x1a'.private_location.v1.IngestGRPCResponse\"\x00BJZHgithub.com/openstatushq/openstatus/packages/proto/private_location/v1;v1b\x06proto3" var ( file_private_location_v1_private_location_proto_rawDescOnce sync.Once @@ -953,7 +1156,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, 12) +var file_private_location_v1_private_location_proto_msgTypes = make([]protoimpl.MessageInfo, 14) var file_private_location_v1_private_location_proto_goTypes = []any{ (*MonitorsRequest)(nil), // 0: private_location.v1.MonitorsRequest (*MonitorsResponse)(nil), // 1: private_location.v1.MonitorsResponse @@ -966,34 +1169,40 @@ var file_private_location_v1_private_location_proto_goTypes = []any{ (*IngestDNSResponse)(nil), // 8: private_location.v1.IngestDNSResponse (*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 + (*IngestGRPCRequest)(nil), // 11: private_location.v1.IngestGRPCRequest + (*IngestGRPCResponse)(nil), // 12: private_location.v1.IngestGRPCResponse + nil, // 13: private_location.v1.IngestDNSRequest.RecordsEntry + (*HTTPMonitor)(nil), // 14: private_location.v1.HTTPMonitor + (*TCPMonitor)(nil), // 15: private_location.v1.TCPMonitor + (*DNSMonitor)(nil), // 16: private_location.v1.DNSMonitor + (*ICMPMonitor)(nil), // 17: private_location.v1.ICMPMonitor + (*GRPCMonitor)(nil), // 18: private_location.v1.GRPCMonitor } var file_private_location_v1_private_location_proto_depIdxs = []int32{ - 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 + 14, // 0: private_location.v1.MonitorsResponse.http_monitors:type_name -> private_location.v1.HTTPMonitor + 15, // 1: private_location.v1.MonitorsResponse.tcp_monitors:type_name -> private_location.v1.TCPMonitor + 16, // 2: private_location.v1.MonitorsResponse.dns_monitors:type_name -> private_location.v1.DNSMonitor + 17, // 3: private_location.v1.MonitorsResponse.icmp_monitors:type_name -> private_location.v1.ICMPMonitor + 18, // 4: private_location.v1.MonitorsResponse.grpc_monitors:type_name -> private_location.v1.GRPCMonitor + 13, // 5: private_location.v1.IngestDNSRequest.records:type_name -> private_location.v1.IngestDNSRequest.RecordsEntry + 6, // 6: private_location.v1.IngestDNSRequest.RecordsEntry.value:type_name -> private_location.v1.Records + 0, // 7: private_location.v1.PrivateLocationService.Monitors:input_type -> private_location.v1.MonitorsRequest + 2, // 8: private_location.v1.PrivateLocationService.IngestTCP:input_type -> private_location.v1.IngestTCPRequest + 4, // 9: private_location.v1.PrivateLocationService.IngestHTTP:input_type -> private_location.v1.IngestHTTPRequest + 7, // 10: private_location.v1.PrivateLocationService.IngestDNS:input_type -> private_location.v1.IngestDNSRequest + 9, // 11: private_location.v1.PrivateLocationService.IngestICMP:input_type -> private_location.v1.IngestICMPRequest + 11, // 12: private_location.v1.PrivateLocationService.IngestGRPC:input_type -> private_location.v1.IngestGRPCRequest + 1, // 13: private_location.v1.PrivateLocationService.Monitors:output_type -> private_location.v1.MonitorsResponse + 3, // 14: private_location.v1.PrivateLocationService.IngestTCP:output_type -> private_location.v1.IngestTCPResponse + 5, // 15: private_location.v1.PrivateLocationService.IngestHTTP:output_type -> private_location.v1.IngestHTTPResponse + 8, // 16: private_location.v1.PrivateLocationService.IngestDNS:output_type -> private_location.v1.IngestDNSResponse + 10, // 17: private_location.v1.PrivateLocationService.IngestICMP:output_type -> private_location.v1.IngestICMPResponse + 12, // 18: private_location.v1.PrivateLocationService.IngestGRPC:output_type -> private_location.v1.IngestGRPCResponse + 13, // [13:19] is the sub-list for method output_type + 7, // [7:13] is the sub-list for method input_type + 7, // [7:7] is the sub-list for extension type_name + 7, // [7:7] is the sub-list for extension extendee + 0, // [0:7] is the sub-list for field type_name } func init() { file_private_location_v1_private_location_proto_init() } @@ -1002,6 +1211,7 @@ func file_private_location_v1_private_location_proto_init() { return } file_private_location_v1_dns_monitor_proto_init() + file_private_location_v1_grpc_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() @@ -1011,7 +1221,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: 12, + NumMessages: 14, NumExtensions: 0, NumServices: 1, }, diff --git a/apps/server/src/libs/checker/utils.test.ts b/apps/server/src/libs/checker/utils.test.ts index aa41d39e..c1417407 100644 --- a/apps/server/src/libs/checker/utils.test.ts +++ b/apps/server/src/libs/checker/utils.test.ts @@ -39,7 +39,7 @@ function buildMonitor(overrides: Partial = {}): Monitor { } test("getCheckerUrl routes each job type to its own checker endpoint", () => { - for (const jobType of ["http", "tcp", "dns", "icmp"] as const) { + for (const jobType of ["http", "tcp", "dns", "icmp", "grpc"] as const) { const url = getCheckerUrl(buildMonitor({ jobType })); expect(url).toContain(`/checker/${jobType}?`); expect(url).toContain("monitor_id=1"); @@ -77,6 +77,41 @@ test("getCheckerPayload builds an ICMP payload without assertions", () => { expect("url" in payload).toBe(false); }); +test("getCheckerPayload builds a gRPC payload with its target configuration", () => { + const payload = getCheckerPayload( + buildMonitor({ + jobType: "grpc", + url: "api.example.com:443", + grpcService: "checkout.v1.CheckoutService", + grpcTls: "tls_insecure", + headers: [{ key: "authorization", value: "Bearer token" }], + assertions: + '[{"version":"v1","type":"status","compare":"eq","target":200}]', + }), + "active", + ); + + expect(payload).toMatchObject({ + uri: "api.example.com:443", + service: "checkout.v1.CheckoutService", + tls: "tls_insecure", + headers: { authorization: "Bearer token" }, + trigger: "api", + }); + expect("assertions" in payload).toBe(false); +}); + +// The column is nullable and only defaults on insert, so a row that predates +// the default must still dial with verification on. +test("getCheckerPayload defaults a gRPC monitor with no TLS mode to tls", () => { + const payload = getCheckerPayload( + buildMonitor({ jobType: "grpc", url: "api.example.com:443" }), + "active", + ); + + expect(payload).toMatchObject({ tls: "tls" }); +}); + test("getCheckerPayload builds a DNS payload with assertions", () => { const payload = getCheckerPayload( buildMonitor({ diff --git a/apps/server/src/libs/checker/utils.ts b/apps/server/src/libs/checker/utils.ts index 5e411a20..32194e8b 100644 --- a/apps/server/src/libs/checker/utils.ts +++ b/apps/server/src/libs/checker/utils.ts @@ -3,6 +3,7 @@ import type { selectMonitorSchema } from "@openstatus/db/src/schema"; import { type DNSPayloadSchema, type httpPayloadSchema, + type grpcPayloadSchema, type icmpPayloadSchema, type tpcPayloadSchema, transformHeaders, @@ -17,7 +18,8 @@ export function getCheckerPayload( | z.infer | z.infer | z.infer - | z.infer { + | z.infer + | z.infer { const timestamp = new Date().getTime(); switch (monitor.jobType) { case "http": @@ -101,6 +103,28 @@ export function getCheckerPayload( : undefined, retry: monitor.retry ?? 0, }; + case "grpc": + // No assertions: a gRPC health check only reports the serving status. + return { + workspaceId: String(monitor.workspaceId), + monitorId: String(monitor.id), + uri: monitor.url, + service: monitor.grpcService ?? undefined, + tls: monitor.grpcTls ?? "tls", + headers: transformHeaders(monitor.headers), + 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", @@ -129,6 +153,7 @@ export function getCheckerUrl( case "tcp": case "dns": case "icmp": + case "grpc": return `https://openstatus-checker.fly.dev/checker/${monitor.jobType}?monitor_id=${monitor.id}&trigger=${opts.trigger}&data=${opts.data}`; default: throw new OpenStatusApiError({ 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 833685d7..d6acd4a1 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 @@ -50,6 +50,7 @@ let testHttpMonitorId: number; let testTcpMonitorId: number; let testDnsMonitorId: number; let testIcmpMonitorId: number; +let testGrpcMonitorId: number; let testMonitorToDeleteId: number; let testMonitorWithStatusId: number; @@ -210,6 +211,24 @@ beforeAll(async () => { .get(); testIcmpMonitorId = icmpMon.id; + const grpcMon = await db + .insert(monitor) + .values({ + workspaceId: 1, + name: `${TEST_PREFIX}-grpc`, + url: "api.example.com:443", + periodicity: "10m", + active: true, + regions: "ams", + jobType: "grpc", + timeout: 5000, + grpcService: "checkout.v1.CheckoutService", + grpcTls: "tls", + }) + .returning() + .get(); + testGrpcMonitorId = grpcMon.id; + // Create monitor to be deleted const deleteMon = await db .insert(monitor) @@ -933,6 +952,103 @@ describe("MonitorService.CreateICMPMonitor", () => { }); }); +describe("MonitorService.CreateGRPCMonitor", () => { + test("successfully creates gRPC monitor", async () => { + const res = await connectRequest( + "CreateGRPCMonitor", + { + monitor: { + name: "test-create-grpc", + uri: "api.example.com:443", + periodicity: "PERIODICITY_5M", + timeout: "5000", + service: "checkout.v1.CheckoutService", + tlsMode: "GRPC_TLS_MODE_TLS_INSECURE", + metadata: [{ key: "authorization", value: "Bearer token" }], + }, + }, + { "x-openstatus-key": "1" }, + ); + + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.monitor).toBeDefined(); + expect(data.monitor.uri).toBe("api.example.com:443"); + expect(data.monitor.service).toBe("checkout.v1.CheckoutService"); + expect(data.monitor.tlsMode).toBe("GRPC_TLS_MODE_TLS_INSECURE"); + + const row = await db + .select() + .from(monitor) + .where(eq(monitor.id, Number(data.monitor.id))) + .get(); + expect(row?.jobType).toBe("grpc"); + expect(row?.grpcService).toBe("checkout.v1.CheckoutService"); + expect(row?.grpcTls).toBe("tls_insecure"); + + if (data.monitor.id) { + await db.delete(monitor).where(eq(monitor.id, Number(data.monitor.id))); + } + }); + + // The column default only applies on insert, and an omitted enum arrives as + // UNSPECIFIED — it must resolve to verified TLS, not to plaintext. + test("defaults the TLS mode to tls when omitted", async () => { + const res = await connectRequest( + "CreateGRPCMonitor", + { + monitor: { + name: "test-create-grpc-default-tls", + uri: "api.example.com:443", + periodicity: "PERIODICITY_5M", + }, + }, + { "x-openstatus-key": "1" }, + ); + + expect(res.status).toBe(200); + + const data = await res.json(); + const row = await db + .select() + .from(monitor) + .where(eq(monitor.id, Number(data.monitor.id))) + .get(); + expect(row?.grpcTls).toBe("tls"); + + if (data.monitor.id) { + await db.delete(monitor).where(eq(monitor.id, Number(data.monitor.id))); + } + }); + + test("rejects a target that is not host:port", async () => { + const res = await connectRequest( + "CreateGRPCMonitor", + { + monitor: { + name: "test-create-grpc-bad-target", + uri: "api.example.com", + periodicity: "PERIODICITY_5M", + }, + }, + { "x-openstatus-key": "1" }, + ); + + expect(res.status).toBe(400); + }); + + test("returns error when monitor is missing", async () => { + const res = await connectRequest( + "CreateGRPCMonitor", + {}, + { "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( @@ -1640,6 +1756,120 @@ describe("MonitorService.UpdateICMPMonitor", () => { }); }); +describe("MonitorService.UpdateGRPCMonitor", () => { + test("successfully updates gRPC monitor with partial data", async () => { + const res = await connectRequest( + "UpdateGRPCMonitor", + { + id: String(testGrpcMonitorId), + monitor: { name: "updated-grpc-name" }, + }, + { "x-openstatus-key": "1" }, + ); + + expect(res.status).toBe(200); + + const data = await res.json(); + expect(data.monitor.name).toBe("updated-grpc-name"); + expect(data.monitor.uri).toBe("api.example.com:443"); + + await connectRequest( + "UpdateGRPCMonitor", + { + id: String(testGrpcMonitorId), + monitor: { name: `${TEST_PREFIX}-grpc` }, + }, + { "x-openstatus-key": "1" }, + ); + }); + + // `tlsMode` has explicit presence, so an omitted one means "leave as-is". + // Resolving UNSPECIFIED to TLS here would silently break a plaintext monitor. + test("leaves the TLS mode untouched when the patch omits it", async () => { + await connectRequest( + "UpdateGRPCMonitor", + { + id: String(testGrpcMonitorId), + monitor: { tlsMode: "GRPC_TLS_MODE_PLAINTEXT" }, + }, + { "x-openstatus-key": "1" }, + ); + + await connectRequest( + "UpdateGRPCMonitor", + { + id: String(testGrpcMonitorId), + monitor: { name: `${TEST_PREFIX}-grpc` }, + }, + { "x-openstatus-key": "1" }, + ); + + const row = await db + .select() + .from(monitor) + .where(eq(monitor.id, testGrpcMonitorId)) + .get(); + expect(row?.grpcTls).toBe("plaintext"); + + await db + .update(monitor) + .set({ grpcTls: "tls" }) + .where(eq(monitor.id, testGrpcMonitorId)); + }); + + // An empty service is a real value: it means "check overall server health". + test("clears the service name when the patch sends an empty string", async () => { + const res = await connectRequest( + "UpdateGRPCMonitor", + { + id: String(testGrpcMonitorId), + monitor: { service: "" }, + }, + { "x-openstatus-key": "1" }, + ); + + expect(res.status).toBe(200); + + const row = await db + .select() + .from(monitor) + .where(eq(monitor.id, testGrpcMonitorId)) + .get(); + expect(row?.grpcService).toBe(""); + + await db + .update(monitor) + .set({ grpcService: "checkout.v1.CheckoutService" }) + .where(eq(monitor.id, testGrpcMonitorId)); + }); + + test("rejects a target that is not host:port", async () => { + const res = await connectRequest( + "UpdateGRPCMonitor", + { + id: String(testGrpcMonitorId), + monitor: { uri: "api.example.com" }, + }, + { "x-openstatus-key": "1" }, + ); + + expect(res.status).toBe(400); + }); + + test("returns error for a monitor of another job type", async () => { + const res = await connectRequest( + "UpdateGRPCMonitor", + { + id: String(testIcmpMonitorId), + monitor: { name: "nope" }, + }, + { "x-openstatus-key": "1" }, + ); + + expect(res.status).toBe(400); + }); +}); + describe("MonitorService - private and internal URLs", () => { // Valid URIs, so protovalidate passes them through to the service guard. const BLOCKED = [ diff --git a/apps/server/src/routes/rpc/handlers/monitor/converters/defaults.ts b/apps/server/src/routes/rpc/handlers/monitor/converters/defaults.ts index 3b6642c2..69166785 100644 --- a/apps/server/src/routes/rpc/handlers/monitor/converters/defaults.ts +++ b/apps/server/src/routes/rpc/handlers/monitor/converters/defaults.ts @@ -8,4 +8,5 @@ export const MONITOR_DEFAULTS = { active: false, public: false, description: "", + grpcTls: "tls", } as const; diff --git a/apps/server/src/routes/rpc/handlers/monitor/converters/enums.ts b/apps/server/src/routes/rpc/handlers/monitor/converters/enums.ts index 82ac27c3..04664b94 100644 --- a/apps/server/src/routes/rpc/handlers/monitor/converters/enums.ts +++ b/apps/server/src/routes/rpc/handlers/monitor/converters/enums.ts @@ -1,5 +1,7 @@ import type { monitorPeriodicitySchema } from "@openstatus/db/src/schema/constants"; +import type { GrpcTlsMode } from "@openstatus/db/src/schema/monitors/validation"; import { + GRPCTlsMode, HTTPMethod, MonitorStatus, Periodicity, @@ -95,6 +97,31 @@ export function stringToMonitorStatus(value: string): MonitorStatus { return DB_TO_MONITOR_STATUS[value] ?? MonitorStatus.UNSPECIFIED; } +// ============================================================ +// gRPC TLS Mode Conversions +// ============================================================ + +const DB_TO_GRPC_TLS_MODE: Record = { + plaintext: GRPCTlsMode.GRPC_TLS_MODE_PLAINTEXT, + tls: GRPCTlsMode.GRPC_TLS_MODE_TLS, + tls_insecure: GRPCTlsMode.GRPC_TLS_MODE_TLS_INSECURE, +}; + +const GRPC_TLS_MODE_TO_DB: Record = { + [GRPCTlsMode.GRPC_TLS_MODE_PLAINTEXT]: "plaintext", + [GRPCTlsMode.GRPC_TLS_MODE_TLS]: "tls", + [GRPCTlsMode.GRPC_TLS_MODE_TLS_INSECURE]: "tls_insecure", + [GRPCTlsMode.GRPC_TLS_MODE_UNSPECIFIED]: "tls", +}; + +export function stringToGrpcTlsMode(value: string | null): GRPCTlsMode { + return DB_TO_GRPC_TLS_MODE[value ?? ""] ?? GRPCTlsMode.GRPC_TLS_MODE_TLS; +} + +export function grpcTlsModeToString(value: GRPCTlsMode): GrpcTlsMode { + return GRPC_TLS_MODE_TO_DB[value] ?? "tls"; +} + // ============================================================ // Time Range Conversions // ============================================================ 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 be77f8d6..1073bb7a 100644 --- a/apps/server/src/routes/rpc/handlers/monitor/converters/index.ts +++ b/apps/server/src/routes/rpc/handlers/monitor/converters/index.ts @@ -30,6 +30,8 @@ export { stringToHttpMethod, httpMethodToString, stringToMonitorStatus, + stringToGrpcTlsMode, + grpcTlsModeToString, timeRangeToKey, type TimeRangeKey, } from "./enums"; @@ -47,6 +49,7 @@ export { dbMonitorToHttpProto, dbMonitorToTcpProto, dbMonitorToDnsProto, + dbMonitorToGrpcProto, dbMonitorToIcmpProto, } from "./monitors"; 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 b8aba3d0..785f22d1 100644 --- a/apps/server/src/routes/rpc/handlers/monitor/converters/monitors.ts +++ b/apps/server/src/routes/rpc/handlers/monitor/converters/monitors.ts @@ -1,6 +1,7 @@ import type { Monitor } from "@openstatus/db/src/schema/monitors/validation"; import type { DNSMonitor, + GRPCMonitor, HTTPMonitor, ICMPMonitor, TCPMonitor, @@ -9,6 +10,7 @@ import type { import { parseDnsAssertions, parseHttpAssertions } from "./assertions"; import { MONITOR_DEFAULTS } from "./defaults"; import { + stringToGrpcTlsMode, stringToHttpMethod, stringToMonitorStatus, stringToPeriodicity, @@ -103,6 +105,35 @@ export function dbMonitorToIcmpProto( }; } +/** + * Transform database gRPC monitor to proto GRPCMonitor. + */ +export function dbMonitorToGrpcProto( + dbMon: Monitor, + privateLocationIds: string[] = [], +): GRPCMonitor { + return { + $typeName: "openstatus.monitor.v1.GRPCMonitor", + 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, + service: dbMon.grpcService ?? undefined, + tlsMode: stringToGrpcTlsMode(dbMon.grpcTls), + metadata: toProtoHeaders(dbMon.headers), + }; +} + /** * 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 5967421b..4716537b 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, + GRPCMonitor, ICMPMonitor, ListMonitorHTTPResponseLogsResponse, MonitorConfig, @@ -15,7 +16,7 @@ import type { RegionStatus, TCPMonitor, } from "@openstatus/proto/monitor/v1"; -import { TimeRange } from "@openstatus/proto/monitor/v1"; +import { GRPCTlsMode, TimeRange } from "@openstatus/proto/monitor/v1"; import { ForbiddenError, LimitExceededError, @@ -48,7 +49,9 @@ import { MONITOR_DEFAULTS, dbMonitorToDnsProto, dbMonitorToHttpProto, + dbMonitorToGrpcProto, dbMonitorToIcmpProto, + grpcTlsModeToString, dbMonitorToTcpProto, protoDnsAssertionsToService, protoHeadersToService, @@ -110,7 +113,7 @@ type DBMonitor = NonNullable>>; async function validateAndGetMonitor( id: string | undefined, workspaceId: number, - expectedJobType: "http" | "tcp" | "dns" | "icmp", + expectedJobType: "http" | "tcp" | "dns" | "icmp" | "grpc", ): Promise { if (!id || id.trim() === "") { throw monitorIdRequiredError(); @@ -303,6 +306,46 @@ export const monitorServiceImpl: ServiceImpl = { } }, + async createGRPCMonitor(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: "grpc", + url: mon.uri, + method: "GET", + headers: protoHeadersToService(mon.metadata) ?? [], + assertions: [], + grpcService: mon.service, + grpcTls: grpcTlsModeToString( + mon.tlsMode ?? GRPCTlsMode.GRPC_TLS_MODE_UNSPECIFIED, + ), + }, + }); + + return { monitor: dbMonitorToGrpcProto(created) }; + } catch (err) { + toConnectError(err); + } + }, + async updateHTTPMonitor(req, ctx) { const rpcCtx = getRpcContext(ctx); const workspaceId = rpcCtx.workspace.id; @@ -536,6 +579,73 @@ export const monitorServiceImpl: ServiceImpl = { ); }, + async updateGRPCMonitor(req, ctx) { + const rpcCtx = getRpcContext(ctx); + const workspaceId = rpcCtx.workspace.id; + const limits = rpcCtx.workspace.limits; + + const dbMon = await validateAndGetMonitor(req.id, workspaceId, "grpc"); + + 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: dbMonitorToGrpcProto(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, { jobType: "grpc" }); + + // 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 gRPC-specific fields + if (mon.uri !== undefined && mon.uri !== "") { + updateValues.url = mon.uri; + } + + // `service` and `tlsMode` carry explicit presence, so `undefined` means + // omitted: an empty service clears it back to overall server health, and an + // omitted tlsMode leaves a plaintext monitor plaintext. + if (mon.service !== undefined) { + updateValues.grpcService = mon.service; + } + + if (mon.tlsMode !== undefined) { + updateValues.grpcTls = grpcTlsModeToString(mon.tlsMode); + } + + if (mon.metadata !== undefined && mon.metadata.length > 0) { + updateValues.headers = protoHeadersToService(mon.metadata) ?? []; + } + + return applyUpdate(rpcCtx, dbMon.id, updateValues, (data) => + dbMonitorToGrpcProto(data, privateLocationIds), + ); + }, + async triggerMonitor(req, ctx) { const rpcCtx = getRpcContext(ctx); const limits = rpcCtx.workspace.limits; @@ -656,6 +766,7 @@ export const monitorServiceImpl: ServiceImpl = { const tcpMonitors: TCPMonitor[] = []; const dnsMonitors: DNSMonitor[] = []; const icmpMonitors: ICMPMonitor[] = []; + const grpcMonitors: GRPCMonitor[] = []; for (const data of parsedMonitors) { const privateLocationIds = plMap.get(data.id) ?? []; @@ -672,6 +783,9 @@ export const monitorServiceImpl: ServiceImpl = { case "icmp": icmpMonitors.push(dbMonitorToIcmpProto(data, privateLocationIds)); break; + case "grpc": + grpcMonitors.push(dbMonitorToGrpcProto(data, privateLocationIds)); + break; } } @@ -680,6 +794,7 @@ export const monitorServiceImpl: ServiceImpl = { tcpMonitors, dnsMonitors, icmpMonitors, + grpcMonitors, totalSize: totalCount, }; }, @@ -769,10 +884,19 @@ export const monitorServiceImpl: ServiceImpl = { }, }; break; + case "grpc": + monitorConfig = { + $typeName: "openstatus.monitor.v1.MonitorConfig", + config: { + case: "grpc", + value: dbMonitorToGrpcProto(monitorData, privateLocationIds), + }, + }; + break; default: throw monitorTypeMismatchError( req.id, - "http, tcp, dns, or icmp", + "http, tcp, dns, icmp, or grpc", 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 216e7720..ae876638 100644 --- a/apps/server/src/routes/rpc/handlers/monitor/validators.test.ts +++ b/apps/server/src/routes/rpc/handlers/monitor/validators.test.ts @@ -247,4 +247,54 @@ describe("validateMonitorPatchBounds", () => { ConnectError, ); }); + + test("rejects a gRPC service name over the proto's limit", () => { + expect(() => + validateMonitorPatchBounds({ service: "s".repeat(513) }), + ).toThrow(ConnectError); + validateMonitorPatchBounds({ service: "s".repeat(512) }); + }); + + test("accepts the host:port shapes a gRPC target can take", () => { + for (const uri of [ + "api.example.com:443", + "10.0.0.5:50051", + "[2001:db8::1]:50051", + "svc.internal:8443", + ]) { + validateMonitorPatchBounds({ uri }, { jobType: "grpc" }); + } + }); + + test("rejects a gRPC target with no port or with a scheme", () => { + for (const uri of [ + "api.example.com", + "grpc://api.example.com:443", + "api.example.com:", + ]) { + expect(() => + validateMonitorPatchBounds({ uri }, { jobType: "grpc" }), + ).toThrow(ConnectError); + } + }); + + // The format check keys off the job type, not off `service` being present, + // so a patch that changes only the target is still validated. + test("checks a gRPC target even when the patch omits the service", () => { + expect(() => + validateMonitorPatchBounds( + { uri: "api.example.com" }, + { jobType: "grpc" }, + ), + ).toThrow(ConnectError); + }); + + test("leaves the other job types' targets unconstrained", () => { + validateMonitorPatchBounds({ uri: "openstatus.dev" }); + validateMonitorPatchBounds({ uri: "openstatus.dev" }, { jobType: "dns" }); + validateMonitorPatchBounds( + { url: "https://openstatus.dev" }, + { jobType: "http" }, + ); + }); }); diff --git a/apps/server/src/routes/rpc/handlers/monitor/validators.ts b/apps/server/src/routes/rpc/handlers/monitor/validators.ts index 23aeb2c8..11c90fc6 100644 --- a/apps/server/src/routes/rpc/handlers/monitor/validators.ts +++ b/apps/server/src/routes/rpc/handlers/monitor/validators.ts @@ -74,6 +74,10 @@ const MONITOR_BOUNDS = { degradedAtMaxMs: 120_000, retryMax: 10, regionsMaxItems: 28, + serviceMaxLen: 512, + // gRPC dials a host:port; a portless target or one carrying a scheme cannot + // be dialled and would surface as a bare UNAVAILABLE. + hostPortPattern: /^(\[[0-9a-fA-F:]+\]|[^:/\s]+):[0-9]{1,5}$/, } as const; function invalidArgument(message: string): never { @@ -85,16 +89,20 @@ function invalidArgument(message: string): never { * 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 { +export function validateMonitorPatchBounds( + mon: { + name?: string; + uri?: string; + url?: string; + timeout?: bigint; + degradedAt?: bigint; + retry?: bigint; + description?: string; + regions?: Region[]; + service?: string; + }, + opts: { jobType?: "http" | "tcp" | "dns" | "icmp" | "grpc" } = {}, +): void { if (mon.name !== undefined && mon.name !== "") { if (mon.name.length > MONITOR_BOUNDS.nameMaxLen) { invalidArgument( @@ -113,6 +121,25 @@ export function validateMonitorPatchBounds(mon: { } } + if (mon.service !== undefined) { + if (mon.service.length > MONITOR_BOUNDS.serviceMaxLen) { + invalidArgument( + `monitor.service: must be at most ${MONITOR_BOUNDS.serviceMaxLen} characters [string.max_len]`, + ); + } + } + + // gRPC only: the other types accept a bare hostname or a URL. Checked on the + // job type rather than on `service` being present, so a patch that changes + // only the target is still validated. + if (opts.jobType === "grpc" && target !== undefined && target !== "") { + if (!MONITOR_BOUNDS.hostPortPattern.test(target)) { + invalidArgument( + "monitor.uri: must match the host:port format [string.pattern]", + ); + } + } + if (mon.description !== undefined) { if (mon.description.length > MONITOR_BOUNDS.descriptionMaxLen) { invalidArgument( 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 6ebe54be..e9bc2068 100644 --- a/apps/server/src/routes/rpc/interceptors/__tests__/tracking.test.ts +++ b/apps/server/src/routes/rpc/interceptors/__tests__/tracking.test.ts @@ -275,6 +275,20 @@ describe("RPC_EVENT_MAP", () => { expect(untracked).toEqual([]); }); + test("gRPC monitor mutations are tracked like the other monitor types", () => { + const create = + RPC_EVENT_MAP["openstatus.monitor.v1.MonitorService/CreateGRPCMonitor"]; + const update = + RPC_EVENT_MAP["openstatus.monitor.v1.MonitorService/UpdateGRPCMonitor"]; + + expect(create?.event).toEqual(Events.CreateMonitor); + expect(update?.event).toEqual(Events.UpdateMonitor); + expect(create?.eventProps).toEqual( + RPC_EVENT_MAP["openstatus.monitor.v1.MonitorService/CreateDNSMonitor"] + ?.eventProps, + ); + }); + test("ICMP monitor mutations are tracked like the other monitor types", () => { const create = RPC_EVENT_MAP["openstatus.monitor.v1.MonitorService/CreateICMPMonitor"]; diff --git a/apps/server/src/routes/rpc/interceptors/tracking.ts b/apps/server/src/routes/rpc/interceptors/tracking.ts index 4ae4ff35..57cdae60 100644 --- a/apps/server/src/routes/rpc/interceptors/tracking.ts +++ b/apps/server/src/routes/rpc/interceptors/tracking.ts @@ -18,8 +18,8 @@ type RpcEventMapping = { }; // 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. +// extraction yields nothing; ICMP and gRPC name their 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 {}; @@ -58,6 +58,11 @@ export const RPC_EVENT_MAP: Record = { eventProps: ["url", "jobType"], normalizeInput: monitorCreateInput("icmp"), }, + "openstatus.monitor.v1.MonitorService/CreateGRPCMonitor": { + event: Events.CreateMonitor, + eventProps: ["url", "jobType"], + normalizeInput: monitorCreateInput("grpc"), + }, "openstatus.monitor.v1.MonitorService/UpdateHTTPMonitor": { event: Events.UpdateMonitor, }, @@ -70,6 +75,9 @@ export const RPC_EVENT_MAP: Record = { "openstatus.monitor.v1.MonitorService/UpdateICMPMonitor": { event: Events.UpdateMonitor, }, + "openstatus.monitor.v1.MonitorService/UpdateGRPCMonitor": { + event: Events.UpdateMonitor, + }, "openstatus.monitor.v1.MonitorService/DeleteMonitor": { event: Events.DeleteMonitor, }, diff --git a/apps/server/src/routes/rpc/interceptors/validation.ts b/apps/server/src/routes/rpc/interceptors/validation.ts index 84611c3e..4c90ce31 100644 --- a/apps/server/src/routes/rpc/interceptors/validation.ts +++ b/apps/server/src/routes/rpc/interceptors/validation.ts @@ -8,6 +8,7 @@ const SKIP_VALIDATION_METHODS = new Set([ "UpdateTCPMonitor", "UpdateDNSMonitor", "UpdateICMPMonitor", + "UpdateGRPCMonitor", ]); // protovalidate >=1.2 dropped the "value " prefix from the string.pattern diff --git a/apps/server/static/openapi.yaml b/apps/server/static/openapi.yaml index 532ee048..ed2d5f69 100644 --- a/apps/server/static/openapi.yaml +++ b/apps/server/static/openapi.yaml @@ -448,6 +448,28 @@ components: title: CreateDNSMonitorResponse additionalProperties: false description: CreateDNSMonitorResponse is the response after creating a DNS monitor. + openstatus.monitor.v1.CreateGRPCMonitorRequest: + type: object + properties: + monitor: + title: monitor + description: Monitor configuration (required). + $ref: '#/components/schemas/openstatus.monitor.v1.GRPCMonitor' + title: CreateGRPCMonitorRequest + required: + - monitor + additionalProperties: false + description: CreateGRPCMonitorRequest is the request to create a new gRPC monitor. + openstatus.monitor.v1.CreateGRPCMonitorResponse: + type: object + properties: + monitor: + title: monitor + description: The created monitor with assigned ID. + $ref: '#/components/schemas/openstatus.monitor.v1.GRPCMonitor' + title: CreateGRPCMonitorResponse + additionalProperties: false + description: CreateGRPCMonitorResponse is the response after creating a gRPC monitor. openstatus.monitor.v1.CreateHTTPMonitorRequest: type: object properties: @@ -645,6 +667,143 @@ components: title: DeleteMonitorResponse additionalProperties: false description: DeleteMonitorResponse is the response after deleting a monitor. + openstatus.monitor.v1.GRPCMonitor: + type: object + properties: + id: + type: string + title: id + description: Unique identifier for the monitor (output only for create requests). + name: + type: string + examples: + - Checkout gRPC + title: name + maxLength: 256 + minLength: 1 + description: Name of the monitor (required, max 256 characters). + uri: + type: string + examples: + - api.example.com:443 + title: uri + maxLength: 2048 + minLength: 1 + pattern: ^(\[[0-9a-fA-F:]+\]|[^:/\s]+):[0-9]{1,5}$ + description: Target in "host:port" form. IPv6 addresses must be bracketed. + 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 + service: + type: + - string + - "null" + examples: + - checkout.v1.CheckoutService + title: service + maxLength: 512 + description: Service name passed to Health/Check. Empty means overall server health. + tlsMode: + oneOf: + - $ref: '#/components/schemas/openstatus.monitor.v1.GRPCTlsMode' + - type: "null" + title: tls_mode + description: How the connection to the target is secured. Defaults to TLS. + metadata: + type: array + items: + $ref: '#/components/schemas/openstatus.monitor.v1.Headers' + title: metadata + maxItems: 20 + description: Metadata sent with the health check request. + title: GRPCMonitor + additionalProperties: false + description: |- + GRPCMonitor defines the configuration for a gRPC health check monitor. + The probe calls grpc.health.v1.Health/Check on the target. + openstatus.monitor.v1.GRPCTlsMode: + type: string + title: GRPCTlsMode + enum: + - GRPC_TLS_MODE_UNSPECIFIED + - GRPC_TLS_MODE_TLS + - GRPC_TLS_MODE_PLAINTEXT + - GRPC_TLS_MODE_TLS_INSECURE + description: GRPCTlsMode selects how the probe secures its connection to the target. openstatus.monitor.v1.GetMonitorHTTPResponseLogRequest: type: object properties: @@ -687,7 +846,7 @@ components: properties: monitor: title: monitor - description: The monitor configuration (one of HTTP, TCP, DNS, or ICMP). + description: The monitor configuration (one of HTTP, TCP, DNS, ICMP, or gRPC). $ref: '#/components/schemas/openstatus.monitor.v1.MonitorConfig' title: GetMonitorResponse additionalProperties: false @@ -1424,6 +1583,12 @@ components: $ref: '#/components/schemas/openstatus.monitor.v1.ICMPMonitor' title: icmp_monitors description: ICMP monitors in the workspace. + grpcMonitors: + type: array + items: + $ref: '#/components/schemas/openstatus.monitor.v1.GRPCMonitor' + title: grpc_monitors + description: gRPC monitors in the workspace. totalSize: type: integer title: total_size @@ -1444,6 +1609,15 @@ components: title: dns required: - dns + - type: object + properties: + grpc: + title: grpc + description: gRPC monitor configuration. + $ref: '#/components/schemas/openstatus.monitor.v1.GRPCMonitor' + title: grpc + required: + - grpc - type: object properties: http: @@ -1808,6 +1982,33 @@ components: title: UpdateDNSMonitorResponse additionalProperties: false description: UpdateDNSMonitorResponse is the response after updating a DNS monitor. + openstatus.monitor.v1.UpdateGRPCMonitorRequest: + type: object + properties: + id: + type: string + title: id + minLength: 1 + description: Monitor ID to update (required). + monitor: + oneOf: + - $ref: '#/components/schemas/openstatus.monitor.v1.GRPCMonitor' + - type: "null" + title: monitor + description: Updated monitor configuration (all fields optional for partial updates). + title: UpdateGRPCMonitorRequest + additionalProperties: false + description: UpdateGRPCMonitorRequest is the request to update an existing gRPC monitor. + openstatus.monitor.v1.UpdateGRPCMonitorResponse: + type: object + properties: + monitor: + title: monitor + description: The updated monitor. + $ref: '#/components/schemas/openstatus.monitor.v1.GRPCMonitor' + title: UpdateGRPCMonitorResponse + additionalProperties: false + description: UpdateGRPCMonitorResponse is the response after updating a gRPC monitor. openstatus.monitor.v1.UpdateHTTPMonitorRequest: type: object properties: @@ -5187,6 +5388,32 @@ paths: application/json: schema: $ref: '#/components/schemas/openstatus.monitor.v1.CreateDNSMonitorResponse' + /rpc/openstatus.monitor.v1.MonitorService/CreateGRPCMonitor: + post: + tags: + - MonitorService + summary: CreateGRPCMonitor + description: CreateGRPCMonitor creates a new gRPC health check monitor. + operationId: MonitorService_CreateGRPCMonitor + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/openstatus.monitor.v1.CreateGRPCMonitorRequest' + 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.CreateGRPCMonitorResponse' /rpc/openstatus.monitor.v1.MonitorService/CreateHTTPMonitor: post: tags: @@ -5298,7 +5525,7 @@ paths: summary: GetMonitor description: |- GetMonitor returns a single monitor by ID within the authenticated workspace. - Returns the monitor configuration (HTTP, TCP, DNS, or ICMP) using the MonitorConfig oneof type. + Returns the monitor configuration (HTTP, TCP, DNS, ICMP, or gRPC) using the MonitorConfig oneof type. operationId: MonitorService_GetMonitor.get parameters: - name: message @@ -5326,7 +5553,7 @@ paths: summary: GetMonitor description: |- GetMonitor returns a single monitor by ID within the authenticated workspace. - Returns the monitor configuration (HTTP, TCP, DNS, or ICMP) using the MonitorConfig oneof type. + Returns the monitor configuration (HTTP, TCP, DNS, ICMP, or gRPC) using the MonitorConfig oneof type. operationId: MonitorService_GetMonitor requestBody: content: @@ -5659,6 +5886,32 @@ paths: application/json: schema: $ref: '#/components/schemas/openstatus.monitor.v1.UpdateDNSMonitorResponse' + /rpc/openstatus.monitor.v1.MonitorService/UpdateGRPCMonitor: + post: + tags: + - MonitorService + summary: UpdateGRPCMonitor + description: UpdateGRPCMonitor updates an existing gRPC monitor. + operationId: MonitorService_UpdateGRPCMonitor + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/openstatus.monitor.v1.UpdateGRPCMonitorRequest' + 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.UpdateGRPCMonitorResponse' /rpc/openstatus.monitor.v1.MonitorService/UpdateHTTPMonitor: post: tags: diff --git a/apps/web/src/content/docs.config.ts b/apps/web/src/content/docs.config.ts index 85573dfd..7f55327c 100644 --- a/apps/web/src/content/docs.config.ts +++ b/apps/web/src/content/docs.config.ts @@ -242,6 +242,7 @@ export const docsNav: DocsNavSection[] = [ { slug: "reference/cli-reference", label: "CLI Reference" }, { slug: "reference/mcp-server", label: "MCP Server" }, { slug: "reference/dns-monitor", label: "DNS Monitor Reference" }, + { slug: "reference/grpc-monitor", label: "gRPC Monitor Reference" }, { slug: "reference/http-monitor", label: "HTTP Monitor Reference" }, { slug: "reference/icmp-monitor", label: "ICMP Monitor Reference" }, { slug: "reference/incident", label: "Incident Reference" }, diff --git a/apps/web/src/content/pages/changelog/grpc-monitoring.mdx b/apps/web/src/content/pages/changelog/grpc-monitoring.mdx new file mode 100644 index 00000000..b755fe62 --- /dev/null +++ b/apps/web/src/content/pages/changelog/grpc-monitoring.mdx @@ -0,0 +1,16 @@ +--- +title: "gRPC Monitoring" +description: "Monitor your gRPC services with health checks from openstatus." +publishedAt: "2026-08-26" +author: "openstatus" +category: "monitoring" +--- + +gRPC monitoring is now available in openstatus. Point a monitor at any service that implements the [gRPC Health Checking Protocol](https://grpc.io/docs/guides/health-checking/) and we'll call `grpc.health.v1.Health/Check` on the schedule you choose — the same check Kubernetes and Envoy already make, so most services need no new endpoint. + +You can check the server's overall health or a single named service, connect over TLS, TLS without certificate verification, or plaintext h2c, and send metadata with the request when your health endpoint sits behind an authenticating proxy. + +Because a gRPC monitor speaks HTTP/2, every check reports the same phase breakdown as an HTTP monitor: DNS, connection, TLS handshake and time to first byte. A service that reports `NOT_SERVING` keeps its measured latency, so you can see it slow down before it drains. + +#### Get Started 🚀 +Add a new monitor, select "gRPC" as your monitor type, and enter the target as `host:port`. diff --git a/apps/web/src/content/pages/docs/reference/grpc-monitor.mdx b/apps/web/src/content/pages/docs/reference/grpc-monitor.mdx new file mode 100644 index 00000000..39eed154 --- /dev/null +++ b/apps/web/src/content/pages/docs/reference/grpc-monitor.mdx @@ -0,0 +1,141 @@ +--- +category: Reference +title: gRPC Monitor Reference +description: Complete technical specification for gRPC health check monitoring. +--- + +A gRPC monitor calls the [gRPC Health Checking Protocol](https://grpc.io/docs/guides/health-checking/) — a unary `grpc.health.v1.Health/Check` request — against your service and reports the serving status it answers with. This is the check Kubernetes, Envoy and `grpcurl` all speak, so a service that is already health-checked in your cluster needs no new endpoint. + +Each check opens a fresh connection, so the reported timings cover DNS resolution, TCP connect, the TLS handshake and the call itself. + +**Use cases:** + +- Verifying a gRPC service is not just reachable but reports itself as serving. +- Catching a service that has drained (`NOT_SERVING`) before traffic reaches it. +- Tracking connection and TLS handshake latency to an internal service from a private location. + +## Outcomes + +| Server answer | Monitor result | Notes | +| --- | --- | --- | +| `SERVING` | Up | Degraded if latency exceeds your threshold | +| `NOT_SERVING` | Down | The service is up but reports itself as not serving | +| `SERVICE_UNKNOWN` | Down | The server does not know the service name you configured | +| `UNIMPLEMENTED` | Down | The server is reachable but has not registered `grpc.health.v1.Health` | +| `DEADLINE_EXCEEDED` | Down | No answer within the timeout | +| `UNAVAILABLE` | Down | Connection refused, DNS failure, or TLS failure | + +`UNIMPLEMENTED` is reported with its own message rather than a generic failure: it means the server is healthy enough to answer, it just has no health service registered. Register one with your gRPC library's health package and the check starts working. + +A check that reaches the server keeps its measured latency and phase timings even when the answer is `NOT_SERVING`, so a service that degrades before it drains is visible in the latency chart. + +## Configuration + +### Host:Port + +**Type:** String (required) +**Format:** `host:port` — a port is required + +The gRPC target. IPv6 addresses must be bracketed. A scheme (`grpc://`, `https://`) is not accepted. + +**Examples:** +- `api.example.com:443` +- `10.0.0.5:50051` +- `[2001:db8::1]:50051` + +### Service + +**Type:** String (optional) +**Default:** empty + +The service name sent in the health check request. Leave it empty to check the server's overall health, which is what most deployments register. + +**Example:** `checkout.v1.CheckoutService` + +### TLS + +**Type:** Enum (optional) +**Default:** `TLS (verify certificate)` + +How the probe secures its connection: + +- **TLS (verify certificate)** — verify the server certificate against the public trust store. Use this for any internet-facing endpoint. +- **TLS (skip verification)** — negotiate TLS but accept any certificate. For internal services presenting a self-signed or mesh-issued certificate, typically behind a private location. +- **Plaintext (h2c)** — no TLS. For a service behind a mesh or load balancer that has already terminated TLS. + +A certificate that fails verification is reported as `certificate verification failed`; the certificate's own details are never returned. + +### Metadata + +**Type:** Key-value pairs (optional) + +Metadata sent with the health check request. gRPC metadata is carried as HTTP/2 headers, so this is where an authorization token goes when your health endpoint sits behind an authenticating proxy. + +**Common example:** +``` +authorization: Bearer +``` + +### Regions + +**Type:** Array of strings (required) +**Format:** Region identifiers (e.g., `iad`, `jnb`) + +The geographical regions the check runs from. 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`) + +How often the health check runs. 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 budget for the whole check — name resolution, connection, TLS handshake and the call. Exceeding it reports `DEADLINE_EXCEEDED`. + +#### Degraded + +**Type:** Duration (optional) + +The latency above which a `SERVING` response is recorded as degraded rather than healthy. + +### Retry + +**Type:** Integer (optional) +**Default:** `3` + +How many times a check is retried before reporting a definitive error. Only connection failures are retried: once the server has answered — including `NOT_SERVING` or `UNIMPLEMENTED` — the result is recorded immediately, because asking again cannot change it. + +### 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 metrics are exported. gRPC monitors export the total request duration, the DNS, connection, TLS and time-to-first-byte phase durations, and a serving-status gauge that is `1` while the service reports `SERVING` and `0` otherwise — so a drained service can be alerted on separately from an unreachable one. + +#### 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. + +## 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/content/pages/docs/reference/overview.mdx b/apps/web/src/content/pages/docs/reference/overview.mdx index ec6a3103..a3413978 100644 --- a/apps/web/src/content/pages/docs/reference/overview.mdx +++ b/apps/web/src/content/pages/docs/reference/overview.mdx @@ -13,6 +13,7 @@ How each monitor type runs checks and what you can configure on it. - **[HTTP monitor](/docs/reference/http-monitor)** — URL, method, headers, body, assertions, regions. - **[TCP monitor](/docs/reference/tcp-monitor)** — host:port checks for non-HTTP services. - **[ICMP monitor](/docs/reference/icmp-monitor)** — ping checks for host reachability, latency, and packet loss. +- **[gRPC monitor](/docs/reference/grpc-monitor)** — health checks against the gRPC Health Checking Protocol. - **[DNS monitor](/docs/reference/dns-monitor)** — record-type assertions for A, AAAA, CNAME, MX, NS, TXT. ## Status pages diff --git a/apps/workflows/src/cron/checker.ts b/apps/workflows/src/cron/checker.ts index e39d950b..30e226cd 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 grpcPayloadSchema, type icmpPayloadSchema, type tpcPayloadSchema, transformHeaders, @@ -287,6 +288,7 @@ const createCronTask = async ( | z.infer | z.infer | z.infer + | z.infer | null = null; // @@ -376,6 +378,29 @@ const createCronTask = async ( }; } + if (row.jobType === "grpc") { + payload = { + workspaceId: String(row.workspaceId), + monitorId: String(row.id), + uri: row.url, + service: row.grpcService ?? undefined, + tls: row.grpcTls ?? "tls", + headers: transformHeaders(row.headers), + 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 706bb21e..11a142ed 100644 --- a/apps/workflows/src/cron/uptime-freeze.ts +++ b/apps/workflows/src/cron/uptime-freeze.ts @@ -22,6 +22,7 @@ const pipes: UptimeFreezePipes = { tcp: tb.tcpStatus45d, dns: tb.dnsStatus45d, icmp: tb.icmpStatus45d, + grpc: tb.grpcStatus45d, }; export async function handleUptimeFreezeCron(c: Context) { diff --git a/packages/api/src/router/checker.ts b/packages/api/src/router/checker.ts index 0e011912..adb8e8c3 100644 --- a/packages/api/src/router/checker.ts +++ b/packages/api/src/router/checker.ts @@ -13,7 +13,9 @@ import { monitor, selectMonitorSchema } from "@openstatus/db/src/schema"; import { monitorRegionSchema } from "@openstatus/db/src/schema/constants"; import { type httpPayloadSchema, + type grpcPayloadSchema, type icmpPayloadSchema, + GRPC_TLS_MODES, safeUrlSchema, type tpcPayloadSchema, transformHeaders, @@ -32,6 +34,9 @@ const ABORT_TIMEOUT = 10000; // answers before the fetch above gives up. const ICMP_TEST_TIMEOUT = 5000; +// Kept under ABORT_TIMEOUT so the checker answers before the fetch gives up. +const GRPC_TEST_TIMEOUT = 5000; + // Input schemas const httpTestInput = z.object({ url: safeUrlSchema, @@ -90,6 +95,51 @@ const icmpTestInput = z.object({ region: monitorRegionSchema.optional().prefault("ams"), }); +const grpcTestInput = z.object({ + url: z.string(), + service: z.string().optional(), + tls: z.enum(GRPC_TLS_MODES).optional().prefault("tls"), + headers: z.array(z.object({ key: z.string(), value: z.string() })).optional(), + region: monitorRegionSchema.optional().prefault("ams"), +}); + +export const grpcOutput = z + .object({ + state: z.literal("success").prefault("success"), + type: z.literal("grpc").prefault("grpc"), + jobType: z.literal("grpc").optional(), + requestId: z.number().optional(), + workspaceId: z.number().optional(), + monitorId: z.number().optional(), + timestamp: z.number(), + timing: z.object({ + dnsStart: z.number(), + dnsDone: z.number(), + connectStart: z.number(), + connectDone: z.number(), + tlsHandshakeStart: z.number(), + tlsHandshakeDone: z.number(), + firstByteStart: z.number(), + firstByteDone: z.number(), + transferStart: z.number(), + transferDone: z.number(), + }), + latency: z.number().optional(), + servingStatus: z.string().optional(), + service: z.string().optional(), + grpcCode: z.number().optional(), + completed: z.boolean().optional(), + errorMessage: z.string().optional(), + error: z.number().optional(), + region: monitorRegionSchema, + }) + .or( + z.object({ + state: z.literal("error").prefault("error"), + message: z.string(), + }), + ); + export const icmpOutput = z .object({ state: z.literal("success").prefault("success"), @@ -462,6 +512,63 @@ export async function testIcmp(input: z.infer) { } } +export async function testGrpc(input: z.infer) { + try { + const res = await fetch( + `https://openstatus-checker.fly.dev/grpc/${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, + service: input.service, + tls: input.tls, + headers: transformHeaders(input.headers ?? []), + timeout: GRPC_TEST_TIMEOUT, + }), + signal: AbortSignal.timeout(ABORT_TIMEOUT), + }, + ); + + const json = await res.json(); + const result = grpcOutput.safeParse(json); + + if (!result.success) { + console.error( + `Checker gRPC 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 gRPC test failed", error); + if (error instanceof TRPCError) { + throw error; + } + + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "gRPC check failed", + }); + } +} + export async function triggerChecker( input: z.infer, ) { @@ -469,6 +576,7 @@ export async function triggerChecker( | z.infer | z.infer | z.infer + | z.infer | null = null; if (process.env.NODE_ENV !== "production") { @@ -562,6 +670,28 @@ export async function triggerChecker( : undefined, }; } + if (input.jobType === "grpc") { + payload = { + workspaceId: String(input.workspaceId), + monitorId: String(input.id), + uri: input.url, + service: input.grpcService ?? undefined, + tls: input.grpcTls ?? "tls", + headers: transformHeaders(input.headers), + 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) { @@ -591,6 +721,8 @@ function generateUrl({ row }: { row: z.infer }) { 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}`; + case "grpc": + return `https://openstatus-checker.fly.dev/checker/grpc?monitor_id=${row.id}`; default: throw new Error("Invalid jobType"); } @@ -623,6 +755,13 @@ export const checkerRouter = createTRPCRouter({ return testIcmp(input); }), + testGrpc: protectedProcedure + .meta({ track: Events.TestMonitor }) + .input(grpcTestInput) + .mutation(async ({ input }) => { + return testGrpc(input); + }), + triggerChecker: protectedProcedure .input(z.object({ id: z.number() })) .mutation(async (opts) => { diff --git a/packages/api/src/router/monitor.ts b/packages/api/src/router/monitor.ts index a1a5b080..7213ffbc 100644 --- a/packages/api/src/router/monitor.ts +++ b/packages/api/src/router/monitor.ts @@ -16,6 +16,7 @@ import { deleteMonitor, deleteMonitors, getMonitor, + grpcTlsModes, listMonitors, monitorJobTypes, monitorMethods, @@ -35,7 +36,7 @@ import { z } from "zod"; import { env } from "../env"; import { toServiceCtx, toTRPCError } from "../service-adapter"; import { createTRPCRouter, protectedProcedure } from "../trpc"; -import { testDns, testHttp, testIcmp, testTcp } from "./checker"; +import { testDns, testGrpc, 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. @@ -63,6 +64,8 @@ const newMonitorTRPCInput = z.object({ active: z.boolean().prefault(false), saveCheck: z.boolean().prefault(false), skipCheck: z.boolean().prefault(false), + grpcService: z.string().optional(), + grpcTls: z.enum(grpcTlsModes).optional(), }); const updateGeneralTRPCInput = z.object({ @@ -77,6 +80,8 @@ const updateGeneralTRPCInput = z.object({ active: z.boolean().prefault(true), skipCheck: z.boolean().prefault(true), saveCheck: z.boolean().prefault(false), + grpcService: z.string().optional(), + grpcTls: z.enum(grpcTlsModes).optional(), }); export const monitorRouter = createTRPCRouter({ @@ -344,6 +349,14 @@ export const monitorRouter = createTRPCRouter({ }); } else if (input.jobType === "icmp") { await testIcmp({ url: input.url, region: "ams" }); + } else if (input.jobType === "grpc") { + await testGrpc({ + url: input.url, + service: input.grpcService, + tls: input.grpcTls ?? "tls", + headers: input.headers, + region: "ams", + }); } } @@ -410,6 +423,14 @@ export const monitorRouter = createTRPCRouter({ }); } else if (input.jobType === "icmp") { await testIcmp({ url: input.url, region: "ams" }); + } else if (input.jobType === "grpc") { + await testGrpc({ + url: input.url, + service: input.grpcService, + tls: input.grpcTls ?? "tls", + headers: input.headers, + region: "ams", + }); } } diff --git a/packages/api/src/router/statusPage.ts b/packages/api/src/router/statusPage.ts index c25b4fc2..cd7c9e63 100644 --- a/packages/api/src/router/statusPage.ts +++ b/packages/api/src/router/statusPage.ts @@ -705,6 +705,7 @@ export const statusPageRouter = createTRPCRouter({ tcp: monitors.filter((c) => c.monitor.jobType === "tcp"), dns: monitors.filter((c) => c.monitor.jobType === "dns"), icmp: monitors.filter((c) => c.monitor.jobType === "icmp"), + grpc: monitors.filter((c) => c.monitor.jobType === "grpc"), }; const proceduresByType = { @@ -712,6 +713,7 @@ export const statusPageRouter = createTRPCRouter({ tcp: getStatusProcedure("45d", "tcp"), dns: getStatusProcedure("45d", "dns"), icmp: getStatusProcedure("45d", "icmp"), + grpc: getStatusProcedure("45d", "grpc"), }; // Manual mode never touches Tinybird. Otherwise race the reads against @@ -719,7 +721,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, null]) + ? Promise.resolve([null, null, null, null, null]) : Promise.all( Object.entries(proceduresByType).map(([type, procedure]) => { const monitorIds = monitorsByType[ @@ -732,12 +734,8 @@ export const statusPageRouter = createTRPCRouter({ ); const tinybirdUnhealthy = !tinybird.ok; - const [statusHttp, statusTcp, statusDns, statusIcmp] = tinybird.data ?? [ - null, - null, - null, - null, - ]; + const [statusHttp, statusTcp, statusDns, statusIcmp, statusGrpc] = + tinybird.data ?? [null, null, null, null, null]; const statusDataByMonitorId = new Map< string, @@ -745,6 +743,7 @@ export const statusPageRouter = createTRPCRouter({ | Awaited>["data"] | Awaited>["data"] | Awaited>["data"] + | Awaited>["data"] >(); // Consolidate status data from all monitor types into the map @@ -753,6 +752,7 @@ export const statusPageRouter = createTRPCRouter({ statusTcp, statusDns, statusIcmp, + statusGrpc, ]) { if (statusResult?.data) { statusResult.data.forEach((status) => { @@ -1020,6 +1020,7 @@ export const statusPageRouter = createTRPCRouter({ tcp: publicMonitors.filter((c) => c.monitor.jobType === "tcp"), dns: publicMonitors.filter((c) => c.monitor.jobType === "dns"), icmp: publicMonitors.filter((c) => c.monitor.jobType === "icmp"), + grpc: publicMonitors.filter((c) => c.monitor.jobType === "grpc"), }; const proceduresByType = { @@ -1027,6 +1028,7 @@ export const statusPageRouter = createTRPCRouter({ tcp: getMetricsLatencyMultiProcedure("1d", "tcp"), dns: getMetricsLatencyMultiProcedure("1d", "dns"), icmp: getMetricsLatencyMultiProcedure("1d", "icmp"), + grpc: getMetricsLatencyMultiProcedure("1d", "grpc"), }; // Slow/erroring Tinybird → empty latency data so the page still renders. @@ -1047,7 +1049,8 @@ export const statusPageRouter = createTRPCRouter({ metricsLatencyMultiTcp, metricsLatencyMultiDns, metricsLatencyMultiIcmp, - ] = metrics.data ?? [null, null, null, null]; + metricsLatencyMultiGrpc, + ] = metrics.data ?? [null, null, null, null, null]; const metricsDataByMonitorId = new Map< string, @@ -1055,6 +1058,7 @@ export const statusPageRouter = createTRPCRouter({ | Awaited>["data"] | Awaited>["data"] | Awaited>["data"] + | Awaited>["data"] >(); if (metricsLatencyMultiHttp?.data) { @@ -1097,6 +1101,16 @@ export const statusPageRouter = createTRPCRouter({ }); } + if (metricsLatencyMultiGrpc?.data) { + metricsLatencyMultiGrpc.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) || []; diff --git a/packages/api/src/router/tinybird/index.ts b/packages/api/src/router/tinybird/index.ts index 2e38de87..b9043e3d 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", "icmp"] as const; +const types = ["http", "tcp", "dns", "icmp", "grpc"] as const; type Period = (typeof periods)[number]; type Type = (typeof types)[number]; @@ -44,6 +44,7 @@ function clampInterval(period: Period, interval?: number) { export function getWorkspace30dProcedure(type: Type) { if (type === "http") return tb.httpWorkspace30d; if (type === "icmp") return tb.icmpWorkspace30d; + if (type === "grpc") return tb.grpcWorkspace30d; return tb.tcpWorkspace30d; } // Helper functions to get the right procedure based on period and type @@ -54,24 +55,28 @@ export function getListProcedure(period: Period, type: Type) { if (type === "tcp") return tb.tcpListDaily; if (type === "dns") return tb.dnsListBiweekly; if (type === "icmp") return tb.icmpListDaily; + if (type === "grpc") return tb.grpcListDaily; 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; + if (type === "grpc") return tb.grpcListWeekly; 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; + if (type === "grpc") return tb.grpcListBiweekly; 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; + if (type === "grpc") return tb.grpcListDaily; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); } } @@ -83,36 +88,42 @@ export function getMetricsProcedure(period: Period, type: Type) { if (type === "http") return tb.httpMetricsDaily; if (type === "tcp") return tb.tcpMetricsDaily; if (type === "icmp") return tb.icmpMetricsDaily; + if (type === "grpc") return tb.grpcMetricsDaily; 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; + if (type === "grpc") return tb.grpcMetricsWeekly; 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; + if (type === "grpc") return tb.grpcMetricsBiweekly; 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; + if (type === "grpc") return tb.grpcMetrics30d; 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; + if (type === "grpc") return tb.grpcMetrics90d; 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; + if (type === "grpc") return tb.grpcMetricsDaily; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); } } @@ -125,36 +136,42 @@ export function getMetricsRegionsProcedure(period: Period, type: Type) { if (type === "http") return tb.httpMetricsRegionsDaily; if (type === "tcp") return tb.tcpMetricsByIntervalDaily; if (type === "icmp") return tb.icmpMetricsByIntervalDaily; + if (type === "grpc") return tb.grpcMetricsByIntervalDaily; 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; + if (type === "grpc") return tb.grpcMetricsByIntervalWeekly; 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; + if (type === "grpc") return tb.grpcMetricsByIntervalBiweekly; 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; + if (type === "grpc") return tb.grpcMetricsByInterval30d; 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; + if (type === "grpc") return tb.grpcMetricsByInterval90d; 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; + if (type === "grpc") return tb.grpcMetricsByIntervalDaily; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); } } @@ -164,6 +181,7 @@ export function getStatusProcedure(_period: "45d", type: Type) { if (type === "http") return tb.httpStatus45d; if (type === "tcp") return tb.tcpStatus45d; if (type === "icmp") return tb.icmpStatus45d; + if (type === "grpc") return tb.grpcStatus45d; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); } @@ -174,12 +192,14 @@ export function getGetProcedure(period: "14d", type: Type) { if (type === "tcp") return tb.tcpGetBiweekly; if (type === "dns") return tb.dnsGetBiweekly; if (type === "icmp") return tb.icmpGetBiweekly; + if (type === "grpc") return tb.grpcGetBiweekly; 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; + if (type === "grpc") return tb.grpcGetBiweekly; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); } } @@ -189,6 +209,7 @@ export function getGlobalMetricsProcedure(type: Type) { if (type === "tcp") return tb.tcpGlobalMetricsDaily; if (type === "dns") return tb.dnsGlobalMetricsDaily; if (type === "icmp") return tb.icmpGlobalMetricsDaily; + if (type === "grpc") return tb.grpcGlobalMetricsDaily; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); } @@ -200,24 +221,28 @@ export function getUptimeProcedure(period: "7d" | "30d" | "90d", type: Type) { if (type === "http") return tb.httpUptimeWeekly; if (type === "tcp") return tb.tcpUptimeWeekly; if (type === "icmp") return tb.icmpUptimeWeekly; + if (type === "grpc") return tb.grpcUptimeWeekly; 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; + if (type === "grpc") return tb.grpcUptime30d; 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; + if (type === "grpc") return tb.grpcUptime90d; 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; + if (type === "grpc") return tb.grpcUptime30d; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); } } @@ -230,12 +255,14 @@ export function getMetricsLatencyProcedure(_period: Period, type: Type) { if (type === "http") return tb.httpMetricsLatency1d; if (type === "tcp") return tb.tcpMetricsLatency1d; if (type === "icmp") return tb.icmpMetricsLatency1d; + if (type === "grpc") return tb.grpcMetricsLatency1d; 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; + if (type === "grpc") return tb.grpcMetricsLatency7d; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); // no dedicated 14d latency pipe; 30d MV is the smallest window covering 14d case "14d": @@ -244,18 +271,21 @@ export function getMetricsLatencyProcedure(_period: Period, type: Type) { if (type === "http") return tb.httpMetricsLatency30d; if (type === "tcp") return tb.tcpMetricsLatency30d; if (type === "icmp") return tb.icmpMetricsLatency30d; + if (type === "grpc") return tb.grpcMetricsLatency30d; 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; + if (type === "grpc") return tb.grpcMetricsLatency90d; 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; + if (type === "grpc") return tb.grpcMetricsLatency1d; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); } } @@ -265,6 +295,7 @@ export function getMetricsLatencyMultiProcedure(_period: Period, type: Type) { if (type === "http") return tb.httpMetricsLatency1dMulti; if (type === "tcp") return tb.tcpMetricsLatency1dMulti; if (type === "icmp") return tb.icmpMetricsLatency1dMulti; + if (type === "grpc") return tb.grpcMetricsLatency1dMulti; throw new TRPCError({ code: "NOT_FOUND", message: "Invalid type" }); } @@ -312,7 +343,7 @@ export const tinybirdRouter = createTRPCRouter({ const procedure = getListProcedure( period, - _monitor.jobType as "http" | "tcp" | "dns" | "icmp", + _monitor.jobType as "http" | "tcp" | "dns" | "icmp" | "grpc", ); return await procedure({ ...opts.input, @@ -505,7 +536,7 @@ export const tinybirdRouter = createTRPCRouter({ const procedure = getGetProcedure( opts.input.period, - _monitor.jobType as "http" | "tcp" | "dns" | "icmp", + _monitor.jobType as "http" | "tcp" | "dns" | "icmp" | "grpc", ); return await procedure(opts.input); }), diff --git a/packages/db/drizzle/0084_flaky_thundra.sql b/packages/db/drizzle/0084_flaky_thundra.sql new file mode 100644 index 00000000..e1a05e67 --- /dev/null +++ b/packages/db/drizzle/0084_flaky_thundra.sql @@ -0,0 +1,2 @@ +ALTER TABLE `monitor` ADD `grpc_service` text;--> statement-breakpoint +ALTER TABLE `monitor` ADD `grpc_tls` text DEFAULT 'tls'; \ No newline at end of file diff --git a/packages/db/drizzle/meta/0084_snapshot.json b/packages/db/drizzle/meta/0084_snapshot.json new file mode 100644 index 00000000..cb484e37 --- /dev/null +++ b/packages/db/drizzle/meta/0084_snapshot.json @@ -0,0 +1,4976 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "b39ead3f-410d-45d6-8d9d-b169d00d0039", + "prevId": "6d3f5d66-7abe-46a6-bb0e-8db6c9f3b401", + "tables": { + "workspace": { + "name": "workspace", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "stripe_id": { + "name": "stripe_id", + "type": "text(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "subscription_id": { + "name": "subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ends_at": { + "name": "ends_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "paid_until": { + "name": "paid_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "limits": { + "name": "limits", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "workos_organization_id": { + "name": "workos_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sso_enabled": { + "name": "sso_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "dsn": { + "name": "dsn", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "workspace_slug_unique": { + "name": "workspace_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "workspace_stripe_id_unique": { + "name": "workspace_stripe_id_unique", + "columns": [ + "stripe_id" + ], + "isUnique": true + }, + "workspace_workos_organization_id_unique": { + "name": "workspace_workos_organization_id_unique", + "columns": [ + "workos_organization_id" + ], + "isUnique": true + }, + "workspace_id_dsn_unique": { + "name": "workspace_id_dsn_unique", + "columns": [ + "id", + "dsn" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspace_sso_domain": { + "name": "workspace_sso_domain", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "verified_at": { + "name": "verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "workspace_sso_domain_domain_unique": { + "name": "workspace_sso_domain_domain_unique", + "columns": [ + "domain" + ], + "isUnique": true + }, + "workspace_sso_domain_workspace_id_idx": { + "name": "workspace_sso_domain_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "workspace_sso_domain_workspace_id_workspace_id_fk": { + "name": "workspace_sso_domain_workspace_id_workspace_id_fk", + "tableFrom": "workspace_sso_domain", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "account": { + "name": "account", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_provider_account_id_pk": { + "columns": [ + "provider", + "provider_account_id" + ], + "name": "account_provider_provider_account_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "photo_url": { + "name": "photo_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "emailVerified": { + "name": "emailVerified", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "user_tenant_id_unique": { + "name": "user_tenant_id_unique", + "columns": [ + "tenant_id" + ], + "isUnique": true + }, + "user_email_idx": { + "name": "user_email_idx", + "columns": [ + "email" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users_to_workspaces": { + "name": "users_to_workspaces", + "columns": { + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "users_to_workspaces_workspace_id_idx": { + "name": "users_to_workspaces_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "users_to_workspaces_user_id_user_id_fk": { + "name": "users_to_workspaces_user_id_user_id_fk", + "tableFrom": "users_to_workspaces", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "users_to_workspaces_workspace_id_workspace_id_fk": { + "name": "users_to_workspaces_workspace_id_workspace_id_fk", + "tableFrom": "users_to_workspaces", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "users_to_workspaces_user_id_workspace_id_pk": { + "columns": [ + "user_id", + "workspace_id" + ], + "name": "users_to_workspaces_user_id_workspace_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification_token": { + "name": "verification_token", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verification_token_identifier_token_pk": { + "columns": [ + "identifier", + "token" + ], + "name": "verification_token_identifier_token_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "status_report": { + "name": "status_report", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "page_id": { + "name": "page_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "status_report_workspace_created_idx": { + "name": "status_report_workspace_created_idx", + "columns": [ + "workspace_id", + "created_at" + ], + "isUnique": false + }, + "status_report_page_id_idx": { + "name": "status_report_page_id_idx", + "columns": [ + "page_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "status_report_workspace_id_workspace_id_fk": { + "name": "status_report_workspace_id_workspace_id_fk", + "tableFrom": "status_report", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "status_report_page_id_page_id_fk": { + "name": "status_report_page_id_page_id_fk", + "tableFrom": "status_report", + "tableTo": "page", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "status_report_update": { + "name": "status_report_update", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status_report_id": { + "name": "status_report_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "status_report_update_status_report_id_idx": { + "name": "status_report_update_status_report_id_idx", + "columns": [ + "status_report_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "status_report_update_status_report_id_status_report_id_fk": { + "name": "status_report_update_status_report_id_status_report_id_fk", + "tableFrom": "status_report_update", + "tableTo": "status_report", + "columnsFrom": [ + "status_report_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "integration": { + "name": "integration", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "credential": { + "name": "credential", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "integration_workspace_id_idx": { + "name": "integration_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "integration_workspace_id_workspace_id_fk": { + "name": "integration_workspace_id_workspace_id_fk", + "tableFrom": "integration", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "page": { + "name": "page", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "slug": { + "name": "slug", + "type": "text(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "custom_domain": { + "name": "custom_domain", + "type": "text(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "published": { + "name": "published", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "force_theme": { + "name": "force_theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'system'" + }, + "custom_theme": { + "name": "custom_theme", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password_protected": { + "name": "password_protected", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "access_type": { + "name": "access_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'public'" + }, + "auth_email_domains": { + "name": "auth_email_domains", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allowed_ip_ranges": { + "name": "allowed_ip_ranges", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "homepage_url": { + "name": "homepage_url", + "type": "text(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "contact_url": { + "name": "contact_url", + "type": "text(256)", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_locale": { + "name": "default_locale", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'en'" + }, + "locales": { + "name": "locales", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "legacy_page": { + "name": "legacy_page", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "configuration": { + "name": "configuration", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "allow_index": { + "name": "allow_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "show_monitor_values": { + "name": "show_monitor_values", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "page_slug_unique": { + "name": "page_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "page_lower_slug_idx": { + "name": "page_lower_slug_idx", + "columns": [ + "LOWER(\"slug\")" + ], + "isUnique": false + }, + "page_lower_custom_domain_idx": { + "name": "page_lower_custom_domain_idx", + "columns": [ + "LOWER(\"custom_domain\")" + ], + "isUnique": false + }, + "page_workspace_id_idx": { + "name": "page_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "page_workspace_id_workspace_id_fk": { + "name": "page_workspace_id_workspace_id_fk", + "tableFrom": "page", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "monitor": { + "name": "monitor", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "job_type": { + "name": "job_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'http'" + }, + "periodicity": { + "name": "periodicity", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'other'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "active": { + "name": "active", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "regions": { + "name": "regions", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "url": { + "name": "url", + "type": "text(2048)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "external_name": { + "name": "external_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "headers": { + "name": "headers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'GET'" + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 45000 + }, + "degraded_after": { + "name": "degraded_after", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assertions": { + "name": "assertions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "otel_endpoint": { + "name": "otel_endpoint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "otel_headers": { + "name": "otel_headers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "public": { + "name": "public", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "retry": { + "name": "retry", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 3 + }, + "follow_redirects": { + "name": "follow_redirects", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": true + }, + "grpc_service": { + "name": "grpc_service", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "grpc_tls": { + "name": "grpc_tls", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'tls'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "monitor_workspace_id_active_idx": { + "name": "monitor_workspace_id_active_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false, + "where": "\"monitor\".\"deleted_at\" IS NULL" + } + }, + "foreignKeys": { + "monitor_workspace_id_workspace_id_fk": { + "name": "monitor_workspace_id_workspace_id_fk", + "tableFrom": "monitor", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "page_subscriber": { + "name": "page_subscriber", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "page_id": { + "name": "page_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "channel_type": { + "name": "channel_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'email'" + }, + "webhook_url": { + "name": "webhook_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "channel_config": { + "name": "channel_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "slack_channel_id": { + "name": "slack_channel_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'self_signup'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "accepted_at": { + "name": "accepted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "unsubscribed_at": { + "name": "unsubscribed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "page_subscriber_page_id_idx": { + "name": "page_subscriber_page_id_idx", + "columns": [ + "page_id" + ], + "isUnique": false + }, + "idx_page_subscriber_email_page_active": { + "name": "idx_page_subscriber_email_page_active", + "columns": [ + "LOWER(\"email\")", + "page_id" + ], + "isUnique": true, + "where": "\"page_subscriber\".\"unsubscribed_at\" IS NULL AND \"page_subscriber\".\"channel_type\" = 'email'" + }, + "idx_page_subscriber_webhook_page_active": { + "name": "idx_page_subscriber_webhook_page_active", + "columns": [ + "LOWER(\"webhook_url\")", + "page_id" + ], + "isUnique": true, + "where": "\"page_subscriber\".\"unsubscribed_at\" IS NULL AND \"page_subscriber\".\"channel_type\" = 'webhook'" + }, + "idx_page_subscriber_slack_channel_page_active": { + "name": "idx_page_subscriber_slack_channel_page_active", + "columns": [ + "slack_channel_id", + "page_id" + ], + "isUnique": true, + "where": "\"page_subscriber\".\"unsubscribed_at\" IS NULL AND \"page_subscriber\".\"channel_type\" = 'slack'" + } + }, + "foreignKeys": { + "page_subscriber_page_id_page_id_fk": { + "name": "page_subscriber_page_id_page_id_fk", + "tableFrom": "page_subscriber", + "tableTo": "page", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "page_subscriber_channel_check": { + "name": "page_subscriber_channel_check", + "value": "(\"page_subscriber\".\"channel_type\" = 'email' AND \"page_subscriber\".\"email\" IS NOT NULL AND \"page_subscriber\".\"webhook_url\" IS NULL) OR (\"page_subscriber\".\"channel_type\" = 'webhook' AND \"page_subscriber\".\"webhook_url\" IS NOT NULL AND \"page_subscriber\".\"email\" IS NULL) OR (\"page_subscriber\".\"channel_type\" = 'slack' AND \"page_subscriber\".\"slack_channel_id\" IS NOT NULL AND \"page_subscriber\".\"email\" IS NULL AND \"page_subscriber\".\"webhook_url\" IS NULL)" + } + } + }, + "page_subscriber_to_page_component": { + "name": "page_subscriber_to_page_component", + "columns": { + "page_subscriber_id": { + "name": "page_subscriber_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "page_component_id": { + "name": "page_component_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": {}, + "foreignKeys": { + "page_subscriber_to_page_component_page_subscriber_id_page_subscriber_id_fk": { + "name": "page_subscriber_to_page_component_page_subscriber_id_page_subscriber_id_fk", + "tableFrom": "page_subscriber_to_page_component", + "tableTo": "page_subscriber", + "columnsFrom": [ + "page_subscriber_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "page_subscriber_to_page_component_page_component_id_page_component_id_fk": { + "name": "page_subscriber_to_page_component_page_component_id_page_component_id_fk", + "tableFrom": "page_subscriber_to_page_component", + "tableTo": "page_component", + "columnsFrom": [ + "page_component_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "page_subscriber_to_page_component_page_subscriber_id_page_component_id_pk": { + "columns": [ + "page_subscriber_id", + "page_component_id" + ], + "name": "page_subscriber_to_page_component_page_subscriber_id_page_component_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notification": { + "name": "notification", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'{}'" + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "notification_workspace_id_idx": { + "name": "notification_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "notification_workspace_id_workspace_id_fk": { + "name": "notification_workspace_id_workspace_id_fk", + "tableFrom": "notification", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notification_trigger": { + "name": "notification_trigger", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notification_id": { + "name": "notification_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cron_timestamp": { + "name": "cron_timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "notification_id_monitor_id_crontimestampe": { + "name": "notification_id_monitor_id_crontimestampe", + "columns": [ + "notification_id", + "monitor_id", + "cron_timestamp" + ], + "isUnique": true + } + }, + "foreignKeys": { + "notification_trigger_monitor_id_monitor_id_fk": { + "name": "notification_trigger_monitor_id_monitor_id_fk", + "tableFrom": "notification_trigger", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notification_trigger_notification_id_notification_id_fk": { + "name": "notification_trigger_notification_id_notification_id_fk", + "tableFrom": "notification_trigger", + "tableTo": "notification", + "columnsFrom": [ + "notification_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notifications_to_monitors": { + "name": "notifications_to_monitors", + "columns": { + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "notification_id": { + "name": "notification_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "notifications_to_monitors_notification_id_idx": { + "name": "notifications_to_monitors_notification_id_idx", + "columns": [ + "notification_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "notifications_to_monitors_monitor_id_monitor_id_fk": { + "name": "notifications_to_monitors_monitor_id_monitor_id_fk", + "tableFrom": "notifications_to_monitors", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notifications_to_monitors_notification_id_notification_id_fk": { + "name": "notifications_to_monitors_notification_id_notification_id_fk", + "tableFrom": "notifications_to_monitors", + "tableTo": "notification", + "columnsFrom": [ + "notification_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "notifications_to_monitors_monitor_id_notification_id_pk": { + "columns": [ + "monitor_id", + "notification_id" + ], + "name": "notifications_to_monitors_monitor_id_notification_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "monitor_status": { + "name": "monitor_status", + "columns": { + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "region": { + "name": "region", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "monitor_status_idx": { + "name": "monitor_status_idx", + "columns": [ + "monitor_id", + "region" + ], + "isUnique": false + } + }, + "foreignKeys": { + "monitor_status_monitor_id_monitor_id_fk": { + "name": "monitor_status_monitor_id_monitor_id_fk", + "tableFrom": "monitor_status", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "monitor_status_monitor_id_region_pk": { + "columns": [ + "monitor_id", + "region" + ], + "name": "monitor_status_monitor_id_region_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invitation": { + "name": "invitation", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "accepted_at": { + "name": "accepted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "invitation_workspace_id_idx": { + "name": "invitation_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "incident": { + "name": "incident", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'triage'" + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "acknowledged_at": { + "name": "acknowledged_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "acknowledged_by": { + "name": "acknowledged_by", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resolved_by": { + "name": "resolved_by", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "incident_screenshot_url": { + "name": "incident_screenshot_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recovery_screenshot_url": { + "name": "recovery_screenshot_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "auto_resolved": { + "name": "auto_resolved", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "incident_workspace_id_started_at_idx": { + "name": "incident_workspace_id_started_at_idx", + "columns": [ + "workspace_id", + "started_at" + ], + "isUnique": false + }, + "incident_open_idx": { + "name": "incident_open_idx", + "columns": [ + "monitor_id" + ], + "isUnique": false, + "where": "\"incident\".\"resolved_at\" IS NULL" + }, + "incident_monitor_id_started_at_unique": { + "name": "incident_monitor_id_started_at_unique", + "columns": [ + "monitor_id", + "started_at" + ], + "isUnique": true + } + }, + "foreignKeys": { + "incident_monitor_id_monitor_id_fk": { + "name": "incident_monitor_id_monitor_id_fk", + "tableFrom": "incident", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set default", + "onUpdate": "no action" + }, + "incident_workspace_id_workspace_id_fk": { + "name": "incident_workspace_id_workspace_id_fk", + "tableFrom": "incident", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "incident_acknowledged_by_user_id_fk": { + "name": "incident_acknowledged_by_user_id_fk", + "tableFrom": "incident", + "tableTo": "user", + "columnsFrom": [ + "acknowledged_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "incident_resolved_by_user_id_fk": { + "name": "incident_resolved_by_user_id_fk", + "tableFrom": "incident", + "tableTo": "user", + "columnsFrom": [ + "resolved_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "monitor_tag": { + "name": "monitor_tag", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "monitor_tag_workspace_id_idx": { + "name": "monitor_tag_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "monitor_tag_workspace_id_workspace_id_fk": { + "name": "monitor_tag_workspace_id_workspace_id_fk", + "tableFrom": "monitor_tag", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "monitor_tag_to_monitor": { + "name": "monitor_tag_to_monitor", + "columns": { + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "monitor_tag_id": { + "name": "monitor_tag_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "monitor_tag_to_monitor_monitor_tag_id_idx": { + "name": "monitor_tag_to_monitor_monitor_tag_id_idx", + "columns": [ + "monitor_tag_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "monitor_tag_to_monitor_monitor_id_monitor_id_fk": { + "name": "monitor_tag_to_monitor_monitor_id_monitor_id_fk", + "tableFrom": "monitor_tag_to_monitor", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "monitor_tag_to_monitor_monitor_tag_id_monitor_tag_id_fk": { + "name": "monitor_tag_to_monitor_monitor_tag_id_monitor_tag_id_fk", + "tableFrom": "monitor_tag_to_monitor", + "tableTo": "monitor_tag", + "columnsFrom": [ + "monitor_tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "monitor_tag_to_monitor_monitor_id_monitor_tag_id_pk": { + "columns": [ + "monitor_id", + "monitor_tag_id" + ], + "name": "monitor_tag_to_monitor_monitor_id_monitor_tag_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "application": { + "name": "application", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dsn": { + "name": "dsn", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "application_dsn_unique": { + "name": "application_dsn_unique", + "columns": [ + "dsn" + ], + "isUnique": true + }, + "application_workspace_id_idx": { + "name": "application_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "application_workspace_id_workspace_id_fk": { + "name": "application_workspace_id_workspace_id_fk", + "tableFrom": "application", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "maintenance": { + "name": "maintenance", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from": { + "name": "from", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "to": { + "name": "to", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "page_id": { + "name": "page_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "maintenance_page_id_idx": { + "name": "maintenance_page_id_idx", + "columns": [ + "page_id" + ], + "isUnique": false + }, + "maintenance_workspace_id_idx": { + "name": "maintenance_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "maintenance_workspace_id_workspace_id_fk": { + "name": "maintenance_workspace_id_workspace_id_fk", + "tableFrom": "maintenance", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "maintenance_page_id_page_id_fk": { + "name": "maintenance_page_id_page_id_fk", + "tableFrom": "maintenance", + "tableTo": "page", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "check": { + "name": "check", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "regions": { + "name": "regions", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "url": { + "name": "url", + "type": "text(4096)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "headers": { + "name": "headers", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "''" + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'GET'" + }, + "count_requests": { + "name": "count_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 1 + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "check_workspace_id_idx": { + "name": "check_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "check_workspace_id_workspace_id_fk": { + "name": "check_workspace_id_workspace_id_fk", + "tableFrom": "check", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "monitor_run": { + "name": "monitor_run", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "runned_at": { + "name": "runned_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "monitor_run_workspace_id_created_at_idx": { + "name": "monitor_run_workspace_id_created_at_idx", + "columns": [ + "workspace_id", + "created_at" + ], + "isUnique": false + }, + "monitor_run_monitor_id_idx": { + "name": "monitor_run_monitor_id_idx", + "columns": [ + "monitor_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "monitor_run_workspace_id_workspace_id_fk": { + "name": "monitor_run_workspace_id_workspace_id_fk", + "tableFrom": "monitor_run", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "monitor_run_monitor_id_monitor_id_fk": { + "name": "monitor_run_monitor_id_monitor_id_fk", + "tableFrom": "monitor_run", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "private_location_monitor_status": { + "name": "private_location_monitor_status", + "columns": { + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "private_location_id": { + "name": "private_location_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "cron_timestamp": { + "name": "cron_timestamp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "private_location_monitor_status_pl_id_idx": { + "name": "private_location_monitor_status_pl_id_idx", + "columns": [ + "private_location_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "private_location_monitor_status_monitor_id_monitor_id_fk": { + "name": "private_location_monitor_status_monitor_id_monitor_id_fk", + "tableFrom": "private_location_monitor_status", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "private_location_monitor_status_private_location_id_private_location_id_fk": { + "name": "private_location_monitor_status_private_location_id_private_location_id_fk", + "tableFrom": "private_location_monitor_status", + "tableTo": "private_location", + "columnsFrom": [ + "private_location_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "private_location_monitor_status_monitor_id_private_location_id_pk": { + "columns": [ + "monitor_id", + "private_location_id" + ], + "name": "private_location_monitor_status_monitor_id_private_location_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "private_location": { + "name": "private_location", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'error'" + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "private_location_workspace_id_idx": { + "name": "private_location_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "private_location_workspace_id_workspace_id_fk": { + "name": "private_location_workspace_id_workspace_id_fk", + "tableFrom": "private_location", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "private_location_to_monitor": { + "name": "private_location_to_monitor", + "columns": { + "private_location_id": { + "name": "private_location_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "private_location_to_monitor_private_location_id_idx": { + "name": "private_location_to_monitor_private_location_id_idx", + "columns": [ + "private_location_id" + ], + "isUnique": false + }, + "private_location_to_monitor_monitor_id_idx": { + "name": "private_location_to_monitor_monitor_id_idx", + "columns": [ + "monitor_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "private_location_to_monitor_private_location_id_private_location_id_fk": { + "name": "private_location_to_monitor_private_location_id_private_location_id_fk", + "tableFrom": "private_location_to_monitor", + "tableTo": "private_location", + "columnsFrom": [ + "private_location_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "private_location_to_monitor_monitor_id_monitor_id_fk": { + "name": "private_location_to_monitor_monitor_id_monitor_id_fk", + "tableFrom": "private_location_to_monitor", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "monitor_group": { + "name": "monitor_group", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "page_id": { + "name": "page_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "monitor_group_workspace_id_idx": { + "name": "monitor_group_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + }, + "monitor_group_page_id_idx": { + "name": "monitor_group_page_id_idx", + "columns": [ + "page_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "monitor_group_workspace_id_workspace_id_fk": { + "name": "monitor_group_workspace_id_workspace_id_fk", + "tableFrom": "monitor_group", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "monitor_group_page_id_page_id_fk": { + "name": "monitor_group_page_id_page_id_fk", + "tableFrom": "monitor_group", + "tableTo": "page", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "viewer": { + "name": "viewer", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "viewer_email_unique": { + "name": "viewer_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "viewer_accounts": { + "name": "viewer_accounts", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "viewer_accounts_user_id_viewer_id_fk": { + "name": "viewer_accounts_user_id_viewer_id_fk", + "tableFrom": "viewer_accounts", + "tableTo": "viewer", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "viewer_accounts_provider_providerAccountId_pk": { + "columns": [ + "provider", + "providerAccountId" + ], + "name": "viewer_accounts_provider_providerAccountId_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "viewer_session": { + "name": "viewer_session", + "columns": { + "session_token": { + "name": "session_token", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "viewer_session_user_id_viewer_id_fk": { + "name": "viewer_session_user_id_viewer_id_fk", + "tableFrom": "viewer_session", + "tableTo": "viewer", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "api_key": { + "name": "api_key", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "hashed_token": { + "name": "hashed_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "scopes": { + "name": "scopes", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[\"write\"]'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "api_key_prefix_unique": { + "name": "api_key_prefix_unique", + "columns": [ + "prefix" + ], + "isUnique": true + }, + "api_key_hashed_token_unique": { + "name": "api_key_hashed_token_unique", + "columns": [ + "hashed_token" + ], + "isUnique": true + }, + "api_key_prefix_idx": { + "name": "api_key_prefix_idx", + "columns": [ + "prefix" + ], + "isUnique": false + }, + "api_key_workspace_id_idx": { + "name": "api_key_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "api_key_workspace_id_workspace_id_fk": { + "name": "api_key_workspace_id_workspace_id_fk", + "tableFrom": "api_key", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_created_by_id_user_id_fk": { + "name": "api_key_created_by_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "maintenance_to_page_component": { + "name": "maintenance_to_page_component", + "columns": { + "maintenance_id": { + "name": "maintenance_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "page_component_id": { + "name": "page_component_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "maintenance_to_page_component_page_component_id_idx": { + "name": "maintenance_to_page_component_page_component_id_idx", + "columns": [ + "page_component_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "maintenance_to_page_component_maintenance_id_maintenance_id_fk": { + "name": "maintenance_to_page_component_maintenance_id_maintenance_id_fk", + "tableFrom": "maintenance_to_page_component", + "tableTo": "maintenance", + "columnsFrom": [ + "maintenance_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "maintenance_to_page_component_page_component_id_page_component_id_fk": { + "name": "maintenance_to_page_component_page_component_id_page_component_id_fk", + "tableFrom": "maintenance_to_page_component", + "tableTo": "page_component", + "columnsFrom": [ + "page_component_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "maintenance_to_page_component_maintenance_id_page_component_id_pk": { + "columns": [ + "maintenance_id", + "page_component_id" + ], + "name": "maintenance_to_page_component_maintenance_id_page_component_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "page_component": { + "name": "page_component", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "page_id": { + "name": "page_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'monitor'" + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "group_id": { + "name": "group_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "group_order": { + "name": "group_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "page_component_workspace_id_idx": { + "name": "page_component_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + }, + "page_component_page_id_monitor_id_unique": { + "name": "page_component_page_id_monitor_id_unique", + "columns": [ + "page_id", + "monitor_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "page_component_workspace_id_workspace_id_fk": { + "name": "page_component_workspace_id_workspace_id_fk", + "tableFrom": "page_component", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "page_component_page_id_page_id_fk": { + "name": "page_component_page_id_page_id_fk", + "tableFrom": "page_component", + "tableTo": "page", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "page_component_monitor_id_monitor_id_fk": { + "name": "page_component_monitor_id_monitor_id_fk", + "tableFrom": "page_component", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "page_component_group_id_page_component_groups_id_fk": { + "name": "page_component_group_id_page_component_groups_id_fk", + "tableFrom": "page_component", + "tableTo": "page_component_groups", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "page_component_type_check": { + "name": "page_component_type_check", + "value": "\"page_component\".\"type\" = 'monitor' AND \"page_component\".\"monitor_id\" IS NOT NULL OR \"page_component\".\"type\" = 'static' AND \"page_component\".\"monitor_id\" IS NULL" + } + } + }, + "status_report_update_to_page_component": { + "name": "status_report_update_to_page_component", + "columns": { + "status_report_update_id": { + "name": "status_report_update_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "page_component_id": { + "name": "page_component_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "impact": { + "name": "impact", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "status_report_update_to_page_component_page_component_id_idx": { + "name": "status_report_update_to_page_component_page_component_id_idx", + "columns": [ + "page_component_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "status_report_update_to_page_component_status_report_update_id_status_report_update_id_fk": { + "name": "status_report_update_to_page_component_status_report_update_id_status_report_update_id_fk", + "tableFrom": "status_report_update_to_page_component", + "tableTo": "status_report_update", + "columnsFrom": [ + "status_report_update_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "status_report_update_to_page_component_page_component_id_page_component_id_fk": { + "name": "status_report_update_to_page_component_page_component_id_page_component_id_fk", + "tableFrom": "status_report_update_to_page_component", + "tableTo": "page_component", + "columnsFrom": [ + "page_component_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "status_report_update_to_page_component_status_report_update_id_page_component_id_pk": { + "columns": [ + "status_report_update_id", + "page_component_id" + ], + "name": "status_report_update_to_page_component_status_report_update_id_page_component_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "status_report_to_page_component": { + "name": "status_report_to_page_component", + "columns": { + "status_report_id": { + "name": "status_report_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "page_component_id": { + "name": "page_component_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "status_report_to_page_component_page_component_id_idx": { + "name": "status_report_to_page_component_page_component_id_idx", + "columns": [ + "page_component_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "status_report_to_page_component_status_report_id_status_report_id_fk": { + "name": "status_report_to_page_component_status_report_id_status_report_id_fk", + "tableFrom": "status_report_to_page_component", + "tableTo": "status_report", + "columnsFrom": [ + "status_report_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "status_report_to_page_component_page_component_id_page_component_id_fk": { + "name": "status_report_to_page_component_page_component_id_page_component_id_fk", + "tableFrom": "status_report_to_page_component", + "tableTo": "page_component", + "columnsFrom": [ + "page_component_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "status_report_to_page_component_status_report_id_page_component_id_pk": { + "columns": [ + "status_report_id", + "page_component_id" + ], + "name": "status_report_to_page_component_status_report_id_page_component_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "page_component_groups": { + "name": "page_component_groups", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "page_id": { + "name": "page_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "default_open": { + "name": "default_open", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "page_component_groups_page_id_idx": { + "name": "page_component_groups_page_id_idx", + "columns": [ + "page_id" + ], + "isUnique": false + }, + "page_component_groups_workspace_id_idx": { + "name": "page_component_groups_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "page_component_groups_workspace_id_workspace_id_fk": { + "name": "page_component_groups_workspace_id_workspace_id_fk", + "tableFrom": "page_component_groups", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "page_component_groups_page_id_page_id_fk": { + "name": "page_component_groups_page_id_page_id_fk", + "tableFrom": "page_component_groups", + "tableTo": "page", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "feedback": { + "name": "feedback", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "blocker": { + "name": "blocker", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "feedback_workspace_id_idx": { + "name": "feedback_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "feedback_workspace_id_workspace_id_fk": { + "name": "feedback_workspace_id_workspace_id_fk", + "tableFrom": "feedback", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "feedback_user_id_user_id_fk": { + "name": "feedback_user_id_user_id_fk", + "tableFrom": "feedback", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audit_log": { + "name": "audit_log", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_type": { + "name": "actor_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "actor_user_id": { + "name": "actor_user_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "before": { + "name": "before", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "after": { + "name": "after", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "changed_fields": { + "name": "changed_fields", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "audit_log_workspace_created_idx": { + "name": "audit_log_workspace_created_idx", + "columns": [ + "workspace_id", + "created_at" + ], + "isUnique": false + }, + "audit_log_entity_idx": { + "name": "audit_log_entity_idx", + "columns": [ + "workspace_id", + "entity_type", + "entity_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_service": { + "name": "external_service", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "aliases": { + "name": "aliases", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(json_array())" + }, + "name": { + "name": "name", + "type": "text(256)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status_page_url": { + "name": "status_page_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "industry": { + "name": "industry", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "api_config": { + "name": "api_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "external_service_slug_unique": { + "name": "external_service_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + }, + "external_service_deleted_at_idx": { + "name": "external_service_deleted_at_idx", + "columns": [ + "deleted_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_service_component": { + "name": "external_service_component", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "external_service_id": { + "name": "external_service_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "upstream_component_id": { + "name": "upstream_component_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "aliases": { + "name": "aliases", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(json_array())" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "group_name": { + "name": "group_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "indicator": { + "name": "indicator", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "external_service_component_unique_idx": { + "name": "external_service_component_unique_idx", + "columns": [ + "external_service_id", + "upstream_component_id" + ], + "isUnique": true + }, + "external_service_component_slug_unique_idx": { + "name": "external_service_component_slug_unique_idx", + "columns": [ + "external_service_id", + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": { + "external_service_component_external_service_id_external_service_id_fk": { + "name": "external_service_component_external_service_id_external_service_id_fk", + "tableFrom": "external_service_component", + "tableTo": "external_service", + "columnsFrom": [ + "external_service_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_service_incident": { + "name": "external_service_incident", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "external_service_id": { + "name": "external_service_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_incident_id": { + "name": "provider_incident_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "impact": { + "name": "impact", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "shortlink": { + "name": "shortlink", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "affected_component_ids": { + "name": "affected_component_ids", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "raw_payload": { + "name": "raw_payload", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "raw_payload_purged_at": { + "name": "raw_payload_purged_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "external_service_incident_unique_idx": { + "name": "external_service_incident_unique_idx", + "columns": [ + "external_service_id", + "provider_incident_id" + ], + "isUnique": true + }, + "external_service_incident_started_at_idx": { + "name": "external_service_incident_started_at_idx", + "columns": [ + "external_service_id", + "started_at" + ], + "isUnique": false + }, + "external_service_incident_resolved_at_idx": { + "name": "external_service_incident_resolved_at_idx", + "columns": [ + "resolved_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "external_service_incident_external_service_id_external_service_id_fk": { + "name": "external_service_incident_external_service_id_external_service_id_fk", + "tableFrom": "external_service_incident", + "tableTo": "external_service", + "columnsFrom": [ + "external_service_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "external_service_report": { + "name": "external_service_report", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "external_service_id": { + "name": "external_service_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "external_service_component_id": { + "name": "external_service_component_id", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reporter_hash": { + "name": "reporter_hash", + "type": "text(64)", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "country": { + "name": "country", + "type": "text(2)", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "external_service_report_service_idx": { + "name": "external_service_report_service_idx", + "columns": [ + "external_service_id", + "created_at" + ], + "isUnique": false + }, + "external_service_report_component_idx": { + "name": "external_service_report_component_idx", + "columns": [ + "external_service_component_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "external_service_report_external_service_id_external_service_id_fk": { + "name": "external_service_report_external_service_id_external_service_id_fk", + "tableFrom": "external_service_report", + "tableTo": "external_service", + "columnsFrom": [ + "external_service_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "external_service_report_external_service_component_id_external_service_component_id_fk": { + "name": "external_service_report_external_service_component_id_external_service_component_id_fk", + "tableFrom": "external_service_report", + "tableTo": "external_service_component", + "columnsFrom": [ + "external_service_component_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "chat_session": { + "name": "chat_session", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "messages": { + "name": "messages", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "chat_session_workspace_user_updated_idx": { + "name": "chat_session_workspace_user_updated_idx", + "columns": [ + "workspace_id", + "user_id", + "updated_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "chat_session_workspace_id_workspace_id_fk": { + "name": "chat_session_workspace_id_workspace_id_fk", + "tableFrom": "chat_session", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_session_user_id_user_id_fk": { + "name": "chat_session_user_id_user_id_fk", + "tableFrom": "chat_session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "frozen_monitor_uptime": { + "name": "frozen_monitor_uptime", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "monitor_id": { + "name": "monitor_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "month": { + "name": "month", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "days": { + "name": "days", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(strftime('%s', 'now'))" + } + }, + "indexes": { + "frozen_monitor_uptime_workspace_id_idx": { + "name": "frozen_monitor_uptime_workspace_id_idx", + "columns": [ + "workspace_id" + ], + "isUnique": false + }, + "frozen_monitor_uptime_monitor_id_month_unique": { + "name": "frozen_monitor_uptime_monitor_id_month_unique", + "columns": [ + "monitor_id", + "month" + ], + "isUnique": true + } + }, + "foreignKeys": { + "frozen_monitor_uptime_workspace_id_workspace_id_fk": { + "name": "frozen_monitor_uptime_workspace_id_workspace_id_fk", + "tableFrom": "frozen_monitor_uptime", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "frozen_monitor_uptime_monitor_id_monitor_id_fk": { + "name": "frozen_monitor_uptime_monitor_id_monitor_id_fk", + "tableFrom": "frozen_monitor_uptime", + "tableTo": "monitor", + "columnsFrom": [ + "monitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": { + "page_lower_slug_idx": { + "columns": { + "LOWER(\"slug\")": { + "isExpression": true + } + } + }, + "page_lower_custom_domain_idx": { + "columns": { + "LOWER(\"custom_domain\")": { + "isExpression": true + } + } + }, + "idx_page_subscriber_email_page_active": { + "columns": { + "LOWER(\"email\")": { + "isExpression": true + } + } + }, + "idx_page_subscriber_webhook_page_active": { + "columns": { + "LOWER(\"webhook_url\")": { + "isExpression": true + } + } + } + } + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index e586f117..02b53c50 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -589,6 +589,13 @@ "when": 1785227235369, "tag": "0083_wide_otto_octavius", "breakpoints": true + }, + { + "idx": 84, + "version": "6", + "when": 1787773726456, + "tag": "0084_flaky_thundra", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/monitors/constants.ts b/packages/db/src/schema/monitors/constants.ts index 9c86c6c8..528c4006 100644 --- a/packages/db/src/schema/monitors/constants.ts +++ b/packages/db/src/schema/monitors/constants.ts @@ -1,4 +1,5 @@ import { + GRPC_TLS_MODES, MONITOR_JOB_TYPES, MONITOR_METHODS, MONITOR_STATUSES, @@ -7,3 +8,4 @@ import { export const monitorMethods = MONITOR_METHODS; export const monitorStatus = MONITOR_STATUSES; export const monitorJobTypes = MONITOR_JOB_TYPES; +export const grpcTlsModes = GRPC_TLS_MODES; diff --git a/packages/db/src/schema/monitors/monitor.ts b/packages/db/src/schema/monitors/monitor.ts index 9c15405a..eae65c16 100644 --- a/packages/db/src/schema/monitors/monitor.ts +++ b/packages/db/src/schema/monitors/monitor.ts @@ -8,7 +8,12 @@ import { monitorTagsToMonitors } from "../monitor_tags"; import { notificationsToMonitors } from "../notifications"; import { privateLocationToMonitors } from "../private_locations"; import { workspace } from "../workspaces/workspace"; -import { monitorJobTypes, monitorMethods, monitorStatus } from "./constants"; +import { + grpcTlsModes, + monitorJobTypes, + monitorMethods, + monitorStatus, +} from "./constants"; export const monitor = sqliteTable( "monitor", @@ -56,6 +61,10 @@ export const monitor = sqliteTable( true, ), + grpcService: text("grpc_service"), + + grpcTls: text("grpc_tls", { enum: grpcTlsModes }).default("tls"), + createdAt: integer("created_at", { mode: "timestamp" }).default( sql`(strftime('%s', 'now'))`, ), diff --git a/packages/db/src/schema/monitors/validation.ts b/packages/db/src/schema/monitors/validation.ts index a6774e94..43bce985 100644 --- a/packages/db/src/schema/monitors/validation.ts +++ b/packages/db/src/schema/monitors/validation.ts @@ -3,12 +3,18 @@ import { createInsertSchema, createSelectSchema } from "drizzle-zod"; import { z } from "zod"; import { monitorPeriodicitySchema, monitorRegionSchema } from "../constants"; -import { monitorJobTypes, monitorMethods, monitorStatus } from "./constants"; +import { + grpcTlsModes, + monitorJobTypes, + monitorMethods, + monitorStatus, +} from "./constants"; import { monitor } from "./monitor"; export const monitorMethodsSchema = z.enum(monitorMethods); export const monitorStatusSchema = z.enum(monitorStatus); export const monitorJobTypesSchema = z.enum(monitorJobTypes); +export const grpcTlsModesSchema = z.enum(grpcTlsModes); // TODO: shared function // oxlint-disable-next-line eslint/no-unused-vars @@ -90,3 +96,4 @@ export type MonitorPeriodicity = z.infer; export type MonitorMethod = z.infer; export type MonitorRegion = z.infer; export type MonitorJobType = z.infer; +export type GrpcTlsMode = z.infer; diff --git a/packages/proto/api/openstatus/monitor/v1/grpc_monitor.proto b/packages/proto/api/openstatus/monitor/v1/grpc_monitor.proto new file mode 100644 index 00000000..18048422 --- /dev/null +++ b/packages/proto/api/openstatus/monitor/v1/grpc_monitor.proto @@ -0,0 +1,116 @@ +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"; + +// GRPCTlsMode selects how the probe secures its connection to the target. +enum GRPCTlsMode { + // Unspecified. Treated as TLS on create; ignored on update. + GRPC_TLS_MODE_UNSPECIFIED = 0; + + // Verify the server certificate against the system trust store. + GRPC_TLS_MODE_TLS = 1; + + // Connect without TLS (h2c). + GRPC_TLS_MODE_PLAINTEXT = 2; + + // Use TLS but do not verify the server certificate. + GRPC_TLS_MODE_TLS_INSECURE = 3; +} + +// GRPCMonitor defines the configuration for a gRPC health check monitor. +// The probe calls grpc.health.v1.Health/Check on the target. +message GRPCMonitor { + // 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: "Checkout gRPC"}} + ]; + + // Target in "host:port" form. IPv6 addresses must be bracketed. + string uri = 3 [ + (buf.validate.field).string = { + min_len: 1 + max_len: 2048 + pattern: "^(\\[[0-9a-fA-F:]+\\]|[^:/\\s]+):[0-9]{1,5}$" + }, + (gnostic.openapi.v3.property) = {example: {yaml: "api.example.com:443"}} + ]; + + // 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} + ]; + + // Service name passed to Health/Check. Empty means overall server health. + optional string service = 15 [ + (buf.validate.field).string.max_len = 512, + (gnostic.openapi.v3.property) = {example: {yaml: "checkout.v1.CheckoutService"}} + ]; + + // How the connection to the target is secured. Defaults to TLS. + optional GRPCTlsMode tls_mode = 16; + + // Metadata sent with the health check request. + repeated Headers metadata = 17 [(buf.validate.field).repeated.max_items = 20]; +} diff --git a/packages/proto/api/openstatus/monitor/v1/service.proto b/packages/proto/api/openstatus/monitor/v1/service.proto index 6ebb9780..1d9fbbdf 100644 --- a/packages/proto/api/openstatus/monitor/v1/service.proto +++ b/packages/proto/api/openstatus/monitor/v1/service.proto @@ -5,6 +5,7 @@ package openstatus.monitor.v1; import "buf/validate/validate.proto"; import "gnostic/openapi/v3/annotations.proto"; import "openstatus/monitor/v1/dns_monitor.proto"; +import "openstatus/monitor/v1/grpc_monitor.proto"; import "openstatus/monitor/v1/http_monitor.proto"; import "openstatus/monitor/v1/icmp_monitor.proto"; import "openstatus/monitor/v1/monitor.proto"; @@ -42,6 +43,9 @@ service MonitorService { // CreateICMPMonitor creates a new ICMP monitor. rpc CreateICMPMonitor(CreateICMPMonitorRequest) returns (CreateICMPMonitorResponse); + // CreateGRPCMonitor creates a new gRPC health check monitor. + rpc CreateGRPCMonitor(CreateGRPCMonitorRequest) returns (CreateGRPCMonitorResponse); + // UpdateHTTPMonitor updates an existing HTTP monitor. rpc UpdateHTTPMonitor(UpdateHTTPMonitorRequest) returns (UpdateHTTPMonitorResponse); @@ -54,6 +58,9 @@ service MonitorService { // UpdateICMPMonitor updates an existing ICMP monitor. rpc UpdateICMPMonitor(UpdateICMPMonitorRequest) returns (UpdateICMPMonitorResponse); + // UpdateGRPCMonitor updates an existing gRPC monitor. + rpc UpdateGRPCMonitor(UpdateGRPCMonitorRequest) returns (UpdateGRPCMonitorResponse); + // TriggerMonitor initiates an immediate check for a monitor across all configured regions. rpc TriggerMonitor(TriggerMonitorRequest) returns (TriggerMonitorResponse) { option (gnostic.openapi.v3.operation) = { @@ -83,7 +90,7 @@ service MonitorService { } // GetMonitor returns a single monitor by ID within the authenticated workspace. - // Returns the monitor configuration (HTTP, TCP, DNS, or ICMP) using the MonitorConfig oneof type. + // Returns the monitor configuration (HTTP, TCP, DNS, ICMP, or gRPC) using the MonitorConfig oneof type. rpc GetMonitor(GetMonitorRequest) returns (GetMonitorResponse) { option idempotency_level = NO_SIDE_EFFECTS; } @@ -147,6 +154,18 @@ message CreateICMPMonitorResponse { ICMPMonitor monitor = 1; } +// CreateGRPCMonitorRequest is the request to create a new gRPC monitor. +message CreateGRPCMonitorRequest { + // Monitor configuration (required). + GRPCMonitor monitor = 1 [(buf.validate.field).required = true]; +} + +// CreateGRPCMonitorResponse is the response after creating a gRPC monitor. +message CreateGRPCMonitorResponse { + // The created monitor with assigned ID. + GRPCMonitor monitor = 1; +} + // UpdateHTTPMonitorRequest is the request to update an existing HTTP monitor. message UpdateHTTPMonitorRequest { // Monitor ID to update (required). @@ -207,6 +226,21 @@ message UpdateICMPMonitorResponse { ICMPMonitor monitor = 1; } +// UpdateGRPCMonitorRequest is the request to update an existing gRPC monitor. +message UpdateGRPCMonitorRequest { + // 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 GRPCMonitor monitor = 2; +} + +// UpdateGRPCMonitorResponse is the response after updating a gRPC monitor. +message UpdateGRPCMonitorResponse { + // The updated monitor. + GRPCMonitor monitor = 1; +} + // TriggerMonitorRequest is the request to trigger a monitor check. message TriggerMonitorRequest { // Monitor ID to trigger (required). @@ -257,6 +291,9 @@ message ListMonitorsResponse { // ICMP monitors in the workspace. repeated ICMPMonitor icmp_monitors = 5; + // gRPC monitors in the workspace. + repeated GRPCMonitor grpc_monitors = 6; + // Total number of monitors across all types. int32 total_size = 4; } @@ -296,6 +333,8 @@ message MonitorConfig { DNSMonitor dns = 3; // ICMP monitor configuration. ICMPMonitor icmp = 4; + // gRPC monitor configuration. + GRPCMonitor grpc = 5; } } @@ -358,7 +397,7 @@ message GetMonitorRequest { // GetMonitorResponse is the response containing the monitor. message GetMonitorResponse { - // The monitor configuration (one of HTTP, TCP, DNS, or ICMP). + // The monitor configuration (one of HTTP, TCP, DNS, ICMP, or gRPC). MonitorConfig monitor = 1; } diff --git a/packages/proto/gen/openapi.yaml b/packages/proto/gen/openapi.yaml index 532ee048..ed2d5f69 100644 --- a/packages/proto/gen/openapi.yaml +++ b/packages/proto/gen/openapi.yaml @@ -448,6 +448,28 @@ components: title: CreateDNSMonitorResponse additionalProperties: false description: CreateDNSMonitorResponse is the response after creating a DNS monitor. + openstatus.monitor.v1.CreateGRPCMonitorRequest: + type: object + properties: + monitor: + title: monitor + description: Monitor configuration (required). + $ref: '#/components/schemas/openstatus.monitor.v1.GRPCMonitor' + title: CreateGRPCMonitorRequest + required: + - monitor + additionalProperties: false + description: CreateGRPCMonitorRequest is the request to create a new gRPC monitor. + openstatus.monitor.v1.CreateGRPCMonitorResponse: + type: object + properties: + monitor: + title: monitor + description: The created monitor with assigned ID. + $ref: '#/components/schemas/openstatus.monitor.v1.GRPCMonitor' + title: CreateGRPCMonitorResponse + additionalProperties: false + description: CreateGRPCMonitorResponse is the response after creating a gRPC monitor. openstatus.monitor.v1.CreateHTTPMonitorRequest: type: object properties: @@ -645,6 +667,143 @@ components: title: DeleteMonitorResponse additionalProperties: false description: DeleteMonitorResponse is the response after deleting a monitor. + openstatus.monitor.v1.GRPCMonitor: + type: object + properties: + id: + type: string + title: id + description: Unique identifier for the monitor (output only for create requests). + name: + type: string + examples: + - Checkout gRPC + title: name + maxLength: 256 + minLength: 1 + description: Name of the monitor (required, max 256 characters). + uri: + type: string + examples: + - api.example.com:443 + title: uri + maxLength: 2048 + minLength: 1 + pattern: ^(\[[0-9a-fA-F:]+\]|[^:/\s]+):[0-9]{1,5}$ + description: Target in "host:port" form. IPv6 addresses must be bracketed. + 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 + service: + type: + - string + - "null" + examples: + - checkout.v1.CheckoutService + title: service + maxLength: 512 + description: Service name passed to Health/Check. Empty means overall server health. + tlsMode: + oneOf: + - $ref: '#/components/schemas/openstatus.monitor.v1.GRPCTlsMode' + - type: "null" + title: tls_mode + description: How the connection to the target is secured. Defaults to TLS. + metadata: + type: array + items: + $ref: '#/components/schemas/openstatus.monitor.v1.Headers' + title: metadata + maxItems: 20 + description: Metadata sent with the health check request. + title: GRPCMonitor + additionalProperties: false + description: |- + GRPCMonitor defines the configuration for a gRPC health check monitor. + The probe calls grpc.health.v1.Health/Check on the target. + openstatus.monitor.v1.GRPCTlsMode: + type: string + title: GRPCTlsMode + enum: + - GRPC_TLS_MODE_UNSPECIFIED + - GRPC_TLS_MODE_TLS + - GRPC_TLS_MODE_PLAINTEXT + - GRPC_TLS_MODE_TLS_INSECURE + description: GRPCTlsMode selects how the probe secures its connection to the target. openstatus.monitor.v1.GetMonitorHTTPResponseLogRequest: type: object properties: @@ -687,7 +846,7 @@ components: properties: monitor: title: monitor - description: The monitor configuration (one of HTTP, TCP, DNS, or ICMP). + description: The monitor configuration (one of HTTP, TCP, DNS, ICMP, or gRPC). $ref: '#/components/schemas/openstatus.monitor.v1.MonitorConfig' title: GetMonitorResponse additionalProperties: false @@ -1424,6 +1583,12 @@ components: $ref: '#/components/schemas/openstatus.monitor.v1.ICMPMonitor' title: icmp_monitors description: ICMP monitors in the workspace. + grpcMonitors: + type: array + items: + $ref: '#/components/schemas/openstatus.monitor.v1.GRPCMonitor' + title: grpc_monitors + description: gRPC monitors in the workspace. totalSize: type: integer title: total_size @@ -1444,6 +1609,15 @@ components: title: dns required: - dns + - type: object + properties: + grpc: + title: grpc + description: gRPC monitor configuration. + $ref: '#/components/schemas/openstatus.monitor.v1.GRPCMonitor' + title: grpc + required: + - grpc - type: object properties: http: @@ -1808,6 +1982,33 @@ components: title: UpdateDNSMonitorResponse additionalProperties: false description: UpdateDNSMonitorResponse is the response after updating a DNS monitor. + openstatus.monitor.v1.UpdateGRPCMonitorRequest: + type: object + properties: + id: + type: string + title: id + minLength: 1 + description: Monitor ID to update (required). + monitor: + oneOf: + - $ref: '#/components/schemas/openstatus.monitor.v1.GRPCMonitor' + - type: "null" + title: monitor + description: Updated monitor configuration (all fields optional for partial updates). + title: UpdateGRPCMonitorRequest + additionalProperties: false + description: UpdateGRPCMonitorRequest is the request to update an existing gRPC monitor. + openstatus.monitor.v1.UpdateGRPCMonitorResponse: + type: object + properties: + monitor: + title: monitor + description: The updated monitor. + $ref: '#/components/schemas/openstatus.monitor.v1.GRPCMonitor' + title: UpdateGRPCMonitorResponse + additionalProperties: false + description: UpdateGRPCMonitorResponse is the response after updating a gRPC monitor. openstatus.monitor.v1.UpdateHTTPMonitorRequest: type: object properties: @@ -5187,6 +5388,32 @@ paths: application/json: schema: $ref: '#/components/schemas/openstatus.monitor.v1.CreateDNSMonitorResponse' + /rpc/openstatus.monitor.v1.MonitorService/CreateGRPCMonitor: + post: + tags: + - MonitorService + summary: CreateGRPCMonitor + description: CreateGRPCMonitor creates a new gRPC health check monitor. + operationId: MonitorService_CreateGRPCMonitor + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/openstatus.monitor.v1.CreateGRPCMonitorRequest' + 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.CreateGRPCMonitorResponse' /rpc/openstatus.monitor.v1.MonitorService/CreateHTTPMonitor: post: tags: @@ -5298,7 +5525,7 @@ paths: summary: GetMonitor description: |- GetMonitor returns a single monitor by ID within the authenticated workspace. - Returns the monitor configuration (HTTP, TCP, DNS, or ICMP) using the MonitorConfig oneof type. + Returns the monitor configuration (HTTP, TCP, DNS, ICMP, or gRPC) using the MonitorConfig oneof type. operationId: MonitorService_GetMonitor.get parameters: - name: message @@ -5326,7 +5553,7 @@ paths: summary: GetMonitor description: |- GetMonitor returns a single monitor by ID within the authenticated workspace. - Returns the monitor configuration (HTTP, TCP, DNS, or ICMP) using the MonitorConfig oneof type. + Returns the monitor configuration (HTTP, TCP, DNS, ICMP, or gRPC) using the MonitorConfig oneof type. operationId: MonitorService_GetMonitor requestBody: content: @@ -5659,6 +5886,32 @@ paths: application/json: schema: $ref: '#/components/schemas/openstatus.monitor.v1.UpdateDNSMonitorResponse' + /rpc/openstatus.monitor.v1.MonitorService/UpdateGRPCMonitor: + post: + tags: + - MonitorService + summary: UpdateGRPCMonitor + description: UpdateGRPCMonitor updates an existing gRPC monitor. + operationId: MonitorService_UpdateGRPCMonitor + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/openstatus.monitor.v1.UpdateGRPCMonitorRequest' + 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.UpdateGRPCMonitorResponse' /rpc/openstatus.monitor.v1.MonitorService/UpdateHTTPMonitor: post: tags: diff --git a/packages/proto/gen/ts/openstatus/monitor/v1/grpc_monitor_pb.ts b/packages/proto/gen/ts/openstatus/monitor/v1/grpc_monitor_pb.ts new file mode 100644 index 00000000..ed04312a --- /dev/null +++ b/packages/proto/gen/ts/openstatus/monitor/v1/grpc_monitor_pb.ts @@ -0,0 +1,195 @@ +// @generated by protoc-gen-es v2.12.0 with parameter "target=ts,import_extension=.ts" +// @generated from file openstatus/monitor/v1/grpc_monitor.proto (package openstatus.monitor.v1, syntax proto3) +/* eslint-disable */ + +import type { GenEnum, GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2"; +import { enumDesc, 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 { Headers, 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/grpc_monitor.proto. + */ +export const file_openstatus_monitor_v1_grpc_monitor: GenFile = /*@__PURE__*/ + fileDesc("CihvcGVuc3RhdHVzL21vbml0b3IvdjEvZ3JwY19tb25pdG9yLnByb3RvEhVvcGVuc3RhdHVzLm1vbml0b3IudjEi/QYKC0dSUENNb25pdG9yEgoKAmlkGAEgASgJEiwKBG5hbWUYAiABKAlCHrpHEToPEg1DaGVja291dCBnUlBDukgHcgUQARiAAhJcCgN1cmkYAyABKAlCT7pHFzoVEhNhcGkuZXhhbXBsZS5jb206NDQzukgycjAQARiAEDIpXihcW1swLTlhLWZBLUY6XStcXXxbXjovXHNdKyk6WzAtOV17MSw1fSQSQQoLcGVyaW9kaWNpdHkYBCABKA4yIi5vcGVuc3RhdHVzLm1vbml0b3IudjEuUGVyaW9kaWNpdHlCCLpIBYIBAiAAEhwKB3RpbWVvdXQYBSABKANCC7pICCIGGMCpBygAEiUKC2RlZ3JhZGVkX2F0GAYgASgDQgu6SAgiBhjAqQcoAEgAiAEBEhgKBXJldHJ5GAcgASgDQgm6SAYiBBgKKAASIgoLZGVzY3JpcHRpb24YCCABKAlCCLpIBXIDGIAISAGIAQESEwoGYWN0aXZlGAkgASgISAKIAQESEwoGcHVibGljGAogASgISAOIAQESPwoHcmVnaW9ucxgLIAMoDjIdLm9wZW5zdGF0dXMubW9uaXRvci52MS5SZWdpb25CD7pIDJIBCRAcIgWCAQIgABJCCg5vcGVuX3RlbGVtZXRyeRgMIAEoCzIqLm9wZW5zdGF0dXMubW9uaXRvci52MS5PcGVuVGVsZW1ldHJ5Q29uZmlnEjQKBnN0YXR1cxgNIAEoDjIkLm9wZW5zdGF0dXMubW9uaXRvci52MS5Nb25pdG9yU3RhdHVzEiMKFHByaXZhdGVfbG9jYXRpb25faWRzGA4gAygJQgW6RwIYARJACgdzZXJ2aWNlGA8gASgJQiq6Rx86HRIbY2hlY2tvdXQudjEuQ2hlY2tvdXRTZXJ2aWNlukgFcgMYgARIBIgBARI5Cgh0bHNfbW9kZRgQIAEoDjIiLm9wZW5zdGF0dXMubW9uaXRvci52MS5HUlBDVGxzTW9kZUgFiAEBEjoKCG1ldGFkYXRhGBEgAygLMh4ub3BlbnN0YXR1cy5tb25pdG9yLnYxLkhlYWRlcnNCCLpIBZIBAhAUQg4KDF9kZWdyYWRlZF9hdEIOCgxfZGVzY3JpcHRpb25CCQoHX2FjdGl2ZUIJCgdfcHVibGljQgoKCF9zZXJ2aWNlQgsKCV90bHNfbW9kZSqAAQoLR1JQQ1Rsc01vZGUSHQoZR1JQQ19UTFNfTU9ERV9VTlNQRUNJRklFRBAAEhUKEUdSUENfVExTX01PREVfVExTEAESGwoXR1JQQ19UTFNfTU9ERV9QTEFJTlRFWFQQAhIeChpHUlBDX1RMU19NT0RFX1RMU19JTlNFQ1VSRRADQlNaUWdpdGh1Yi5jb20vb3BlbnN0YXR1c2hxL29wZW5zdGF0dXMvcGFja2FnZXMvcHJvdG8vb3BlbnN0YXR1cy9tb25pdG9yL3YxO21vbml0b3J2MWIGcHJvdG8z", [file_buf_validate_validate, file_gnostic_openapi_v3_annotations, file_openstatus_monitor_v1_http_monitor, file_openstatus_monitor_v1_monitor]); + +/** + * GRPCMonitor defines the configuration for a gRPC health check monitor. + * The probe calls grpc.health.v1.Health/Check on the target. + * + * @generated from message openstatus.monitor.v1.GRPCMonitor + */ +export type GRPCMonitor = Message<"openstatus.monitor.v1.GRPCMonitor"> & { + /** + * 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; + + /** + * Target in "host:port" form. IPv6 addresses must be bracketed. + * + * @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[]; + + /** + * Service name passed to Health/Check. Empty means overall server health. + * + * @generated from field: optional string service = 15; + */ + service?: string | undefined; + + /** + * How the connection to the target is secured. Defaults to TLS. + * + * @generated from field: optional openstatus.monitor.v1.GRPCTlsMode tls_mode = 16; + */ + tlsMode?: GRPCTlsMode | undefined; + + /** + * Metadata sent with the health check request. + * + * @generated from field: repeated openstatus.monitor.v1.Headers metadata = 17; + */ + metadata: Headers[]; +}; + +/** + * Describes the message openstatus.monitor.v1.GRPCMonitor. + * Use `create(GRPCMonitorSchema)` to create a new message. + */ +export const GRPCMonitorSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_openstatus_monitor_v1_grpc_monitor, 0); + +/** + * GRPCTlsMode selects how the probe secures its connection to the target. + * + * @generated from enum openstatus.monitor.v1.GRPCTlsMode + */ +export enum GRPCTlsMode { + /** + * Unspecified. Treated as TLS on create; ignored on update. + * + * @generated from enum value: GRPC_TLS_MODE_UNSPECIFIED = 0; + */ + GRPC_TLS_MODE_UNSPECIFIED = 0, + + /** + * Verify the server certificate against the system trust store. + * + * @generated from enum value: GRPC_TLS_MODE_TLS = 1; + */ + GRPC_TLS_MODE_TLS = 1, + + /** + * Connect without TLS (h2c). + * + * @generated from enum value: GRPC_TLS_MODE_PLAINTEXT = 2; + */ + GRPC_TLS_MODE_PLAINTEXT = 2, + + /** + * Use TLS but do not verify the server certificate. + * + * @generated from enum value: GRPC_TLS_MODE_TLS_INSECURE = 3; + */ + GRPC_TLS_MODE_TLS_INSECURE = 3, +} + +/** + * Describes the enum openstatus.monitor.v1.GRPCTlsMode. + */ +export const GRPCTlsModeSchema: GenEnum = /*@__PURE__*/ + enumDesc(file_openstatus_monitor_v1_grpc_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 e068ed2e..edb1e9ee 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 "./grpc_monitor_pb.js"; export * from "./icmp_monitor_pb.js"; export * from "./tcp_monitor_pb.js"; export * from "./monitor_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 9de06bc9..2e0f6450 100644 --- a/packages/proto/gen/ts/openstatus/monitor/v1/service_pb.ts +++ b/packages/proto/gen/ts/openstatus/monitor/v1/service_pb.ts @@ -8,6 +8,8 @@ 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 { DNSMonitor } from "./dns_monitor_pb.ts"; import { file_openstatus_monitor_v1_dns_monitor } from "./dns_monitor_pb.ts"; +import type { GRPCMonitor } from "./grpc_monitor_pb.ts"; +import { file_openstatus_monitor_v1_grpc_monitor } from "./grpc_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"; @@ -22,7 +24,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("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]); + fileDesc("CiNvcGVuc3RhdHVzL21vbml0b3IvdjEvc2VydmljZS5wcm90bxIVb3BlbnN0YXR1cy5tb25pdG9yLnYxIlcKGENyZWF0ZUhUVFBNb25pdG9yUmVxdWVzdBI7Cgdtb25pdG9yGAEgASgLMiIub3BlbnN0YXR1cy5tb25pdG9yLnYxLkhUVFBNb25pdG9yQga6SAPIAQEiUAoZQ3JlYXRlSFRUUE1vbml0b3JSZXNwb25zZRIzCgdtb25pdG9yGAEgASgLMiIub3BlbnN0YXR1cy5tb25pdG9yLnYxLkhUVFBNb25pdG9yIlUKF0NyZWF0ZVRDUE1vbml0b3JSZXF1ZXN0EjoKB21vbml0b3IYASABKAsyIS5vcGVuc3RhdHVzLm1vbml0b3IudjEuVENQTW9uaXRvckIGukgDyAEBIk4KGENyZWF0ZVRDUE1vbml0b3JSZXNwb25zZRIyCgdtb25pdG9yGAEgASgLMiEub3BlbnN0YXR1cy5tb25pdG9yLnYxLlRDUE1vbml0b3IiVQoXQ3JlYXRlRE5TTW9uaXRvclJlcXVlc3QSOgoHbW9uaXRvchgBIAEoCzIhLm9wZW5zdGF0dXMubW9uaXRvci52MS5ETlNNb25pdG9yQga6SAPIAQEiTgoYQ3JlYXRlRE5TTW9uaXRvclJlc3BvbnNlEjIKB21vbml0b3IYASABKAsyIS5vcGVuc3RhdHVzLm1vbml0b3IudjEuRE5TTW9uaXRvciJXChhDcmVhdGVJQ01QTW9uaXRvclJlcXVlc3QSOwoHbW9uaXRvchgBIAEoCzIiLm9wZW5zdGF0dXMubW9uaXRvci52MS5JQ01QTW9uaXRvckIGukgDyAEBIlAKGUNyZWF0ZUlDTVBNb25pdG9yUmVzcG9uc2USMwoHbW9uaXRvchgBIAEoCzIiLm9wZW5zdGF0dXMubW9uaXRvci52MS5JQ01QTW9uaXRvciJXChhDcmVhdGVHUlBDTW9uaXRvclJlcXVlc3QSOwoHbW9uaXRvchgBIAEoCzIiLm9wZW5zdGF0dXMubW9uaXRvci52MS5HUlBDTW9uaXRvckIGukgDyAEBIlAKGUNyZWF0ZUdSUENNb25pdG9yUmVzcG9uc2USMwoHbW9uaXRvchgBIAEoCzIiLm9wZW5zdGF0dXMubW9uaXRvci52MS5HUlBDTW9uaXRvciJ1ChhVcGRhdGVIVFRQTW9uaXRvclJlcXVlc3QSEwoCaWQYASABKAlCB7pIBHICEAESOAoHbW9uaXRvchgCIAEoCzIiLm9wZW5zdGF0dXMubW9uaXRvci52MS5IVFRQTW9uaXRvckgAiAEBQgoKCF9tb25pdG9yIlAKGVVwZGF0ZUhUVFBNb25pdG9yUmVzcG9uc2USMwoHbW9uaXRvchgBIAEoCzIiLm9wZW5zdGF0dXMubW9uaXRvci52MS5IVFRQTW9uaXRvciJzChdVcGRhdGVUQ1BNb25pdG9yUmVxdWVzdBITCgJpZBgBIAEoCUIHukgEcgIQARI3Cgdtb25pdG9yGAIgASgLMiEub3BlbnN0YXR1cy5tb25pdG9yLnYxLlRDUE1vbml0b3JIAIgBAUIKCghfbW9uaXRvciJOChhVcGRhdGVUQ1BNb25pdG9yUmVzcG9uc2USMgoHbW9uaXRvchgBIAEoCzIhLm9wZW5zdGF0dXMubW9uaXRvci52MS5UQ1BNb25pdG9yInMKF1VwZGF0ZUROU01vbml0b3JSZXF1ZXN0EhMKAmlkGAEgASgJQge6SARyAhABEjcKB21vbml0b3IYAiABKAsyIS5vcGVuc3RhdHVzLm1vbml0b3IudjEuRE5TTW9uaXRvckgAiAEBQgoKCF9tb25pdG9yIk4KGFVwZGF0ZUROU01vbml0b3JSZXNwb25zZRIyCgdtb25pdG9yGAEgASgLMiEub3BlbnN0YXR1cy5tb25pdG9yLnYxLkROU01vbml0b3IidQoYVXBkYXRlSUNNUE1vbml0b3JSZXF1ZXN0EhMKAmlkGAEgASgJQge6SARyAhABEjgKB21vbml0b3IYAiABKAsyIi5vcGVuc3RhdHVzLm1vbml0b3IudjEuSUNNUE1vbml0b3JIAIgBAUIKCghfbW9uaXRvciJQChlVcGRhdGVJQ01QTW9uaXRvclJlc3BvbnNlEjMKB21vbml0b3IYASABKAsyIi5vcGVuc3RhdHVzLm1vbml0b3IudjEuSUNNUE1vbml0b3IidQoYVXBkYXRlR1JQQ01vbml0b3JSZXF1ZXN0EhMKAmlkGAEgASgJQge6SARyAhABEjgKB21vbml0b3IYAiABKAsyIi5vcGVuc3RhdHVzLm1vbml0b3IudjEuR1JQQ01vbml0b3JIAIgBAUIKCghfbW9uaXRvciJQChlVcGRhdGVHUlBDTW9uaXRvclJlc3BvbnNlEjMKB21vbml0b3IYASABKAsyIi5vcGVuc3RhdHVzLm1vbml0b3IudjEuR1JQQ01vbml0b3IiLAoVVHJpZ2dlck1vbml0b3JSZXF1ZXN0EhMKAmlkGAEgASgJQge6SARyAhABIikKFlRyaWdnZXJNb25pdG9yUmVzcG9uc2USDwoHc3VjY2VzcxgBIAEoCCIrChREZWxldGVNb25pdG9yUmVxdWVzdBITCgJpZBgBIAEoCUIHukgEcgIQASIoChVEZWxldGVNb25pdG9yUmVzcG9uc2USDwoHc3VjY2VzcxgBIAEoCCJnChNMaXN0TW9uaXRvcnNSZXF1ZXN0Eh0KBWxpbWl0GAEgASgFQgm6SAYaBBhkKAFIAIgBARIcCgZvZmZzZXQYAiABKAVCB7pIBBoCKABIAYgBAUIICgZfbGltaXRCCQoHX29mZnNldCLNAgoUTGlzdE1vbml0b3JzUmVzcG9uc2USOQoNaHR0cF9tb25pdG9ycxgBIAMoCzIiLm9wZW5zdGF0dXMubW9uaXRvci52MS5IVFRQTW9uaXRvchI3Cgx0Y3BfbW9uaXRvcnMYAiADKAsyIS5vcGVuc3RhdHVzLm1vbml0b3IudjEuVENQTW9uaXRvchI3CgxkbnNfbW9uaXRvcnMYAyADKAsyIS5vcGVuc3RhdHVzLm1vbml0b3IudjEuRE5TTW9uaXRvchI5Cg1pY21wX21vbml0b3JzGAUgAygLMiIub3BlbnN0YXR1cy5tb25pdG9yLnYxLklDTVBNb25pdG9yEjkKDWdycGNfbW9uaXRvcnMYBiADKAsyIi5vcGVuc3RhdHVzLm1vbml0b3IudjEuR1JQQ01vbml0b3ISEgoKdG90YWxfc2l6ZRgEIAEoBSIuChdHZXRNb25pdG9yU3RhdHVzUmVxdWVzdBITCgJpZBgBIAEoCUIHukgEcgIQASJzCgxSZWdpb25TdGF0dXMSLQoGcmVnaW9uGAEgASgOMh0ub3BlbnN0YXR1cy5tb25pdG9yLnYxLlJlZ2lvbhI0CgZzdGF0dXMYAiABKA4yJC5vcGVuc3RhdHVzLm1vbml0b3IudjEuTW9uaXRvclN0YXR1cyJcChhHZXRNb25pdG9yU3RhdHVzUmVzcG9uc2USCgoCaWQYASABKAkSNAoHcmVnaW9ucxgCIAMoCzIjLm9wZW5zdGF0dXMubW9uaXRvci52MS5SZWdpb25TdGF0dXMimQIKDU1vbml0b3JDb25maWcSMgoEaHR0cBgBIAEoCzIiLm9wZW5zdGF0dXMubW9uaXRvci52MS5IVFRQTW9uaXRvckgAEjAKA3RjcBgCIAEoCzIhLm9wZW5zdGF0dXMubW9uaXRvci52MS5UQ1BNb25pdG9ySAASMAoDZG5zGAMgASgLMiEub3BlbnN0YXR1cy5tb25pdG9yLnYxLkROU01vbml0b3JIABIyCgRpY21wGAQgASgLMiIub3BlbnN0YXR1cy5tb25pdG9yLnYxLklDTVBNb25pdG9ySAASMgoEZ3JwYxgFIAEoCzIiLm9wZW5zdGF0dXMubW9uaXRvci52MS5HUlBDTW9uaXRvckgAQggKBmNvbmZpZyKfAQoYR2V0TW9uaXRvclN1bW1hcnlSZXF1ZXN0EhMKAmlkGAEgASgJQge6SARyAhABEjQKCnRpbWVfcmFuZ2UYAiABKA4yIC5vcGVuc3RhdHVzLm1vbml0b3IudjEuVGltZVJhbmdlEjgKB3JlZ2lvbnMYAyADKA4yHS5vcGVuc3RhdHVzLm1vbml0b3IudjEuUmVnaW9uQgi6SAWSAQIQHCKsAgoZR2V0TW9uaXRvclN1bW1hcnlSZXNwb25zZRIKCgJpZBgBIAEoCRIUCgxsYXN0X3BpbmdfYXQYAiABKAkSGAoQdG90YWxfc3VjY2Vzc2Z1bBgDIAEoAxIWCg50b3RhbF9kZWdyYWRlZBgEIAEoAxIUCgx0b3RhbF9mYWlsZWQYBSABKAMSCwoDcDUwGAYgASgDEgsKA3A3NRgHIAEoAxILCgNwOTAYCCABKAMSCwoDcDk1GAkgASgDEgsKA3A5ORgKIAEoAxI0Cgp0aW1lX3JhbmdlGAsgASgOMiAub3BlbnN0YXR1cy5tb25pdG9yLnYxLlRpbWVSYW5nZRIuCgdyZWdpb25zGAwgAygOMh0ub3BlbnN0YXR1cy5tb25pdG9yLnYxLlJlZ2lvbiIoChFHZXRNb25pdG9yUmVxdWVzdBITCgJpZBgBIAEoCUIHukgEcgIQASJLChJHZXRNb25pdG9yUmVzcG9uc2USNQoHbW9uaXRvchgBIAEoCzIkLm9wZW5zdGF0dXMubW9uaXRvci52MS5Nb25pdG9yQ29uZmlnImIKFUhUVFBSZXNwb25zZUxvZ1RpbWluZxILCgNkbnMYASABKAUSDwoHY29ubmVjdBgCIAEoBRILCgN0bHMYAyABKAUSDAoEdHRmYhgEIAEoBRIQCgh0cmFuc2ZlchgFIAEoBSK1AwoXSFRUUFJlc3BvbnNlTG9nTGlzdEl0ZW0SDwoCaWQYASABKAlIAIgBARIPCgdsYXRlbmN5GAIgASgFEhgKC3N0YXR1c19jb2RlGAMgASgFSAGIAQESEgoKbW9uaXRvcl9pZBgEIAEoCRJLCg5yZXF1ZXN0X3N0YXR1cxgFIAEoDjIzLm9wZW5zdGF0dXMubW9uaXRvci52MS5IVFRQUmVzcG9uc2VMb2dSZXF1ZXN0U3RhdHVzEi0KBnJlZ2lvbhgGIAEoDjIdLm9wZW5zdGF0dXMubW9uaXRvci52MS5SZWdpb24SFgoOY3Jvbl90aW1lc3RhbXAYByABKAMSPgoHdHJpZ2dlchgIIAEoDjItLm9wZW5zdGF0dXMubW9uaXRvci52MS5IVFRQUmVzcG9uc2VMb2dUcmlnZ2VyEhEKCXRpbWVzdGFtcBgJIAEoAxJBCgZ0aW1pbmcYCiABKAsyLC5vcGVuc3RhdHVzLm1vbml0b3IudjEuSFRUUFJlc3BvbnNlTG9nVGltaW5nSAKIAQFCBQoDX2lkQg4KDF9zdGF0dXNfY29kZUIJCgdfdGltaW5nIrYCChVIVFRQUmVzcG9uc2VMb2dEZXRhaWwSOwoDbG9nGAEgASgLMi4ub3BlbnN0YXR1cy5tb25pdG9yLnYxLkhUVFBSZXNwb25zZUxvZ0xpc3RJdGVtEgsKA3VybBgCIAEoCRINCgVlcnJvchgDIAEoCBIUCgdtZXNzYWdlGAQgASgJSACIAQESSgoHaGVhZGVycxgFIAMoCzI5Lm9wZW5zdGF0dXMubW9uaXRvci52MS5IVFRQUmVzcG9uc2VMb2dEZXRhaWwuSGVhZGVyc0VudHJ5EhcKCmFzc2VydGlvbnMYBiABKAlIAYgBARouCgxIZWFkZXJzRW50cnkSCwoDa2V5GAEgASgJEg0KBXZhbHVlGAIgASgJOgI4AUIKCghfbWVzc2FnZUINCgtfYXNzZXJ0aW9ucyLnAQoiTGlzdE1vbml0b3JIVFRQUmVzcG9uc2VMb2dzUmVxdWVzdBITCgJpZBgBIAEoCUIHukgEcgIQARIbCg5mcm9tX3RpbWVzdGFtcBgCIAEoA0gAiAEBEhkKDHRvX3RpbWVzdGFtcBgDIAEoA0gBiAEBEh0KBWxpbWl0GAQgASgFQgm6SAYaBBhkKAFIAogBARIcCgZvZmZzZXQYBSABKAVCB7pIBBoCKABIA4gBAUIRCg9fZnJvbV90aW1lc3RhbXBCDwoNX3RvX3RpbWVzdGFtcEIICgZfbGltaXRCCQoHX29mZnNldCJ2ChlIVFRQUmVzcG9uc2VMb2dQYWdpbmF0aW9uEg0KBWxpbWl0GAEgASgFEg4KBm9mZnNldBgCIAEoBRIQCghoYXNfbW9yZRgDIAEoCBIYCgtuZXh0X29mZnNldBgEIAEoBUgAiAEBQg4KDF9uZXh0X29mZnNldCKpAQojTGlzdE1vbml0b3JIVFRQUmVzcG9uc2VMb2dzUmVzcG9uc2USPAoEbG9ncxgBIAMoCzIuLm9wZW5zdGF0dXMubW9uaXRvci52MS5IVFRQUmVzcG9uc2VMb2dMaXN0SXRlbRJECgpwYWdpbmF0aW9uGAIgASgLMjAub3BlbnN0YXR1cy5tb25pdG9yLnYxLkhUVFBSZXNwb25zZUxvZ1BhZ2luYXRpb24iUAogR2V0TW9uaXRvckhUVFBSZXNwb25zZUxvZ1JlcXVlc3QSEwoCaWQYASABKAlCB7pIBHICEAESFwoGbG9nX2lkGAIgASgJQge6SARyAhABIl4KIUdldE1vbml0b3JIVFRQUmVzcG9uc2VMb2dSZXNwb25zZRI5CgNsb2cYASABKAsyLC5vcGVuc3RhdHVzLm1vbml0b3IudjEuSFRUUFJlc3BvbnNlTG9nRGV0YWlsKmEKCVRpbWVSYW5nZRIaChZUSU1FX1JBTkdFX1VOU1BFQ0lGSUVEEAASEQoNVElNRV9SQU5HRV8xRBABEhEKDVRJTUVfUkFOR0VfN0QQAhISCg5USU1FX1JBTkdFXzE0RBADKtkBChxIVFRQUmVzcG9uc2VMb2dSZXF1ZXN0U3RhdHVzEjAKLEhUVFBfUkVTUE9OU0VfTE9HX1JFUVVFU1RfU1RBVFVTX1VOU1BFQ0lGSUVEEAASLAooSFRUUF9SRVNQT05TRV9MT0dfUkVRVUVTVF9TVEFUVVNfU1VDQ0VTUxABEioKJkhUVFBfUkVTUE9OU0VfTE9HX1JFUVVFU1RfU1RBVFVTX0VSUk9SEAISLQopSFRUUF9SRVNQT05TRV9MT0dfUkVRVUVTVF9TVEFUVVNfREVHUkFERUQQAyqKAQoWSFRUUFJlc3BvbnNlTG9nVHJpZ2dlchIpCiVIVFRQX1JFU1BPTlNFX0xPR19UUklHR0VSX1VOU1BFQ0lGSUVEEAASIgoeSFRUUF9SRVNQT05TRV9MT0dfVFJJR0dFUl9DUk9OEAESIQodSFRUUF9SRVNQT05TRV9MT0dfVFJJR0dFUl9BUEkQAjL7FwoOTW9uaXRvclNlcnZpY2USuQMKEUNyZWF0ZUhUVFBNb25pdG9yEi8ub3BlbnN0YXR1cy5tb25pdG9yLnYxLkNyZWF0ZUhUVFBNb25pdG9yUmVxdWVzdBowLm9wZW5zdGF0dXMubW9uaXRvci52MS5DcmVhdGVIVFRQTW9uaXRvclJlc3BvbnNlIsACuke8Ahq5AkNyZWF0ZXMgYSBuZXcgSFRUUCBtb25pdG9yIGluIHRoZSBhdXRoZW50aWNhdGVkIHdvcmtzcGFjZS4gQ29uZmlndXJlIHRoZSB0YXJnZXQgVVJMLCBIVFRQIG1ldGhvZCwgcmVxdWVzdCBoZWFkZXJzIGFuZCBib2R5LCByZXNwb25zZSBhc3NlcnRpb25zIChzdGF0dXMgY29kZSwgYm9keSBjb250ZW50LCBoZWFkZXJzKSwgY2hlY2sgcGVyaW9kaWNpdHksIGdlb2dyYXBoaWMgcmVnaW9ucywgYW5kIG9wdGlvbmFsIE9wZW5UZWxlbWV0cnkgZXhwb3J0LiBUaGUgbW9uaXRvciBzdGFydHMgY2hlY2tpbmcgaW1tZWRpYXRlbHkgaWYgc2V0IHRvIGFjdGl2ZS4ScwoQQ3JlYXRlVENQTW9uaXRvchIuLm9wZW5zdGF0dXMubW9uaXRvci52MS5DcmVhdGVUQ1BNb25pdG9yUmVxdWVzdBovLm9wZW5zdGF0dXMubW9uaXRvci52MS5DcmVhdGVUQ1BNb25pdG9yUmVzcG9uc2UScwoQQ3JlYXRlRE5TTW9uaXRvchIuLm9wZW5zdGF0dXMubW9uaXRvci52MS5DcmVhdGVETlNNb25pdG9yUmVxdWVzdBovLm9wZW5zdGF0dXMubW9uaXRvci52MS5DcmVhdGVETlNNb25pdG9yUmVzcG9uc2USdgoRQ3JlYXRlSUNNUE1vbml0b3ISLy5vcGVuc3RhdHVzLm1vbml0b3IudjEuQ3JlYXRlSUNNUE1vbml0b3JSZXF1ZXN0GjAub3BlbnN0YXR1cy5tb25pdG9yLnYxLkNyZWF0ZUlDTVBNb25pdG9yUmVzcG9uc2USdgoRQ3JlYXRlR1JQQ01vbml0b3ISLy5vcGVuc3RhdHVzLm1vbml0b3IudjEuQ3JlYXRlR1JQQ01vbml0b3JSZXF1ZXN0GjAub3BlbnN0YXR1cy5tb25pdG9yLnYxLkNyZWF0ZUdSUENNb25pdG9yUmVzcG9uc2USdgoRVXBkYXRlSFRUUE1vbml0b3ISLy5vcGVuc3RhdHVzLm1vbml0b3IudjEuVXBkYXRlSFRUUE1vbml0b3JSZXF1ZXN0GjAub3BlbnN0YXR1cy5tb25pdG9yLnYxLlVwZGF0ZUhUVFBNb25pdG9yUmVzcG9uc2UScwoQVXBkYXRlVENQTW9uaXRvchIuLm9wZW5zdGF0dXMubW9uaXRvci52MS5VcGRhdGVUQ1BNb25pdG9yUmVxdWVzdBovLm9wZW5zdGF0dXMubW9uaXRvci52MS5VcGRhdGVUQ1BNb25pdG9yUmVzcG9uc2UScwoQVXBkYXRlRE5TTW9uaXRvchIuLm9wZW5zdGF0dXMubW9uaXRvci52MS5VcGRhdGVETlNNb25pdG9yUmVxdWVzdBovLm9wZW5zdGF0dXMubW9uaXRvci52MS5VcGRhdGVETlNNb25pdG9yUmVzcG9uc2USdgoRVXBkYXRlSUNNUE1vbml0b3ISLy5vcGVuc3RhdHVzLm1vbml0b3IudjEuVXBkYXRlSUNNUE1vbml0b3JSZXF1ZXN0GjAub3BlbnN0YXR1cy5tb25pdG9yLnYxLlVwZGF0ZUlDTVBNb25pdG9yUmVzcG9uc2USdgoRVXBkYXRlR1JQQ01vbml0b3ISLy5vcGVuc3RhdHVzLm1vbml0b3IudjEuVXBkYXRlR1JQQ01vbml0b3JSZXF1ZXN0GjAub3BlbnN0YXR1cy5tb25pdG9yLnYxLlVwZGF0ZUdSUENNb25pdG9yUmVzcG9uc2US6QIKDlRyaWdnZXJNb25pdG9yEiwub3BlbnN0YXR1cy5tb25pdG9yLnYxLlRyaWdnZXJNb25pdG9yUmVxdWVzdBotLm9wZW5zdGF0dXMubW9uaXRvci52MS5UcmlnZ2VyTW9uaXRvclJlc3BvbnNlIvkBukf1ARryAU1hbnVhbGx5IHRyaWdnZXJzIGFuIGltbWVkaWF0ZSBjaGVjayBmb3IgdGhlIHNwZWNpZmllZCBtb25pdG9yIGFjcm9zcyBhbGwgY29uZmlndXJlZCByZWdpb25zLiBUaGlzIG9wZXJhdGlvbiBpcyByYXRlLWxpbWl0ZWQgdW5kZXIgdGhlIHN5bnRoZXRpYy1jaGVja3MgcXVvdGEuIEEgbW9uaXRvciBydW4gcmVjb3JkIGlzIGNyZWF0ZWQgYW5kIHRoZSBjaGVjayBpcyBkaXNwYXRjaGVkIHRvIHRoZSBjaGVja2VyIHNlcnZpY2UuEmoKDURlbGV0ZU1vbml0b3ISKy5vcGVuc3RhdHVzLm1vbml0b3IudjEuRGVsZXRlTW9uaXRvclJlcXVlc3QaLC5vcGVuc3RhdHVzLm1vbml0b3IudjEuRGVsZXRlTW9uaXRvclJlc3BvbnNlEmwKDExpc3RNb25pdG9ycxIqLm9wZW5zdGF0dXMubW9uaXRvci52MS5MaXN0TW9uaXRvcnNSZXF1ZXN0Gisub3BlbnN0YXR1cy5tb25pdG9yLnYxLkxpc3RNb25pdG9yc1Jlc3BvbnNlIgOQAgESeAoQR2V0TW9uaXRvclN0YXR1cxIuLm9wZW5zdGF0dXMubW9uaXRvci52MS5HZXRNb25pdG9yU3RhdHVzUmVxdWVzdBovLm9wZW5zdGF0dXMubW9uaXRvci52MS5HZXRNb25pdG9yU3RhdHVzUmVzcG9uc2UiA5ACARKmAwoRR2V0TW9uaXRvclN1bW1hcnkSLy5vcGVuc3RhdHVzLm1vbml0b3IudjEuR2V0TW9uaXRvclN1bW1hcnlSZXF1ZXN0GjAub3BlbnN0YXR1cy5tb25pdG9yLnYxLkdldE1vbml0b3JTdW1tYXJ5UmVzcG9uc2UirQKQAgG6R6YCGqMCUmV0dXJucyBhZ2dyZWdhdGVkIG1ldHJpY3MgZm9yIGEgbW9uaXRvciBpbmNsdWRpbmcgbGF0ZW5jeSBwZXJjZW50aWxlcyAocDUwLCBwNzUsIHA5MCwgcDk1LCBwOTkpLCByZXF1ZXN0IGNvdW50cyBieSBzdGF0dXMgKHN1Y2Nlc3NmdWwsIGRlZ3JhZGVkLCBmYWlsZWQpLCBhbmQgdGhlIHRpbWVzdGFtcCBvZiB0aGUgbGFzdCBjaGVjay4gTWV0cmljcyBjYW4gYmUgc2NvcGVkIHRvIGEgdGltZSByYW5nZSAoMSBkYXksIDcgZGF5cywgb3IgMTQgZGF5cykgYW5kIGZpbHRlcmVkIGJ5IHNwZWNpZmljIHJlZ2lvbnMuEmYKCkdldE1vbml0b3ISKC5vcGVuc3RhdHVzLm1vbml0b3IudjEuR2V0TW9uaXRvclJlcXVlc3QaKS5vcGVuc3RhdHVzLm1vbml0b3IudjEuR2V0TW9uaXRvclJlc3BvbnNlIgOQAgESmQEKG0xpc3RNb25pdG9ySFRUUFJlc3BvbnNlTG9ncxI5Lm9wZW5zdGF0dXMubW9uaXRvci52MS5MaXN0TW9uaXRvckhUVFBSZXNwb25zZUxvZ3NSZXF1ZXN0Gjoub3BlbnN0YXR1cy5tb25pdG9yLnYxLkxpc3RNb25pdG9ySFRUUFJlc3BvbnNlTG9nc1Jlc3BvbnNlIgOQAgESkwEKGUdldE1vbml0b3JIVFRQUmVzcG9uc2VMb2cSNy5vcGVuc3RhdHVzLm1vbml0b3IudjEuR2V0TW9uaXRvckhUVFBSZXNwb25zZUxvZ1JlcXVlc3QaOC5vcGVuc3RhdHVzLm1vbml0b3IudjEuR2V0TW9uaXRvckhUVFBSZXNwb25zZUxvZ1Jlc3BvbnNlIgOQAgFCU1pRZ2l0aHViLmNvbS9vcGVuc3RhdHVzaHEvb3BlbnN0YXR1cy9wYWNrYWdlcy9wcm90by9vcGVuc3RhdHVzL21vbml0b3IvdjE7bW9uaXRvcnYxYgZwcm90bzM", [file_buf_validate_validate, file_gnostic_openapi_v3_annotations, file_openstatus_monitor_v1_dns_monitor, file_openstatus_monitor_v1_grpc_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. @@ -192,6 +194,48 @@ export type CreateICMPMonitorResponse = Message<"openstatus.monitor.v1.CreateICM export const CreateICMPMonitorResponseSchema: GenMessage = /*@__PURE__*/ messageDesc(file_openstatus_monitor_v1_service, 7); +/** + * CreateGRPCMonitorRequest is the request to create a new gRPC monitor. + * + * @generated from message openstatus.monitor.v1.CreateGRPCMonitorRequest + */ +export type CreateGRPCMonitorRequest = Message<"openstatus.monitor.v1.CreateGRPCMonitorRequest"> & { + /** + * Monitor configuration (required). + * + * @generated from field: openstatus.monitor.v1.GRPCMonitor monitor = 1; + */ + monitor?: GRPCMonitor | undefined; +}; + +/** + * Describes the message openstatus.monitor.v1.CreateGRPCMonitorRequest. + * Use `create(CreateGRPCMonitorRequestSchema)` to create a new message. + */ +export const CreateGRPCMonitorRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_openstatus_monitor_v1_service, 8); + +/** + * CreateGRPCMonitorResponse is the response after creating a gRPC monitor. + * + * @generated from message openstatus.monitor.v1.CreateGRPCMonitorResponse + */ +export type CreateGRPCMonitorResponse = Message<"openstatus.monitor.v1.CreateGRPCMonitorResponse"> & { + /** + * The created monitor with assigned ID. + * + * @generated from field: openstatus.monitor.v1.GRPCMonitor monitor = 1; + */ + monitor?: GRPCMonitor | undefined; +}; + +/** + * Describes the message openstatus.monitor.v1.CreateGRPCMonitorResponse. + * Use `create(CreateGRPCMonitorResponseSchema)` to create a new message. + */ +export const CreateGRPCMonitorResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_openstatus_monitor_v1_service, 9); + /** * UpdateHTTPMonitorRequest is the request to update an existing HTTP monitor. * @@ -218,7 +262,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, 8); + messageDesc(file_openstatus_monitor_v1_service, 10); /** * UpdateHTTPMonitorResponse is the response after updating an HTTP monitor. @@ -239,7 +283,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, 9); + messageDesc(file_openstatus_monitor_v1_service, 11); /** * UpdateTCPMonitorRequest is the request to update an existing TCP monitor. @@ -267,7 +311,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, 10); + messageDesc(file_openstatus_monitor_v1_service, 12); /** * UpdateTCPMonitorResponse is the response after updating a TCP monitor. @@ -288,7 +332,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, 11); + messageDesc(file_openstatus_monitor_v1_service, 13); /** * UpdateDNSMonitorRequest is the request to update an existing DNS monitor. @@ -316,7 +360,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, 12); + messageDesc(file_openstatus_monitor_v1_service, 14); /** * UpdateDNSMonitorResponse is the response after updating a DNS monitor. @@ -337,7 +381,7 @@ 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, 13); + messageDesc(file_openstatus_monitor_v1_service, 15); /** * UpdateICMPMonitorRequest is the request to update an existing ICMP monitor. @@ -365,7 +409,7 @@ export type UpdateICMPMonitorRequest = Message<"openstatus.monitor.v1.UpdateICMP * Use `create(UpdateICMPMonitorRequestSchema)` to create a new message. */ export const UpdateICMPMonitorRequestSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 14); + messageDesc(file_openstatus_monitor_v1_service, 16); /** * UpdateICMPMonitorResponse is the response after updating an ICMP monitor. @@ -386,7 +430,56 @@ export type UpdateICMPMonitorResponse = Message<"openstatus.monitor.v1.UpdateICM * Use `create(UpdateICMPMonitorResponseSchema)` to create a new message. */ export const UpdateICMPMonitorResponseSchema: GenMessage = /*@__PURE__*/ - messageDesc(file_openstatus_monitor_v1_service, 15); + messageDesc(file_openstatus_monitor_v1_service, 17); + +/** + * UpdateGRPCMonitorRequest is the request to update an existing gRPC monitor. + * + * @generated from message openstatus.monitor.v1.UpdateGRPCMonitorRequest + */ +export type UpdateGRPCMonitorRequest = Message<"openstatus.monitor.v1.UpdateGRPCMonitorRequest"> & { + /** + * 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.GRPCMonitor monitor = 2; + */ + monitor?: GRPCMonitor | undefined; +}; + +/** + * Describes the message openstatus.monitor.v1.UpdateGRPCMonitorRequest. + * Use `create(UpdateGRPCMonitorRequestSchema)` to create a new message. + */ +export const UpdateGRPCMonitorRequestSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_openstatus_monitor_v1_service, 18); + +/** + * UpdateGRPCMonitorResponse is the response after updating a gRPC monitor. + * + * @generated from message openstatus.monitor.v1.UpdateGRPCMonitorResponse + */ +export type UpdateGRPCMonitorResponse = Message<"openstatus.monitor.v1.UpdateGRPCMonitorResponse"> & { + /** + * The updated monitor. + * + * @generated from field: openstatus.monitor.v1.GRPCMonitor monitor = 1; + */ + monitor?: GRPCMonitor | undefined; +}; + +/** + * Describes the message openstatus.monitor.v1.UpdateGRPCMonitorResponse. + * Use `create(UpdateGRPCMonitorResponseSchema)` to create a new message. + */ +export const UpdateGRPCMonitorResponseSchema: GenMessage = /*@__PURE__*/ + messageDesc(file_openstatus_monitor_v1_service, 19); /** * TriggerMonitorRequest is the request to trigger a monitor check. @@ -407,7 +500,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, 16); + messageDesc(file_openstatus_monitor_v1_service, 20); /** * TriggerMonitorResponse is the response after triggering a monitor. @@ -428,7 +521,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, 17); + messageDesc(file_openstatus_monitor_v1_service, 21); /** * DeleteMonitorRequest is the request to delete a monitor. @@ -449,7 +542,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, 18); + messageDesc(file_openstatus_monitor_v1_service, 22); /** * DeleteMonitorResponse is the response after deleting a monitor. @@ -470,7 +563,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, 19); + messageDesc(file_openstatus_monitor_v1_service, 23); /** * ListMonitorsRequest is the request to list monitors. @@ -498,7 +591,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, 20); + messageDesc(file_openstatus_monitor_v1_service, 24); /** * ListMonitorsResponse is the response containing a list of monitors. @@ -534,6 +627,13 @@ export type ListMonitorsResponse = Message<"openstatus.monitor.v1.ListMonitorsRe */ icmpMonitors: ICMPMonitor[]; + /** + * gRPC monitors in the workspace. + * + * @generated from field: repeated openstatus.monitor.v1.GRPCMonitor grpc_monitors = 6; + */ + grpcMonitors: GRPCMonitor[]; + /** * Total number of monitors across all types. * @@ -547,7 +647,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, 21); + messageDesc(file_openstatus_monitor_v1_service, 25); /** * GetMonitorStatusRequest is the request to get the status of all regions for a monitor. @@ -568,7 +668,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, 22); + messageDesc(file_openstatus_monitor_v1_service, 26); /** * RegionStatus represents the status of a monitor in a specific region. @@ -596,7 +696,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, 23); + messageDesc(file_openstatus_monitor_v1_service, 27); /** * GetMonitorStatusResponse is the response containing the status of all regions for a monitor. @@ -624,7 +724,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, 24); + messageDesc(file_openstatus_monitor_v1_service, 28); /** * MonitorConfig represents the type-specific configuration for a monitor. @@ -667,6 +767,14 @@ export type MonitorConfig = Message<"openstatus.monitor.v1.MonitorConfig"> & { */ value: ICMPMonitor; case: "icmp"; + } | { + /** + * gRPC monitor configuration. + * + * @generated from field: openstatus.monitor.v1.GRPCMonitor grpc = 5; + */ + value: GRPCMonitor; + case: "grpc"; } | { case: undefined; value?: undefined }; }; @@ -675,7 +783,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, 25); + messageDesc(file_openstatus_monitor_v1_service, 29); /** * GetMonitorSummaryRequest is the request to get aggregated metrics for a monitor. @@ -710,7 +818,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, 26); + messageDesc(file_openstatus_monitor_v1_service, 30); /** * GetMonitorSummaryResponse is the response containing aggregated metrics for a monitor. @@ -808,7 +916,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, 27); + messageDesc(file_openstatus_monitor_v1_service, 31); /** * GetMonitorRequest is the request to get a single monitor by ID. @@ -829,7 +937,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, 28); + messageDesc(file_openstatus_monitor_v1_service, 32); /** * GetMonitorResponse is the response containing the monitor. @@ -838,7 +946,7 @@ export const GetMonitorRequestSchema: GenMessage = /*@__PURE_ */ export type GetMonitorResponse = Message<"openstatus.monitor.v1.GetMonitorResponse"> & { /** - * The monitor configuration (one of HTTP, TCP, DNS, or ICMP). + * The monitor configuration (one of HTTP, TCP, DNS, ICMP, or gRPC). * * @generated from field: openstatus.monitor.v1.MonitorConfig monitor = 1; */ @@ -850,7 +958,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, 29); + messageDesc(file_openstatus_monitor_v1_service, 33); /** * HTTPResponseLogTiming contains calculated timing phases in milliseconds. @@ -899,7 +1007,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, 30); + messageDesc(file_openstatus_monitor_v1_service, 34); /** * HTTPResponseLogListItem is a compact response log entry. @@ -983,7 +1091,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, 31); + messageDesc(file_openstatus_monitor_v1_service, 35); /** * HTTPResponseLogDetail contains full response log debugging data. @@ -1039,7 +1147,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, 32); + messageDesc(file_openstatus_monitor_v1_service, 36); /** * ListMonitorHTTPResponseLogsRequest is the request to list response logs within the 14-day HTTP response-log window. @@ -1088,7 +1196,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, 33); + messageDesc(file_openstatus_monitor_v1_service, 37); /** * HTTPResponseLogPagination contains offset pagination metadata. @@ -1130,7 +1238,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, 34); + messageDesc(file_openstatus_monitor_v1_service, 38); /** * ListMonitorHTTPResponseLogsResponse is the response containing response logs. @@ -1158,7 +1266,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, 35); + messageDesc(file_openstatus_monitor_v1_service, 39); /** * GetMonitorHTTPResponseLogRequest is the request to get one response log. @@ -1186,7 +1294,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, 36); + messageDesc(file_openstatus_monitor_v1_service, 40); /** * GetMonitorHTTPResponseLogResponse is the response containing one response log. @@ -1207,7 +1315,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, 37); + messageDesc(file_openstatus_monitor_v1_service, 41); /** * TimeRange represents the time period for metrics aggregation. @@ -1371,6 +1479,16 @@ export const MonitorService: GenService<{ input: typeof CreateICMPMonitorRequestSchema; output: typeof CreateICMPMonitorResponseSchema; }, + /** + * CreateGRPCMonitor creates a new gRPC health check monitor. + * + * @generated from rpc openstatus.monitor.v1.MonitorService.CreateGRPCMonitor + */ + createGRPCMonitor: { + methodKind: "unary"; + input: typeof CreateGRPCMonitorRequestSchema; + output: typeof CreateGRPCMonitorResponseSchema; + }, /** * UpdateHTTPMonitor updates an existing HTTP monitor. * @@ -1411,6 +1529,16 @@ export const MonitorService: GenService<{ input: typeof UpdateICMPMonitorRequestSchema; output: typeof UpdateICMPMonitorResponseSchema; }, + /** + * UpdateGRPCMonitor updates an existing gRPC monitor. + * + * @generated from rpc openstatus.monitor.v1.MonitorService.UpdateGRPCMonitor + */ + updateGRPCMonitor: { + methodKind: "unary"; + input: typeof UpdateGRPCMonitorRequestSchema; + output: typeof UpdateGRPCMonitorResponseSchema; + }, /** * TriggerMonitor initiates an immediate check for a monitor across all configured regions. * @@ -1463,7 +1591,7 @@ export const MonitorService: GenService<{ }, /** * GetMonitor returns a single monitor by ID within the authenticated workspace. - * Returns the monitor configuration (HTTP, TCP, DNS, or ICMP) using the MonitorConfig oneof type. + * Returns the monitor configuration (HTTP, TCP, DNS, ICMP, or gRPC) using the MonitorConfig oneof type. * * @generated from rpc openstatus.monitor.v1.MonitorService.GetMonitor */ diff --git a/packages/proto/internal/private_location/v1/grpc_monitor.proto b/packages/proto/internal/private_location/v1/grpc_monitor.proto new file mode 100644 index 00000000..0112e9cb --- /dev/null +++ b/packages/proto/internal/private_location/v1/grpc_monitor.proto @@ -0,0 +1,24 @@ +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 GRPCMonitor { + string id = 1; + string uri = 2; + int64 timeout = 3; + optional int64 degraded_at = 4; + string periodicity = 5; + int64 retry = 6; + string service = 7; + string tls_mode = 8; + + repeated Headers metadata = 10; + + 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 b8633c7d..d4a55bff 100644 --- a/packages/proto/internal/private_location/v1/private_location.proto +++ b/packages/proto/internal/private_location/v1/private_location.proto @@ -3,6 +3,7 @@ syntax = "proto3"; package private_location.v1; import "private_location/v1/dns_monitor.proto"; +import "private_location/v1/grpc_monitor.proto"; import "private_location/v1/http_monitor.proto"; import "private_location/v1/icmp_monitor.proto"; import "private_location/v1/tcp_monitor.proto"; @@ -16,6 +17,7 @@ service PrivateLocationService { rpc IngestHTTP(IngestHTTPRequest) returns (IngestHTTPResponse) {} rpc IngestDNS(IngestDNSRequest) returns (IngestDNSResponse) {} rpc IngestICMP(IngestICMPRequest) returns (IngestICMPResponse) {} + rpc IngestGRPC(IngestGRPCRequest) returns (IngestGRPCResponse) {} } @@ -26,6 +28,7 @@ message MonitorsResponse { repeated TCPMonitor tcp_monitors = 2; repeated DNSMonitor dns_monitors = 3; repeated ICMPMonitor icmp_monitors = 5; + repeated GRPCMonitor grpc_monitors = 6; string region = 4; } @@ -109,3 +112,23 @@ message IngestICMPRequest { message IngestICMPResponse { } + +message IngestGRPCRequest { + string id = 1; + string monitorId = 2; + int64 latency = 3; + int64 timestamp = 4; + int64 cronTimestamp = 5; + string uri = 6; + string service = 7; + string servingStatus = 8; + int64 grpcCode = 9; + string message = 10; + string requestStatus = 11; + int64 error = 12; + string timing = 13; +} + +message IngestGRPCResponse { + +} 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 5335f78e..52594603 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, icmp: pipe }; + return { http: pipe, tcp: pipe, dns: pipe, icmp: pipe, grpc: pipe }; } function failingPipes(): UptimeFreezePipes { const pipe = () => Promise.reject(new Error("tinybird down")); - return { http: pipe, tcp: pipe, dns: pipe, icmp: pipe }; + return { http: pipe, tcp: pipe, dns: pipe, icmp: pipe, grpc: 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 7c6a3df7..dcb7d486 100644 --- a/packages/services/src/frozen-uptime/__tests__/run.test.ts +++ b/packages/services/src/frozen-uptime/__tests__/run.test.ts @@ -33,6 +33,7 @@ function makePipes( tcp: overrides.tcp ?? fallback, dns: overrides.dns ?? fallback, icmp: overrides.icmp ?? fallback, + grpc: overrides.grpc ?? fallback, }; } diff --git a/packages/services/src/frozen-uptime/get-history.ts b/packages/services/src/frozen-uptime/get-history.ts index ec1e3fa5..2effc4a0 100644 --- a/packages/services/src/frozen-uptime/get-history.ts +++ b/packages/services/src/frozen-uptime/get-history.ts @@ -242,6 +242,7 @@ export async function getUptimeHistory(args: { tcp: defaultTb.tcpStatus45d, dns: defaultTb.dnsStatus45d, icmp: defaultTb.icmpStatus45d, + grpc: defaultTb.grpcStatus45d, }; return fetchFreezeCounts({ monitorIdsByJobType, diff --git a/packages/services/src/frozen-uptime/run.ts b/packages/services/src/frozen-uptime/run.ts index ba66b6a1..fb133030 100644 --- a/packages/services/src/frozen-uptime/run.ts +++ b/packages/services/src/frozen-uptime/run.ts @@ -22,7 +22,7 @@ export type StatusPipeFn = (params: { // 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" | "icmp", + "http" | "tcp" | "dns" | "icmp" | "grpc", StatusPipeFn >; @@ -60,7 +60,8 @@ function hasStatusPipe( jobType === "http" || jobType === "tcp" || jobType === "dns" || - jobType === "icmp" + jobType === "icmp" || + jobType === "grpc" ); } diff --git a/packages/services/src/import/phase-writers.ts b/packages/services/src/import/phase-writers.ts index 8bdc6655..5a33ec01 100644 --- a/packages/services/src/import/phase-writers.ts +++ b/packages/services/src/import/phase-writers.ts @@ -754,6 +754,7 @@ export async function writeMonitorsPhase( | "http" | "tcp" | "icmp" + | "grpc" | "udp" | "dns" | "ssl", diff --git a/packages/services/src/monitor/create.ts b/packages/services/src/monitor/create.ts index c1bd5aca..8bc94be7 100644 --- a/packages/services/src/monitor/create.ts +++ b/packages/services/src/monitor/create.ts @@ -54,6 +54,8 @@ export async function createMonitor(args: { degradedAfter: input.degradedAfter, retry: input.retry, followRedirects: input.followRedirects, + grpcService: input.grpcService, + grpcTls: input.grpcTls, otelEndpoint: input.otelEndpoint, otelHeaders: headersToDbJson(input.otelHeaders), updatedAt: new Date(), diff --git a/packages/services/src/monitor/get-daily-summary.ts b/packages/services/src/monitor/get-daily-summary.ts index 00e5eefb..5719b11b 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" | "icmp"; +type SupportedJobType = "http" | "tcp" | "dns" | "icmp" | "grpc"; /** * Raw daily status buckets (one row per monitor per day) from the 45d Tinybird @@ -39,20 +39,22 @@ export async function fetchMonitorDailyStats(args: { tcp: [], dns: [], icmp: [], + grpc: [], }; for (const row of rows) { if ( row.jobType === "http" || row.jobType === "tcp" || row.jobType === "dns" || - row.jobType === "icmp" + row.jobType === "icmp" || + row.jobType === "grpc" ) { idsByJobType[row.jobType].push(String(row.id)); } } const results = await Promise.all( - (["http", "tcp", "dns", "icmp"] as const) + (["http", "tcp", "dns", "icmp", "grpc"] as const) .filter((jobType) => idsByJobType[jobType].length > 0) .map((jobType) => { const monitorIds = idsByJobType[jobType]; @@ -63,7 +65,9 @@ export async function fetchMonitorDailyStats(args: { ? args.tb.tcpStatus45d : jobType === "dns" ? args.tb.dnsStatus45d - : args.tb.icmpStatus45d; + : jobType === "icmp" + ? args.tb.icmpStatus45d + : args.tb.grpcStatus45d; return pipe({ monitorIds }); }), ); diff --git a/packages/services/src/monitor/get-monitor-summary.ts b/packages/services/src/monitor/get-monitor-summary.ts index 5345242d..1c43f914 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" | "icmp"; +type SupportedJobType = "http" | "tcp" | "dns" | "icmp" | "grpc"; function fetchMetrics( tb: NonNullable, @@ -63,6 +63,11 @@ function fetchMetrics( "7d": tb.icmpMetricsWeekly, "14d": tb.icmpMetricsBiweekly, }, + grpc: { + "1d": tb.grpcMetricsDaily, + "7d": tb.grpcMetricsWeekly, + "14d": tb.grpcMetricsBiweekly, + }, }[jobType][timeRange]; return pipe(params); } @@ -86,7 +91,8 @@ export async function getMonitorSummary(args: { parsed.jobType !== "http" && parsed.jobType !== "tcp" && parsed.jobType !== "dns" && - parsed.jobType !== "icmp" + parsed.jobType !== "icmp" && + parsed.jobType !== "grpc" ) { throw new ValidationError( `getMonitorSummary does not support jobType '${parsed.jobType}'`, diff --git a/packages/services/src/monitor/index.ts b/packages/services/src/monitor/index.ts index 87ce2d9e..17f2ecd5 100644 --- a/packages/services/src/monitor/index.ts +++ b/packages/services/src/monitor/index.ts @@ -62,6 +62,7 @@ export { GetMonitorSummaryInput, GetPrivateLocationIdsByMonitorInput, GetResponseLogInput, + grpcTlsModes, ListMonitorsInput, ListResponseLogsInput, monitorJobTypes, diff --git a/packages/services/src/monitor/schemas.ts b/packages/services/src/monitor/schemas.ts index c95f7bd0..87166958 100644 --- a/packages/services/src/monitor/schemas.ts +++ b/packages/services/src/monitor/schemas.ts @@ -7,12 +7,13 @@ import { } from "@openstatus/assertions"; import { monitorPeriodicity } from "@openstatus/db/src/schema/constants"; import { + grpcTlsModes, monitorJobTypes, monitorMethods, } from "@openstatus/db/src/schema/monitors/constants"; import { z } from "zod"; -export { monitorJobTypes, monitorMethods, monitorPeriodicity }; +export { grpcTlsModes, monitorJobTypes, monitorMethods, monitorPeriodicity }; const headerPair = z.object({ key: z.string(), value: z.string() }); const assertion = z.discriminatedUnion("type", [ @@ -56,6 +57,8 @@ export const CreateMonitorInput = z.object({ degradedAfter: apiTimeoutMs.nullish(), retry: z.number().int().min(0).optional(), followRedirects: z.boolean().optional(), + grpcService: z.string().optional(), + grpcTls: z.enum(grpcTlsModes).optional(), otelEndpoint: z.string().optional(), otelHeaders: z.array(headerPair).optional(), }); @@ -87,6 +90,8 @@ export const UpdateMonitorConfigInput = z.object({ degradedAfter: apiTimeoutMs.nullish(), retry: z.number().int().min(0).optional(), followRedirects: z.boolean().optional(), + grpcService: z.string().optional(), + grpcTls: z.enum(grpcTlsModes).optional(), otelEndpoint: z.string().optional(), otelHeaders: z.array(headerPair).optional(), }); diff --git a/packages/services/src/monitor/update.ts b/packages/services/src/monitor/update.ts index c4e8c916..c74ba11a 100644 --- a/packages/services/src/monitor/update.ts +++ b/packages/services/src/monitor/update.ts @@ -70,6 +70,8 @@ export async function updateMonitorConfig(args: { if (input.followRedirects !== undefined) { values.followRedirects = input.followRedirects; } + if (input.grpcService !== undefined) values.grpcService = input.grpcService; + if (input.grpcTls !== undefined) values.grpcTls = input.grpcTls; if (input.otelEndpoint !== undefined) { values.otelEndpoint = input.otelEndpoint; } diff --git a/packages/tinybird/datasources/check_grpc_response__v0.datasource b/packages/tinybird/datasources/check_grpc_response__v0.datasource new file mode 100644 index 00000000..9989d0b8 --- /dev/null +++ b/packages/tinybird/datasources/check_grpc_response__v0.datasource @@ -0,0 +1,20 @@ +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`, + `servingStatus` Nullable(String) `json:$.servingStatus`, + `grpcCode` Nullable(Int16) `json:$.grpcCode`, + `service` Nullable(String) `json:$.service`, + `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/grpc_response__v0.datasource b/packages/tinybird/datasources/grpc_response__v0.datasource new file mode 100644 index 00000000..43a694f1 --- /dev/null +++ b/packages/tinybird/datasources/grpc_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`, + `workspaceId` Int32 `json:$.workspaceId`, + `latency` Int64 `json:$.latency`, + `servingStatus` Nullable(String) `json:$.servingStatus`, + `grpcCode` Nullable(Int16) `json:$.grpcCode`, + `service` Nullable(String) `json:$.service`, + `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__grpc_14d__v0.datasource b/packages/tinybird/datasources/mv__grpc_14d__v0.datasource new file mode 100644 index 00000000..3e27e66e --- /dev/null +++ b/packages/tinybird/datasources/mv__grpc_14d__v0.datasource @@ -0,0 +1,21 @@ +# Data Source created from Pipe 'aggregate__grpc_14d__v0' + +SCHEMA > + `time` DateTime, + `latency` Int64, + `servingStatus` Nullable(String), + `grpcCode` Nullable(Int16), + `service` Nullable(String), + `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__grpc_1d__v0.datasource b/packages/tinybird/datasources/mv__grpc_1d__v0.datasource new file mode 100644 index 00000000..2e3ef49d --- /dev/null +++ b/packages/tinybird/datasources/mv__grpc_1d__v0.datasource @@ -0,0 +1,21 @@ +# Data Source created from Pipe 'aggregate__grpc_1d__v0' + +SCHEMA > + `time` DateTime, + `latency` Int64, + `servingStatus` Nullable(String), + `grpcCode` Nullable(Int16), + `service` Nullable(String), + `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__grpc_30d__v0.datasource b/packages/tinybird/datasources/mv__grpc_30d__v0.datasource new file mode 100644 index 00000000..f6f73666 --- /dev/null +++ b/packages/tinybird/datasources/mv__grpc_30d__v0.datasource @@ -0,0 +1,21 @@ +# Data Source created from Pipe 'aggregate__grpc_30d__v0' + +SCHEMA > + `time` DateTime, + `latency` Int64, + `servingStatus` Nullable(String), + `grpcCode` Nullable(Int16), + `service` Nullable(String), + `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__grpc_7d__v0.datasource b/packages/tinybird/datasources/mv__grpc_7d__v0.datasource new file mode 100644 index 00000000..f7250815 --- /dev/null +++ b/packages/tinybird/datasources/mv__grpc_7d__v0.datasource @@ -0,0 +1,21 @@ +# Data Source created from Pipe 'aggregate__grpc_7d__v0' + +SCHEMA > + `time` DateTime, + `latency` Int64, + `servingStatus` Nullable(String), + `grpcCode` Nullable(Int16), + `service` Nullable(String), + `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__grpc_90d__v0.datasource b/packages/tinybird/datasources/mv__grpc_90d__v0.datasource new file mode 100644 index 00000000..e4b59871 --- /dev/null +++ b/packages/tinybird/datasources/mv__grpc_90d__v0.datasource @@ -0,0 +1,21 @@ +# Data Source created from Pipe 'aggregate__grpc_90d__v0' + +SCHEMA > + `time` DateTime, + `latency` Int64, + `servingStatus` Nullable(String), + `grpcCode` Nullable(Int16), + `service` Nullable(String), + `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__grpc_full_14d__v0.datasource b/packages/tinybird/datasources/mv__grpc_full_14d__v0.datasource new file mode 100644 index 00000000..b1e350e7 --- /dev/null +++ b/packages/tinybird/datasources/mv__grpc_full_14d__v0.datasource @@ -0,0 +1,25 @@ +# Data Source created from Pipe 'aggregate__grpc_full_14d__v0' + +SCHEMA > + `time` DateTime, + `monitorId` Int32, + `region` String, + `timestamp` Int64, + `cronTimestamp` Int64, + `timing` String, + `workspaceId` Int32, + `latency` Int64, + `servingStatus` Nullable(String), + `grpcCode` Nullable(Int16), + `service` Nullable(String), + `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__grpc_full_30d__v0.datasource b/packages/tinybird/datasources/mv__grpc_full_30d__v0.datasource new file mode 100644 index 00000000..2d5c0d86 --- /dev/null +++ b/packages/tinybird/datasources/mv__grpc_full_30d__v0.datasource @@ -0,0 +1,25 @@ +# Data Source created from Pipe 'aggregate__grpc_full_30d__v0' + +SCHEMA > + `time` DateTime, + `monitorId` Int32, + `region` String, + `timestamp` Int64, + `cronTimestamp` Int64, + `timing` String, + `workspaceId` Int32, + `latency` Int64, + `servingStatus` Nullable(String), + `grpcCode` Nullable(Int16), + `service` Nullable(String), + `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__grpc_status_45d__v0.datasource b/packages/tinybird/datasources/mv__grpc_status_45d__v0.datasource new file mode 100644 index 00000000..62eeec90 --- /dev/null +++ b/packages/tinybird/datasources/mv__grpc_status_45d__v0.datasource @@ -0,0 +1,14 @@ +# Data Source created from Pipe 'aggregate__grpc_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__grpc_status_7d__v0.datasource b/packages/tinybird/datasources/mv__grpc_status_7d__v0.datasource new file mode 100644 index 00000000..b64b3c6e --- /dev/null +++ b/packages/tinybird/datasources/mv__grpc_status_7d__v0.datasource @@ -0,0 +1,12 @@ +# Data Source created from Pipe 'aggregate__grpc_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__grpc_uptime_30d__v0.datasource b/packages/tinybird/datasources/mv__grpc_uptime_30d__v0.datasource new file mode 100644 index 00000000..ca2ee107 --- /dev/null +++ b/packages/tinybird/datasources/mv__grpc_uptime_30d__v0.datasource @@ -0,0 +1,13 @@ +# Data Source created from Pipe 'aggregate__grpc_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__grpc_uptime_7d__v0.datasource b/packages/tinybird/datasources/mv__grpc_uptime_7d__v0.datasource new file mode 100644 index 00000000..56f45b8d --- /dev/null +++ b/packages/tinybird/datasources/mv__grpc_uptime_7d__v0.datasource @@ -0,0 +1,13 @@ +# Data Source created from Pipe 'aggregate__grpc_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__grpc_uptime_90d__v0.datasource b/packages/tinybird/datasources/mv__grpc_uptime_90d__v0.datasource new file mode 100644 index 00000000..500fae25 --- /dev/null +++ b/packages/tinybird/datasources/mv__grpc_uptime_90d__v0.datasource @@ -0,0 +1,13 @@ +# Data Source created from Pipe 'aggregate__grpc_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__grpc_workspace_30d__v0.datasource b/packages/tinybird/datasources/mv__grpc_workspace_30d__v0.datasource new file mode 100644 index 00000000..99593ab0 --- /dev/null +++ b/packages/tinybird/datasources/mv__grpc_workspace_30d__v0.datasource @@ -0,0 +1,12 @@ +# Data Source created from Pipe 'aggregate__grpc_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__grpc_get_14d__v0.pipe b/packages/tinybird/endpoints/endpoint__grpc_get_14d__v0.pipe new file mode 100644 index 00000000..5514d081 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__grpc_get_14d__v0.pipe @@ -0,0 +1,15 @@ +TAGS "grpc" + +NODE endpoint +SQL > + + % + SELECT * + FROM mv__grpc_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__grpc_get_30d__v0.pipe b/packages/tinybird/endpoints/endpoint__grpc_get_30d__v0.pipe new file mode 100644 index 00000000..d1f3687f --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__grpc_get_30d__v0.pipe @@ -0,0 +1,15 @@ +TAGS "grpc" + +NODE endpoint +SQL > + +% + SELECT * + FROM mv__grpc_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__grpc_list_14d__v0.pipe b/packages/tinybird/endpoints/endpoint__grpc_list_14d__v0.pipe new file mode 100644 index 00000000..9925bb64 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__grpc_list_14d__v0.pipe @@ -0,0 +1,19 @@ +TAGS "grpc" + +NODE endpoint +SQL > + + % + SELECT * FROM mv__grpc_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__grpc_list_1d__v0.pipe b/packages/tinybird/endpoints/endpoint__grpc_list_1d__v0.pipe new file mode 100644 index 00000000..7ef93d74 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__grpc_list_1d__v0.pipe @@ -0,0 +1,19 @@ +TAGS "grpc" + +NODE endpoint +SQL > + + % + SELECT * FROM mv__grpc_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__grpc_list_7d__v0.pipe b/packages/tinybird/endpoints/endpoint__grpc_list_7d__v0.pipe new file mode 100644 index 00000000..7587e512 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__grpc_list_7d__v0.pipe @@ -0,0 +1,19 @@ +TAGS "grpc" + +NODE endpoint +SQL > + + % + SELECT * FROM mv__grpc_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__grpc_metrics_14d__v0.pipe b/packages/tinybird/endpoints/endpoint__grpc_metrics_14d__v0.pipe new file mode 100644 index 00000000..63d8204a --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__grpc_metrics_14d__v0.pipe @@ -0,0 +1,43 @@ +TAGS "grpc" + +NODE endpoint +SQL > + + % + SELECT + round(quantileIf(0.50)(latency, servingStatus IS NOT NULL)) as p50Latency, + round(quantileIf(0.75)(latency, servingStatus IS NOT NULL)) as p75Latency, + round(quantileIf(0.90)(latency, servingStatus IS NOT NULL)) as p90Latency, + round(quantileIf(0.95)(latency, servingStatus IS NOT NULL)) as p95Latency, + round(quantileIf(0.99)(latency, servingStatus IS NOT NULL)) 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__grpc_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(quantileIf(0.50)(latency, servingStatus IS NOT NULL)) AS p50Latency, + round(quantileIf(0.75)(latency, servingStatus IS NOT NULL)) AS p75Latency, + round(quantileIf(0.90)(latency, servingStatus IS NOT NULL)) AS p90Latency, + round(quantileIf(0.95)(latency, servingStatus IS NOT NULL)) AS p95Latency, + round(quantileIf(0.99)(latency, servingStatus IS NOT NULL)) 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__grpc_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__grpc_metrics_1d__v0.pipe b/packages/tinybird/endpoints/endpoint__grpc_metrics_1d__v0.pipe new file mode 100644 index 00000000..2abd6112 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__grpc_metrics_1d__v0.pipe @@ -0,0 +1,43 @@ +TAGS "grpc" + +NODE endpoint +SQL > + + % + SELECT + round(quantileIf(0.50)(latency, servingStatus IS NOT NULL)) as p50Latency, + round(quantileIf(0.75)(latency, servingStatus IS NOT NULL)) as p75Latency, + round(quantileIf(0.90)(latency, servingStatus IS NOT NULL)) as p90Latency, + round(quantileIf(0.95)(latency, servingStatus IS NOT NULL)) as p95Latency, + round(quantileIf(0.99)(latency, servingStatus IS NOT NULL)) 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__grpc_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(quantileIf(0.50)(latency, servingStatus IS NOT NULL)) AS p50Latency, + round(quantileIf(0.75)(latency, servingStatus IS NOT NULL)) AS p75Latency, + round(quantileIf(0.90)(latency, servingStatus IS NOT NULL)) AS p90Latency, + round(quantileIf(0.95)(latency, servingStatus IS NOT NULL)) AS p95Latency, + round(quantileIf(0.99)(latency, servingStatus IS NOT NULL)) 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__grpc_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__grpc_metrics_30d__v0.pipe b/packages/tinybird/endpoints/endpoint__grpc_metrics_30d__v0.pipe new file mode 100644 index 00000000..d6aac75e --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__grpc_metrics_30d__v0.pipe @@ -0,0 +1,42 @@ +TAGS "grpc" + +NODE endpoint +SQL > + + % + SELECT + round(quantileIf(0.50)(latency, servingStatus IS NOT NULL)) as p50Latency, + round(quantileIf(0.75)(latency, servingStatus IS NOT NULL)) as p75Latency, + round(quantileIf(0.90)(latency, servingStatus IS NOT NULL)) as p90Latency, + round(quantileIf(0.95)(latency, servingStatus IS NOT NULL)) as p95Latency, + round(quantileIf(0.99)(latency, servingStatus IS NOT NULL)) 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__grpc_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(quantileIf(0.50)(latency, servingStatus IS NOT NULL)) AS p50Latency, + round(quantileIf(0.75)(latency, servingStatus IS NOT NULL)) AS p75Latency, + round(quantileIf(0.90)(latency, servingStatus IS NOT NULL)) AS p90Latency, + round(quantileIf(0.95)(latency, servingStatus IS NOT NULL)) AS p95Latency, + round(quantileIf(0.99)(latency, servingStatus IS NOT NULL)) 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__grpc_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__grpc_metrics_7d__v0.pipe b/packages/tinybird/endpoints/endpoint__grpc_metrics_7d__v0.pipe new file mode 100644 index 00000000..7e4a97f8 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__grpc_metrics_7d__v0.pipe @@ -0,0 +1,43 @@ +TAGS "grpc" + +NODE endpoint +SQL > + + % + SELECT + round(quantileIf(0.50)(latency, servingStatus IS NOT NULL)) as p50Latency, + round(quantileIf(0.75)(latency, servingStatus IS NOT NULL)) as p75Latency, + round(quantileIf(0.90)(latency, servingStatus IS NOT NULL)) as p90Latency, + round(quantileIf(0.95)(latency, servingStatus IS NOT NULL)) as p95Latency, + round(quantileIf(0.99)(latency, servingStatus IS NOT NULL)) 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__grpc_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(quantileIf(0.50)(latency, servingStatus IS NOT NULL)) AS p50Latency, + round(quantileIf(0.75)(latency, servingStatus IS NOT NULL)) AS p75Latency, + round(quantileIf(0.90)(latency, servingStatus IS NOT NULL)) AS p90Latency, + round(quantileIf(0.95)(latency, servingStatus IS NOT NULL)) AS p95Latency, + round(quantileIf(0.99)(latency, servingStatus IS NOT NULL)) 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__grpc_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__grpc_metrics_90d__v0.pipe b/packages/tinybird/endpoints/endpoint__grpc_metrics_90d__v0.pipe new file mode 100644 index 00000000..00df748e --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__grpc_metrics_90d__v0.pipe @@ -0,0 +1,39 @@ +TAGS "grpc" + +NODE endpoint +SQL > + + % + SELECT + round(quantileIf(0.50)(latency, servingStatus IS NOT NULL)) as p50Latency, + round(quantileIf(0.75)(latency, servingStatus IS NOT NULL)) as p75Latency, + round(quantileIf(0.90)(latency, servingStatus IS NOT NULL)) as p90Latency, + round(quantileIf(0.95)(latency, servingStatus IS NOT NULL)) as p95Latency, + round(quantileIf(0.99)(latency, servingStatus IS NOT NULL)) 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__grpc_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__grpc_metrics_by_interval_14d__v0.pipe b/packages/tinybird/endpoints/endpoint__grpc_metrics_by_interval_14d__v0.pipe new file mode 100644 index 00000000..43464999 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__grpc_metrics_by_interval_14d__v0.pipe @@ -0,0 +1,29 @@ +VERSION 0 + +TAGS grpc + +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__grpc_14d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + GROUP BY h, region + ORDER BY h DESC + + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__grpc_metrics_by_interval_1d__v0.pipe b/packages/tinybird/endpoints/endpoint__grpc_metrics_by_interval_1d__v0.pipe new file mode 100644 index 00000000..89c3effd --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__grpc_metrics_by_interval_1d__v0.pipe @@ -0,0 +1,29 @@ +VERSION 0 + +TAGS grpc + +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__grpc_1d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + GROUP BY h, region + ORDER BY h DESC + + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__grpc_metrics_by_interval_30d__v0.pipe b/packages/tinybird/endpoints/endpoint__grpc_metrics_by_interval_30d__v0.pipe new file mode 100644 index 00000000..27e5e2d4 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__grpc_metrics_by_interval_30d__v0.pipe @@ -0,0 +1,27 @@ +VERSION 0 + +TAGS grpc + +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__grpc_30d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + GROUP BY h, region + ORDER BY h DESC + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__grpc_metrics_by_interval_7d__v0.pipe b/packages/tinybird/endpoints/endpoint__grpc_metrics_by_interval_7d__v0.pipe new file mode 100644 index 00000000..4ffabc67 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__grpc_metrics_by_interval_7d__v0.pipe @@ -0,0 +1,29 @@ +VERSION 0 + +TAGS grpc + +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__grpc_7d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + GROUP BY h, region + ORDER BY h DESC + + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__grpc_metrics_by_interval_90d__v0.pipe b/packages/tinybird/endpoints/endpoint__grpc_metrics_by_interval_90d__v0.pipe new file mode 100644 index 00000000..b1cf97df --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__grpc_metrics_by_interval_90d__v0.pipe @@ -0,0 +1,27 @@ +VERSION 0 + +TAGS grpc + +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__grpc_90d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + {% if regions %} AND region IN {{ Array(regions, 'String', 'ams,fra') }} {% end %} + GROUP BY h, region + ORDER BY h DESC + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__grpc_metrics_by_region_14d__v0.pipe b/packages/tinybird/endpoints/endpoint__grpc_metrics_by_region_14d__v0.pipe new file mode 100644 index 00000000..ee81be98 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__grpc_metrics_by_region_14d__v0.pipe @@ -0,0 +1,22 @@ +TAGS "grpc" + +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__grpc_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__grpc_metrics_by_region_1d__v0.pipe b/packages/tinybird/endpoints/endpoint__grpc_metrics_by_region_1d__v0.pipe new file mode 100644 index 00000000..a52af487 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__grpc_metrics_by_region_1d__v0.pipe @@ -0,0 +1,22 @@ +TAGS "grpc" + +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__grpc_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__grpc_metrics_by_region_7d__v0.pipe b/packages/tinybird/endpoints/endpoint__grpc_metrics_by_region_7d__v0.pipe new file mode 100644 index 00000000..7c7c4eda --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__grpc_metrics_by_region_7d__v0.pipe @@ -0,0 +1,22 @@ +TAGS "grpc" + +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__grpc_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__grpc_metrics_global_1d__v0.pipe b/packages/tinybird/endpoints/endpoint__grpc_metrics_global_1d__v0.pipe new file mode 100644 index 00000000..67a2873a --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__grpc_metrics_global_1d__v0.pipe @@ -0,0 +1,25 @@ +VERSION 0 + +TAGS "grpc" + +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__grpc_1d__v0 + WHERE monitorId IN {{ Array(monitorIds, 'String', '4433') }} + GROUP BY monitorId + + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__grpc_metrics_latency_1d__v0.pipe b/packages/tinybird/endpoints/endpoint__grpc_metrics_latency_1d__v0.pipe new file mode 100644 index 00000000..1a37d8ed --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__grpc_metrics_latency_1d__v0.pipe @@ -0,0 +1,25 @@ +TAGS "grpc" + +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__grpc_1d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + AND servingStatus IS NOT NULL + GROUP BY h + ORDER BY h DESC + + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__grpc_metrics_latency_1d_multi__v0.pipe b/packages/tinybird/endpoints/endpoint__grpc_metrics_latency_1d_multi__v0.pipe new file mode 100644 index 00000000..fa2f6bbd --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__grpc_metrics_latency_1d_multi__v0.pipe @@ -0,0 +1,26 @@ +TAGS "grpc" + +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__grpc_1d__v0 + WHERE + monitorId IN {{ Array(monitorIds, 'String', '4433') }} + AND servingStatus IS NOT NULL + GROUP BY h, monitorId + ORDER BY h DESC + + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__grpc_metrics_latency_30d__v0.pipe b/packages/tinybird/endpoints/endpoint__grpc_metrics_latency_30d__v0.pipe new file mode 100644 index 00000000..aa158359 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__grpc_metrics_latency_30d__v0.pipe @@ -0,0 +1,26 @@ +TAGS "grpc" + +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__grpc_30d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + AND servingStatus IS NOT NULL + {% if fromDate %} AND time >= parseDateTimeBestEffortOrNull({{ String(fromDate) }}) {% end %} + {% if toDate %} AND time <= parseDateTimeBestEffortOrNull({{ String(toDate) }}) {% end %} + GROUP BY h + ORDER BY h DESC + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__grpc_metrics_latency_7d__v0.pipe b/packages/tinybird/endpoints/endpoint__grpc_metrics_latency_7d__v0.pipe new file mode 100644 index 00000000..35a051de --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__grpc_metrics_latency_7d__v0.pipe @@ -0,0 +1,25 @@ +TAGS "grpc" + +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__grpc_7d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + AND servingStatus IS NOT NULL + GROUP BY h + ORDER BY h DESC + + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__grpc_metrics_latency_90d__v0.pipe b/packages/tinybird/endpoints/endpoint__grpc_metrics_latency_90d__v0.pipe new file mode 100644 index 00000000..59015abe --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__grpc_metrics_latency_90d__v0.pipe @@ -0,0 +1,26 @@ +TAGS "grpc" + +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__grpc_90d__v0 + WHERE + monitorId = {{ String(monitorId, '4433', required=True) }} + AND servingStatus IS NOT NULL + {% if fromDate %} AND time >= parseDateTimeBestEffortOrNull({{ String(fromDate) }}) {% end %} + {% if toDate %} AND time <= parseDateTimeBestEffortOrNull({{ String(toDate) }}) {% end %} + GROUP BY h + ORDER BY h DESC + +TYPE ENDPOINT diff --git a/packages/tinybird/endpoints/endpoint__grpc_status_45d__v0.pipe b/packages/tinybird/endpoints/endpoint__grpc_status_45d__v0.pipe new file mode 100644 index 00000000..543cc6d4 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__grpc_status_45d__v0.pipe @@ -0,0 +1,20 @@ +TAGS "grpc" + +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__grpc_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__grpc_status_7d__v0.pipe b/packages/tinybird/endpoints/endpoint__grpc_status_7d__v0.pipe new file mode 100644 index 00000000..2a6c04ac --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__grpc_status_7d__v0.pipe @@ -0,0 +1,20 @@ +TAGS "grpc" + +NODE endpoint +SQL > + +% + SELECT time as day, countMerge(count) as count, countMerge(ok) as ok + FROM mv__grpc_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__grpc_uptime_30d__v0.pipe b/packages/tinybird/endpoints/endpoint__grpc_uptime_30d__v0.pipe new file mode 100644 index 00000000..b1a2bf65 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__grpc_uptime_30d__v0.pipe @@ -0,0 +1,22 @@ +TAGS "grpc" + +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__grpc_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__grpc_uptime_7d__v0.pipe b/packages/tinybird/endpoints/endpoint__grpc_uptime_7d__v0.pipe new file mode 100644 index 00000000..ee3d5507 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__grpc_uptime_7d__v0.pipe @@ -0,0 +1,22 @@ +TAGS "grpc" + +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__grpc_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__grpc_uptime_90d__v0.pipe b/packages/tinybird/endpoints/endpoint__grpc_uptime_90d__v0.pipe new file mode 100644 index 00000000..3663451f --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__grpc_uptime_90d__v0.pipe @@ -0,0 +1,21 @@ +TAGS "grpc" + +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__grpc_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__grpc_workspace_30d__v0.pipe b/packages/tinybird/endpoints/endpoint__grpc_workspace_30d__v0.pipe new file mode 100644 index 00000000..f1a63a2d --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__grpc_workspace_30d__v0.pipe @@ -0,0 +1,16 @@ +TAGS "grpc" + +NODE endpoint +SQL > + + % + SELECT + time as day, + countMerge(count_state) as count + FROM mv__grpc_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__grpc_full_30d__v0.pipe b/packages/tinybird/materializations/aggregate__grpc_full_30d__v0.pipe new file mode 100644 index 00000000..5add6c93 --- /dev/null +++ b/packages/tinybird/materializations/aggregate__grpc_full_30d__v0.pipe @@ -0,0 +1,31 @@ +DESCRIPTION > + Stores all the data from the grpc_response table for the last 30 days, mainly used for accessing the data details. + + +TAGS "grpc, full" + +NODE aggregate +SQL > + + SELECT + toDateTime(fromUnixTimestamp64Milli(cronTimestamp)) AS time, + monitorId, + region, + timestamp, + cronTimestamp, + timing, + workspaceId, + latency, + servingStatus, + grpcCode, + service, + errorMessage, + error, + trigger, + uri, + id, + requestStatus + FROM grpc_response__v0 + +TYPE materialized +DATASOURCE mv__grpc_full_30d__v0 diff --git a/packages/tinybird/materializations/aggregate__grpc_status_7d__v0.pipe b/packages/tinybird/materializations/aggregate__grpc_status_7d__v0.pipe new file mode 100644 index 00000000..380aae7f --- /dev/null +++ b/packages/tinybird/materializations/aggregate__grpc_status_7d__v0.pipe @@ -0,0 +1,17 @@ +TAGS "grpc" + +NODE aggregate +SQL > + + SELECT + toStartOfDay(toTimeZone(fromUnixTimestamp64Milli(cronTimestamp), 'UTC')) AS time, + monitorId, + countState() AS count, + countState(if(error = 0, 1, NULL)) AS ok + FROM grpc_response__v0 + GROUP BY + time, + monitorId + +TYPE materialized +DATASOURCE mv__grpc_status_7d__v0 diff --git a/packages/tinybird/pipes/aggregate__grpc_14d__v0.pipe b/packages/tinybird/pipes/aggregate__grpc_14d__v0.pipe new file mode 100644 index 00000000..03065f20 --- /dev/null +++ b/packages/tinybird/pipes/aggregate__grpc_14d__v0.pipe @@ -0,0 +1,23 @@ +TAGS "grpc" + +NODE aggregate +SQL > + + SELECT + toDateTime(fromUnixTimestamp64Milli(cronTimestamp)) AS time, + latency, + servingStatus, + grpcCode, + service, + error, + region, + trigger, + timestamp, + cronTimestamp, + monitorId, + requestStatus, + id + FROM grpc_response__v0 + +TYPE materialized +DATASOURCE mv__grpc_14d__v0 diff --git a/packages/tinybird/pipes/aggregate__grpc_1d__v0.pipe b/packages/tinybird/pipes/aggregate__grpc_1d__v0.pipe new file mode 100644 index 00000000..d592fa9b --- /dev/null +++ b/packages/tinybird/pipes/aggregate__grpc_1d__v0.pipe @@ -0,0 +1,23 @@ +TAGS "grpc" + +NODE aggregate +SQL > + + SELECT + toDateTime(fromUnixTimestamp64Milli(cronTimestamp)) AS time, + latency, + servingStatus, + grpcCode, + service, + error, + region, + trigger, + timestamp, + cronTimestamp, + monitorId, + requestStatus, + id + FROM grpc_response__v0 + +TYPE materialized +DATASOURCE mv__grpc_1d__v0 diff --git a/packages/tinybird/pipes/aggregate__grpc_30d__v0.pipe b/packages/tinybird/pipes/aggregate__grpc_30d__v0.pipe new file mode 100644 index 00000000..ac3feb88 --- /dev/null +++ b/packages/tinybird/pipes/aggregate__grpc_30d__v0.pipe @@ -0,0 +1,23 @@ +TAGS "grpc" + +NODE aggregate +SQL > + + SELECT + toDateTime(fromUnixTimestamp64Milli(cronTimestamp)) AS time, + latency, + servingStatus, + grpcCode, + service, + error, + region, + trigger, + timestamp, + cronTimestamp, + monitorId, + requestStatus, + id + FROM grpc_response__v0 + +TYPE materialized +DATASOURCE mv__grpc_30d__v0 diff --git a/packages/tinybird/pipes/aggregate__grpc_7d__v0.pipe b/packages/tinybird/pipes/aggregate__grpc_7d__v0.pipe new file mode 100644 index 00000000..e376511c --- /dev/null +++ b/packages/tinybird/pipes/aggregate__grpc_7d__v0.pipe @@ -0,0 +1,23 @@ +TAGS "grpc" + +NODE aggregate +SQL > + + SELECT + toDateTime(fromUnixTimestamp64Milli(cronTimestamp)) AS time, + latency, + servingStatus, + grpcCode, + service, + error, + region, + trigger, + timestamp, + cronTimestamp, + monitorId, + requestStatus, + id + FROM grpc_response__v0 + +TYPE materialized +DATASOURCE mv__grpc_7d__v0 diff --git a/packages/tinybird/pipes/aggregate__grpc_90d__v0.pipe b/packages/tinybird/pipes/aggregate__grpc_90d__v0.pipe new file mode 100644 index 00000000..b661e6d7 --- /dev/null +++ b/packages/tinybird/pipes/aggregate__grpc_90d__v0.pipe @@ -0,0 +1,23 @@ +TAGS "grpc" + +NODE aggregate +SQL > + + SELECT + toDateTime(fromUnixTimestamp64Milli(cronTimestamp)) AS time, + latency, + servingStatus, + grpcCode, + service, + error, + region, + trigger, + timestamp, + cronTimestamp, + monitorId, + requestStatus, + id + FROM grpc_response__v0 + +TYPE materialized +DATASOURCE mv__grpc_90d__v0 diff --git a/packages/tinybird/pipes/aggregate__grpc_full_14d__v0.pipe b/packages/tinybird/pipes/aggregate__grpc_full_14d__v0.pipe new file mode 100644 index 00000000..a6eda35b --- /dev/null +++ b/packages/tinybird/pipes/aggregate__grpc_full_14d__v0.pipe @@ -0,0 +1,31 @@ +DESCRIPTION > + Stores all the data from the grpc_response table, mainly used for accessing the data details. + + +TAGS "grpc, full" + +NODE aggregate +SQL > + + SELECT + toDateTime(fromUnixTimestamp64Milli(cronTimestamp)) AS time, + monitorId, + region, + timestamp, + cronTimestamp, + timing, + workspaceId, + latency, + servingStatus, + grpcCode, + service, + errorMessage, + error, + trigger, + uri, + id, + requestStatus + FROM grpc_response__v0 + +TYPE materialized +DATASOURCE mv__grpc_full_14d__v0 diff --git a/packages/tinybird/pipes/aggregate__grpc_status_45d__v0.pipe b/packages/tinybird/pipes/aggregate__grpc_status_45d__v0.pipe new file mode 100644 index 00000000..4d62f91e --- /dev/null +++ b/packages/tinybird/pipes/aggregate__grpc_status_45d__v0.pipe @@ -0,0 +1,19 @@ +TAGS "grpc, 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 grpc_response__v0 + GROUP BY + time, + monitorId + +TYPE materialized +DATASOURCE mv__grpc_status_45d__v0 diff --git a/packages/tinybird/pipes/aggregate__grpc_uptime_30d__v0.pipe b/packages/tinybird/pipes/aggregate__grpc_uptime_30d__v0.pipe new file mode 100644 index 00000000..04f664eb --- /dev/null +++ b/packages/tinybird/pipes/aggregate__grpc_uptime_30d__v0.pipe @@ -0,0 +1,13 @@ +NODE aggregate +SQL > + + SELECT + toDateTime(fromUnixTimestamp64Milli(cronTimestamp)) AS time, + region, + requestStatus, + monitorId, + workspaceId + FROM grpc_response__v0 + +TYPE materialized +DATASOURCE mv__grpc_uptime_30d__v0 diff --git a/packages/tinybird/pipes/aggregate__grpc_uptime_7d__v0.pipe b/packages/tinybird/pipes/aggregate__grpc_uptime_7d__v0.pipe new file mode 100644 index 00000000..e8eebbe5 --- /dev/null +++ b/packages/tinybird/pipes/aggregate__grpc_uptime_7d__v0.pipe @@ -0,0 +1,13 @@ +NODE aggregate +SQL > + + SELECT + toDateTime(fromUnixTimestamp64Milli(cronTimestamp)) AS time, + region, + requestStatus, + monitorId, + workspaceId + FROM grpc_response__v0 + +TYPE materialized +DATASOURCE mv__grpc_uptime_7d__v0 diff --git a/packages/tinybird/pipes/aggregate__grpc_uptime_90d__v0.pipe b/packages/tinybird/pipes/aggregate__grpc_uptime_90d__v0.pipe new file mode 100644 index 00000000..be85cb91 --- /dev/null +++ b/packages/tinybird/pipes/aggregate__grpc_uptime_90d__v0.pipe @@ -0,0 +1,13 @@ +NODE aggregate +SQL > + + SELECT + toDateTime(fromUnixTimestamp64Milli(cronTimestamp)) AS time, + region, + requestStatus, + monitorId, + workspaceId + FROM grpc_response__v0 + +TYPE materialized +DATASOURCE mv__grpc_uptime_90d__v0 diff --git a/packages/tinybird/pipes/aggregate__grpc_workspace_30d__v0.pipe b/packages/tinybird/pipes/aggregate__grpc_workspace_30d__v0.pipe new file mode 100644 index 00000000..e629d712 --- /dev/null +++ b/packages/tinybird/pipes/aggregate__grpc_workspace_30d__v0.pipe @@ -0,0 +1,18 @@ +TAGS "grpc" + +NODE aggregate +SQL > + + SELECT + toStartOfDay(toTimeZone(fromUnixTimestamp64Milli(cronTimestamp), 'UTC')) AS time, + workspaceId, + ifNull(trigger, 'cron') AS trigger, + countState() AS count_state + FROM grpc_response__v0 + GROUP BY + time, + workspaceId, + trigger + +TYPE materialized +DATASOURCE mv__grpc_workspace_30d__v0 diff --git a/packages/tinybird/src/client.ts b/packages/tinybird/src/client.ts index a6f43412..d1487969 100644 --- a/packages/tinybird/src/client.ts +++ b/packages/tinybird/src/client.ts @@ -91,6 +91,75 @@ const icmpUptimeShape = z.object({ error: z.int(), }); +const grpcMetricsShape = 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 grpcMetricsByIntervalParameters = z.object({ + regions: z.array(z.enum(monitorRegions).or(z.string())).optional(), + interval: z.int().optional(), + monitorId: z.string(), +}); + +const grpcMetricsByIntervalShape = 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 grpcMetricsByRegionParameters = z.object({ + monitorId: z.string(), + regions: z.array(z.enum(monitorRegions).or(z.string())).optional(), +}); + +const grpcMetricsByRegionShape = 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 grpcMetricsLatencyShape = z.object({ + timestamp: z.int(), + p50Latency: z.int(), + p75Latency: z.int(), + p90Latency: z.int(), + p95Latency: z.int(), + p99Latency: z.int(), +}); + +const grpcUptimeParameters = 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 grpcUptimeShape = z.object({ + interval: z.coerce.date(), + success: z.int(), + degraded: z.int(), + error: z.int(), +}); + export const TINYBIRD_DEFAULT_URL = "https://api.tinybird.co"; /** @@ -1698,6 +1767,452 @@ export class OSTinybird { }); } + public get grpcListDaily() { + return this.tb.buildPipe({ + pipe: "endpoint__grpc_list_1d__v0", + parameters: z.object({ + monitorId: z.string(), + fromDate: z.int().optional(), + toDate: z.int().optional(), + }), + data: z.object({ + type: z.literal("grpc").prefault("grpc"), + id: z.string().nullable(), + latency: z.int(), + servingStatus: z.string().nullable(), + grpcCode: z.int().nullable(), + service: z.string().nullable(), + 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 grpcListWeekly() { + return this.tb.buildPipe({ + pipe: "endpoint__grpc_list_7d__v0", + parameters: z.object({ + monitorId: z.string(), + fromDate: z.int().optional(), + toDate: z.int().optional(), + }), + data: z.object({ + type: z.literal("grpc").prefault("grpc"), + id: z.string().nullable(), + latency: z.int(), + servingStatus: z.string().nullable(), + grpcCode: z.int().nullable(), + service: z.string().nullable(), + 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 grpcListBiweekly() { + return this.tb.buildPipe({ + pipe: "endpoint__grpc_list_14d__v0", + parameters: z.object({ + monitorId: z.string(), + fromDate: z.int().optional(), + toDate: z.int().optional(), + }), + data: z.object({ + type: z.literal("grpc").prefault("grpc"), + id: z.string().nullable(), + latency: z.int(), + servingStatus: z.string().nullable(), + grpcCode: z.int().nullable(), + service: z.string().nullable(), + 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 grpcGetBiweekly() { + return this.tb.buildPipe({ + pipe: "endpoint__grpc_get_14d__v0", + parameters: z.object({ + id: z.string().nullable(), + monitorId: z.string(), + }), + data: z.object({ + type: z.literal("grpc").prefault("grpc"), + id: z.string().nullable(), + uri: z.string(), + latency: z.int(), + servingStatus: z.string().nullable(), + grpcCode: z.int().nullable(), + service: z.string().nullable(), + 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 grpcMetricsDaily() { + return this.tb.buildPipe({ + pipe: "endpoint__grpc_metrics_1d__v0", + parameters: z.object({ + interval: z.int().optional(), + regions: z.array(z.enum(monitorRegions).or(z.string())).optional(), + monitorId: z.string(), + }), + data: grpcMetricsShape, + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get grpcMetricsWeekly() { + return this.tb.buildPipe({ + pipe: "endpoint__grpc_metrics_7d__v0", + parameters: z.object({ + interval: z.int().optional(), + regions: z.array(z.enum(monitorRegions).or(z.string())).optional(), + monitorId: z.string(), + }), + data: grpcMetricsShape, + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get grpcMetricsBiweekly() { + return this.tb.buildPipe({ + pipe: "endpoint__grpc_metrics_14d__v0", + parameters: z.object({ + interval: z.int().optional(), + regions: z.array(z.enum(monitorRegions).or(z.string())).optional(), + monitorId: z.string(), + }), + data: grpcMetricsShape, + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get grpcMetrics30d() { + return this.tb.buildPipe({ + pipe: "endpoint__grpc_metrics_30d__v0", + parameters: z.object({ + interval: z.int().optional(), + regions: z.array(z.enum(monitorRegions).or(z.string())).optional(), + monitorId: z.string(), + }), + data: grpcMetricsShape, + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get grpcMetrics90d() { + return this.tb.buildPipe({ + pipe: "endpoint__grpc_metrics_90d__v0", + parameters: z.object({ + interval: z.int().optional(), + regions: z.array(z.enum(monitorRegions).or(z.string())).optional(), + monitorId: z.string(), + }), + data: grpcMetricsShape, + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get grpcMetricsByIntervalDaily() { + return this.tb.buildPipe({ + pipe: "endpoint__grpc_metrics_by_interval_1d__v0", + parameters: grpcMetricsByIntervalParameters, + data: grpcMetricsByIntervalShape, + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get grpcMetricsByIntervalWeekly() { + return this.tb.buildPipe({ + pipe: "endpoint__grpc_metrics_by_interval_7d__v0", + parameters: grpcMetricsByIntervalParameters, + data: grpcMetricsByIntervalShape, + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get grpcMetricsByIntervalBiweekly() { + return this.tb.buildPipe({ + pipe: "endpoint__grpc_metrics_by_interval_14d__v0", + parameters: grpcMetricsByIntervalParameters, + data: grpcMetricsByIntervalShape, + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get grpcMetricsByInterval30d() { + return this.tb.buildPipe({ + pipe: "endpoint__grpc_metrics_by_interval_30d__v0", + parameters: grpcMetricsByIntervalParameters, + data: grpcMetricsByIntervalShape, + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get grpcMetricsByInterval90d() { + return this.tb.buildPipe({ + pipe: "endpoint__grpc_metrics_by_interval_90d__v0", + parameters: grpcMetricsByIntervalParameters, + data: grpcMetricsByIntervalShape, + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get grpcMetricsLatency1d() { + return this.tb.buildPipe({ + pipe: "endpoint__grpc_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: grpcMetricsLatencyShape, + }); + } + + public get grpcMetricsLatency7d() { + return this.tb.buildPipe({ + pipe: "endpoint__grpc_metrics_latency_7d__v0", + parameters: z.object({ + monitorId: z.string(), + fromDate: z.string().optional(), + toDate: z.string().optional(), + }), + data: grpcMetricsLatencyShape, + }); + } + + public get grpcMetricsLatency30d() { + return this.tb.buildPipe({ + pipe: "endpoint__grpc_metrics_latency_30d__v0", + parameters: z.object({ + monitorId: z.string(), + fromDate: z.string().optional(), + toDate: z.string().optional(), + }), + data: grpcMetricsLatencyShape, + }); + } + + public get grpcMetricsLatency90d() { + return this.tb.buildPipe({ + pipe: "endpoint__grpc_metrics_latency_90d__v0", + parameters: z.object({ + monitorId: z.string(), + fromDate: z.string().optional(), + toDate: z.string().optional(), + }), + data: grpcMetricsLatencyShape, + }); + } + + public get grpcMetricsLatency1dMulti() { + return this.tb.buildPipe({ + pipe: "endpoint__grpc_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 grpcStatus45d() { + return this.tb.buildPipe({ + pipe: "endpoint__grpc_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 grpcUptimeWeekly() { + return this.tb.buildPipe({ + pipe: "endpoint__grpc_uptime_7d__v0", + parameters: grpcUptimeParameters, + data: grpcUptimeShape, + }); + } + + public get grpcUptime30d() { + return this.tb.buildPipe({ + pipe: "endpoint__grpc_uptime_30d__v0", + parameters: grpcUptimeParameters, + data: grpcUptimeShape, + }); + } + + public get grpcUptime90d() { + return this.tb.buildPipe({ + pipe: "endpoint__grpc_uptime_90d__v0", + parameters: grpcUptimeParameters, + data: grpcUptimeShape, + }); + } + + public get grpcGlobalMetricsDaily() { + return this.tb.buildPipe({ + pipe: "endpoint__grpc_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 grpcWorkspace30d() { + return this.tb.buildPipe({ + pipe: "endpoint__grpc_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 grpcGetMonthly() { + return this.tb.buildPipe({ + pipe: "endpoint__grpc_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("grpc").prefault("grpc"), + id: z.string().nullable(), + uri: z.string(), + latency: z.int(), + servingStatus: z.string().nullable(), + grpcCode: z.int().nullable(), + service: z.string().nullable(), + 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 grpcStatusWeekly() { + return this.tb.buildPipe({ + pipe: "endpoint__grpc_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 grpcMetricsByRegionDaily() { + return this.tb.buildPipe({ + pipe: "endpoint__grpc_metrics_by_region_1d__v0", + parameters: grpcMetricsByRegionParameters, + data: grpcMetricsByRegionShape, + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get grpcMetricsByRegionWeekly() { + return this.tb.buildPipe({ + pipe: "endpoint__grpc_metrics_by_region_7d__v0", + parameters: grpcMetricsByRegionParameters, + data: grpcMetricsByRegionShape, + opts: { next: { revalidate: REVALIDATE } }, + }); + } + + public get grpcMetricsByRegionBiweekly() { + return this.tb.buildPipe({ + pipe: "endpoint__grpc_metrics_by_region_14d__v0", + parameters: grpcMetricsByRegionParameters, + data: grpcMetricsByRegionShape, + 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/tinybird/src/schema.ts b/packages/tinybird/src/schema.ts index aae67c4e..248459f5 100644 --- a/packages/tinybird/src/schema.ts +++ b/packages/tinybird/src/schema.ts @@ -1,6 +1,14 @@ import { z } from "zod"; -export const jobTypes = ["http", "tcp", "icmp", "udp", "dns", "ssl"] as const; +export const jobTypes = [ + "http", + "tcp", + "icmp", + "grpc", + "udp", + "dns", + "ssl", +] as const; export const jobTypeEnum = z.enum(jobTypes); export type JobType = z.infer; diff --git a/packages/utils/src/constants.ts b/packages/utils/src/constants.ts index 0ffb264d..4100888a 100644 --- a/packages/utils/src/constants.ts +++ b/packages/utils/src/constants.ts @@ -16,6 +16,7 @@ export const MONITOR_JOB_TYPES = [ "http", "tcp", "icmp", + "grpc", "udp", "dns", "ssl", diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index ec9a4400..4fef0aeb 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -7,7 +7,10 @@ export { DNSPayloadSchema, type DNSPayload, icmpPayloadSchema, + grpcPayloadSchema, + GRPC_TLS_MODES, type IcmpPayload, + type GrpcPayload, } from "./payloads"; export { MONITOR_METHODS, diff --git a/packages/utils/src/payloads.ts b/packages/utils/src/payloads.ts index e213f435..7bf73494 100644 --- a/packages/utils/src/payloads.ts +++ b/packages/utils/src/payloads.ts @@ -89,3 +89,28 @@ export const icmpPayloadSchema = z.object({ }); export type IcmpPayload = z.infer; + +export const GRPC_TLS_MODES = ["plaintext", "tls", "tls_insecure"] as const; + +export const grpcPayloadSchema = z.object({ + status: z.enum(MONITOR_STATUSES), + workspaceId: z.string(), + uri: z.string(), + monitorId: z.string(), + service: z.string().optional(), + tls: z.enum(GRPC_TLS_MODES).prefault("tls"), + headers: z.record(z.string(), z.string()).optional(), + 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 GrpcPayload = z.infer;