From 5e8042e0b8f94ef84b270ad94ff2c3c59fde49a5 Mon Sep 17 00:00:00 2001 From: Thibault Le Ouay Ducasse Date: Wed, 26 Aug 2026 21:30:27 +0200 Subject: [PATCH] feat: gRPC health check monitors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a fifth monitor type that calls grpc.health.v1.Health/Check and reports the serving status it answers with. The probing tier and the TypeScript tier move together, per apps/checker/AGENTS.md: the Go checker, the private-location agent, the proto contracts and the product surface are all in this change. Notable decisions: - Health protocol only. No reflection, no arbitrary unary calls. - Two additive monitor columns, grpc_service and grpc_tls, rather than encoding the configuration into the URI — a URI parser would have had to exist in Go twice and TypeScript four times. - Only transport failures are retried. A server that answers NOT_SERVING, SERVICE_UNKNOWN or UNIMPLEMENTED is recorded once, the way an HTTP assertion failure already is, so alerts are not delayed by the backoff window. - GRPCResult carries an explicit Completed flag. It cannot be derived from the error flag, because NOT_SERVING is a failed check that completed perfectly well. Completion drives retry, OTel branching, what latency and timing hold, and the latency-quantile filter. - Timings reuse HTTP's ten-field shape verbatim, so calculateTiming and the dashboard waterfall work with no new code. - UNIMPLEMENTED gets its own message: the server is reachable, it just never registered a health service, and reading that as "down" sends people hunting the wrong problem. - TLS failures return a fixed string. The raw error quotes the peer's certificate subject and chain, which must not reach the caller. The three defects the ICMP pipes shipped with are fixed in the gRPC copies rather than inherited: no SELECT * in a materialization, the regions parameter is actually used, and the 30d/90d latency endpoints honour the forwarded window. Latency quantiles additionally exclude checks whose RPC never completed, so a refused connection no longer drags the median toward zero. While adding the fifth ingest validator, the monitor-id, latency and timestamp checks the four existing ones duplicated were extracted into one helper. mapMonitors gained a logging default so an unsupported job type can no longer vanish without a row, a log or an error. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01YDMfxo4TidoFQt93wcnsYm --- apps/checker/checker/grpc.go | 349 ++ apps/checker/checker/grpc_internal_test.go | 109 + apps/checker/checker/grpc_test.go | 248 + apps/checker/checker/testserver_test.go | 123 + apps/checker/cmd/server/main.go | 7 +- apps/checker/go.mod | 2 +- apps/checker/handlers/grpc.go | 395 ++ apps/checker/handlers/grpc_test.go | 181 + apps/checker/handlers/otel_wiring_test.go | 30 + apps/checker/pkg/job/grpc_job.go | 194 + apps/checker/pkg/job/grpc_job_test.go | 223 + apps/checker/pkg/job/job.go | 1 + apps/checker/pkg/otel/otel.go | 64 + apps/checker/pkg/otel/otel_test.go | 99 + apps/checker/pkg/scheduler/scheduler.go | 54 + apps/checker/pkg/scheduler/scheduler_test.go | 99 + .../private_location/v1/grpc_monitor.pb.go | 213 + .../v1/private_location.connect.go | 29 + .../v1/private_location.pb.go | 276 +- apps/checker/request/request.go | 20 + .../(dashboard)/monitors/[id]/logs/client.tsx | 14 +- .../(dashboard)/monitors/[id]/nav-actions.tsx | 25 +- .../monitors/[id]/overview/client.tsx | 13 +- .../app/(dashboard)/monitors/[id]/sidebar.tsx | 2 +- .../app/(dashboard)/monitors/create/page.tsx | 2 + .../components/chart/chart-area-latency.tsx | 2 +- .../chart/chart-bar-uptime-light.tsx | 2 +- .../src/components/chart/chart-bar-uptime.tsx | 2 +- .../response-logs/data-table-basics.tsx | 755 +-- .../response-logs/data-table-sheet-test.tsx | 25 +- .../components/forms/monitor/form-general.tsx | 166 +- .../src/components/forms/monitor/update.tsx | 6 +- .../metric/global-uptime/section.tsx | 2 +- apps/dashboard/src/data/monitors.client.ts | 6 + apps/private-location/README.md | 17 + .../internal/database/models.go | 3 + .../internal/server/db_testdata | 2 +- .../internal/server/ingest_grpc.go | 89 + .../internal/server/monitors.go | 45 +- .../internal/server/validation.go | 68 +- .../internal/server/validation_test.go | 315 +- .../internal/tinybird/client.go | 1 + .../private_location/v1/grpc_monitor.pb.go | 213 + .../v1/private_location.connect.go | 29 + .../v1/private_location.pb.go | 276 +- apps/server/src/libs/checker/utils.test.ts | 37 +- apps/server/src/libs/checker/utils.ts | 27 +- .../monitor/__tests__/monitor.test.ts | 230 + .../handlers/monitor/converters/defaults.ts | 1 + .../rpc/handlers/monitor/converters/enums.ts | 27 + .../rpc/handlers/monitor/converters/index.ts | 3 + .../handlers/monitor/converters/monitors.ts | 31 + .../src/routes/rpc/handlers/monitor/index.ts | 130 +- .../rpc/handlers/monitor/validators.test.ts | 50 + .../routes/rpc/handlers/monitor/validators.ts | 47 +- .../interceptors/__tests__/tracking.test.ts | 14 + .../src/routes/rpc/interceptors/tracking.ts | 12 +- .../src/routes/rpc/interceptors/validation.ts | 1 + apps/server/static/openapi.yaml | 259 +- apps/web/src/content/docs.config.ts | 1 + .../pages/changelog/grpc-monitoring.mdx | 16 + .../pages/docs/reference/grpc-monitor.mdx | 141 + .../content/pages/docs/reference/overview.mdx | 1 + apps/workflows/src/cron/checker.ts | 25 + apps/workflows/src/cron/uptime-freeze.ts | 1 + packages/api/src/router/checker.ts | 139 + packages/api/src/router/monitor.ts | 23 +- packages/api/src/router/statusPage.ts | 30 +- packages/api/src/router/tinybird/index.ts | 37 +- packages/db/drizzle/0084_flaky_thundra.sql | 2 + packages/db/drizzle/meta/0084_snapshot.json | 4976 +++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/src/schema/monitors/constants.ts | 2 + packages/db/src/schema/monitors/monitor.ts | 11 +- packages/db/src/schema/monitors/validation.ts | 9 +- .../openstatus/monitor/v1/grpc_monitor.proto | 116 + .../api/openstatus/monitor/v1/service.proto | 43 +- packages/proto/gen/openapi.yaml | 259 +- .../openstatus/monitor/v1/grpc_monitor_pb.ts | 195 + .../gen/ts/openstatus/monitor/v1/index.ts | 1 + .../ts/openstatus/monitor/v1/service_pb.ts | 194 +- .../private_location/v1/grpc_monitor.proto | 24 + .../v1/private_location.proto | 23 + .../__tests__/get-history.test.ts | 4 +- .../src/frozen-uptime/__tests__/run.test.ts | 1 + .../services/src/frozen-uptime/get-history.ts | 1 + packages/services/src/frozen-uptime/run.ts | 5 +- packages/services/src/import/phase-writers.ts | 1 + packages/services/src/monitor/create.ts | 2 + .../services/src/monitor/get-daily-summary.ts | 12 +- .../src/monitor/get-monitor-summary.ts | 10 +- packages/services/src/monitor/index.ts | 1 + packages/services/src/monitor/schemas.ts | 7 +- packages/services/src/monitor/update.ts | 2 + .../check_grpc_response__v0.datasource | 20 + .../datasources/grpc_response__v0.datasource | 21 + .../datasources/mv__grpc_14d__v0.datasource | 21 + .../datasources/mv__grpc_1d__v0.datasource | 21 + .../datasources/mv__grpc_30d__v0.datasource | 21 + .../datasources/mv__grpc_7d__v0.datasource | 21 + .../datasources/mv__grpc_90d__v0.datasource | 21 + .../mv__grpc_full_14d__v0.datasource | 25 + .../mv__grpc_full_30d__v0.datasource | 25 + .../mv__grpc_status_45d__v0.datasource | 14 + .../mv__grpc_status_7d__v0.datasource | 12 + .../mv__grpc_uptime_30d__v0.datasource | 13 + .../mv__grpc_uptime_7d__v0.datasource | 13 + .../mv__grpc_uptime_90d__v0.datasource | 13 + .../mv__grpc_workspace_30d__v0.datasource | 12 + .../endpoints/endpoint__grpc_get_14d__v0.pipe | 15 + .../endpoints/endpoint__grpc_get_30d__v0.pipe | 15 + .../endpoint__grpc_list_14d__v0.pipe | 19 + .../endpoints/endpoint__grpc_list_1d__v0.pipe | 19 + .../endpoints/endpoint__grpc_list_7d__v0.pipe | 19 + .../endpoint__grpc_metrics_14d__v0.pipe | 43 + .../endpoint__grpc_metrics_1d__v0.pipe | 43 + .../endpoint__grpc_metrics_30d__v0.pipe | 42 + .../endpoint__grpc_metrics_7d__v0.pipe | 43 + .../endpoint__grpc_metrics_90d__v0.pipe | 39 + ...int__grpc_metrics_by_interval_14d__v0.pipe | 29 + ...oint__grpc_metrics_by_interval_1d__v0.pipe | 29 + ...int__grpc_metrics_by_interval_30d__v0.pipe | 27 + ...oint__grpc_metrics_by_interval_7d__v0.pipe | 29 + ...int__grpc_metrics_by_interval_90d__v0.pipe | 27 + ...point__grpc_metrics_by_region_14d__v0.pipe | 22 + ...dpoint__grpc_metrics_by_region_1d__v0.pipe | 22 + ...dpoint__grpc_metrics_by_region_7d__v0.pipe | 22 + .../endpoint__grpc_metrics_global_1d__v0.pipe | 25 + ...endpoint__grpc_metrics_latency_1d__v0.pipe | 25 + ...nt__grpc_metrics_latency_1d_multi__v0.pipe | 26 + ...ndpoint__grpc_metrics_latency_30d__v0.pipe | 26 + ...endpoint__grpc_metrics_latency_7d__v0.pipe | 25 + ...ndpoint__grpc_metrics_latency_90d__v0.pipe | 26 + .../endpoint__grpc_status_45d__v0.pipe | 20 + .../endpoint__grpc_status_7d__v0.pipe | 20 + .../endpoint__grpc_uptime_30d__v0.pipe | 22 + .../endpoint__grpc_uptime_7d__v0.pipe | 22 + .../endpoint__grpc_uptime_90d__v0.pipe | 21 + .../endpoint__grpc_workspace_30d__v0.pipe | 16 + .../aggregate__grpc_full_30d__v0.pipe | 31 + .../aggregate__grpc_status_7d__v0.pipe | 17 + .../pipes/aggregate__grpc_14d__v0.pipe | 23 + .../pipes/aggregate__grpc_1d__v0.pipe | 23 + .../pipes/aggregate__grpc_30d__v0.pipe | 23 + .../pipes/aggregate__grpc_7d__v0.pipe | 23 + .../pipes/aggregate__grpc_90d__v0.pipe | 23 + .../pipes/aggregate__grpc_full_14d__v0.pipe | 31 + .../pipes/aggregate__grpc_status_45d__v0.pipe | 19 + .../pipes/aggregate__grpc_uptime_30d__v0.pipe | 13 + .../pipes/aggregate__grpc_uptime_7d__v0.pipe | 13 + .../pipes/aggregate__grpc_uptime_90d__v0.pipe | 13 + .../aggregate__grpc_workspace_30d__v0.pipe | 18 + packages/tinybird/src/client.ts | 515 ++ packages/tinybird/src/schema.ts | 10 +- packages/utils/src/constants.ts | 1 + packages/utils/src/index.ts | 3 + packages/utils/src/payloads.ts | 25 + 157 files changed, 12945 insertions(+), 917 deletions(-) create mode 100644 apps/checker/checker/grpc.go create mode 100644 apps/checker/checker/grpc_internal_test.go create mode 100644 apps/checker/checker/grpc_test.go create mode 100644 apps/checker/checker/testserver_test.go create mode 100644 apps/checker/handlers/grpc.go create mode 100644 apps/checker/handlers/grpc_test.go create mode 100644 apps/checker/pkg/job/grpc_job.go create mode 100644 apps/checker/pkg/job/grpc_job_test.go create mode 100644 apps/checker/proto/private_location/v1/grpc_monitor.pb.go create mode 100644 apps/private-location/internal/server/ingest_grpc.go create mode 100644 apps/private-location/proto/private_location/v1/grpc_monitor.pb.go create mode 100644 apps/web/src/content/pages/changelog/grpc-monitoring.mdx create mode 100644 apps/web/src/content/pages/docs/reference/grpc-monitor.mdx create mode 100644 packages/db/drizzle/0084_flaky_thundra.sql create mode 100644 packages/db/drizzle/meta/0084_snapshot.json create mode 100644 packages/proto/api/openstatus/monitor/v1/grpc_monitor.proto create mode 100644 packages/proto/gen/ts/openstatus/monitor/v1/grpc_monitor_pb.ts create mode 100644 packages/proto/internal/private_location/v1/grpc_monitor.proto create mode 100644 packages/tinybird/datasources/check_grpc_response__v0.datasource create mode 100644 packages/tinybird/datasources/grpc_response__v0.datasource create mode 100644 packages/tinybird/datasources/mv__grpc_14d__v0.datasource create mode 100644 packages/tinybird/datasources/mv__grpc_1d__v0.datasource create mode 100644 packages/tinybird/datasources/mv__grpc_30d__v0.datasource create mode 100644 packages/tinybird/datasources/mv__grpc_7d__v0.datasource create mode 100644 packages/tinybird/datasources/mv__grpc_90d__v0.datasource create mode 100644 packages/tinybird/datasources/mv__grpc_full_14d__v0.datasource create mode 100644 packages/tinybird/datasources/mv__grpc_full_30d__v0.datasource create mode 100644 packages/tinybird/datasources/mv__grpc_status_45d__v0.datasource create mode 100644 packages/tinybird/datasources/mv__grpc_status_7d__v0.datasource create mode 100644 packages/tinybird/datasources/mv__grpc_uptime_30d__v0.datasource create mode 100644 packages/tinybird/datasources/mv__grpc_uptime_7d__v0.datasource create mode 100644 packages/tinybird/datasources/mv__grpc_uptime_90d__v0.datasource create mode 100644 packages/tinybird/datasources/mv__grpc_workspace_30d__v0.datasource create mode 100644 packages/tinybird/endpoints/endpoint__grpc_get_14d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__grpc_get_30d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__grpc_list_14d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__grpc_list_1d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__grpc_list_7d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__grpc_metrics_14d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__grpc_metrics_1d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__grpc_metrics_30d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__grpc_metrics_7d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__grpc_metrics_90d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__grpc_metrics_by_interval_14d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__grpc_metrics_by_interval_1d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__grpc_metrics_by_interval_30d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__grpc_metrics_by_interval_7d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__grpc_metrics_by_interval_90d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__grpc_metrics_by_region_14d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__grpc_metrics_by_region_1d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__grpc_metrics_by_region_7d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__grpc_metrics_global_1d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__grpc_metrics_latency_1d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__grpc_metrics_latency_1d_multi__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__grpc_metrics_latency_30d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__grpc_metrics_latency_7d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__grpc_metrics_latency_90d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__grpc_status_45d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__grpc_status_7d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__grpc_uptime_30d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__grpc_uptime_7d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__grpc_uptime_90d__v0.pipe create mode 100644 packages/tinybird/endpoints/endpoint__grpc_workspace_30d__v0.pipe create mode 100644 packages/tinybird/materializations/aggregate__grpc_full_30d__v0.pipe create mode 100644 packages/tinybird/materializations/aggregate__grpc_status_7d__v0.pipe create mode 100644 packages/tinybird/pipes/aggregate__grpc_14d__v0.pipe create mode 100644 packages/tinybird/pipes/aggregate__grpc_1d__v0.pipe create mode 100644 packages/tinybird/pipes/aggregate__grpc_30d__v0.pipe create mode 100644 packages/tinybird/pipes/aggregate__grpc_7d__v0.pipe create mode 100644 packages/tinybird/pipes/aggregate__grpc_90d__v0.pipe create mode 100644 packages/tinybird/pipes/aggregate__grpc_full_14d__v0.pipe create mode 100644 packages/tinybird/pipes/aggregate__grpc_status_45d__v0.pipe create mode 100644 packages/tinybird/pipes/aggregate__grpc_uptime_30d__v0.pipe create mode 100644 packages/tinybird/pipes/aggregate__grpc_uptime_7d__v0.pipe create mode 100644 packages/tinybird/pipes/aggregate__grpc_uptime_90d__v0.pipe create mode 100644 packages/tinybird/pipes/aggregate__grpc_workspace_30d__v0.pipe diff --git a/apps/checker/checker/grpc.go b/apps/checker/checker/grpc.go new file mode 100644 index 000000000..b4f0028b0 --- /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 000000000..4bf45b0e3 --- /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 000000000..fbc6160eb --- /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 000000000..f765dd90d --- /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 ed17e4ac1..60ef72dc0 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 46ca43051..6a2cd7445 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 000000000..97e8de4c9 --- /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 000000000..80dbf5c3b --- /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 4bbbf03e9..8c37dddf8 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 000000000..ee592d996 --- /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 000000000..88b19f54e --- /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 74518d410..f768bca6e 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 c4938d364..dfce446e0 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 c34281194..f86bc91b6 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 87cab525f..21ab0c027 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 ba1013a57..65afde2d2 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 000000000..6784cdc91 --- /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 8138ab7c5..e52f95039 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 a28112bc7..6cded0613 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 ac5b9626c..290fdd0b5 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 26680f149..79df5d762 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 7197d2c87..84ac57a45 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 35e9c6add..eaecd0af9 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 655f3b2b3..97b7fdbde 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 88240aa00..6d62bc997 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 b1dfc8887..c76fe07a0 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 ac4672c72..eeba847d7 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 cfbb1f7eb..a0d726a80 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 41f23c055..6642dea21 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 71ecdfddd..e65dcb398 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 5233f2d60..81464a008 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 071607f47..0e2a08a30 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 807d7367d..7c91eac67 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 e59e8dbb8..970fcf033 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 7c1eb9a44..3c0d578e1 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 9334198c3..6627ea15e 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 573663850..b174cc571 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 000000000..479436e3c --- /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 04cece34a..29d4f47d2 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 cf9f11f2c..f6ef69148 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 d83f80ab6..a4b19a161 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 b50fd5386..27382b7f1 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 000000000..6784cdc91 --- /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 8138ab7c5..e52f95039 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 a28112bc7..6cded0613 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 aa41d39e1..c1417407d 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 5e411a20f..32194e8b2 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 833685d72..d6acd4a10 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 3b6642c26..69166785c 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 82ac27c3e..04664b940 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 be77f8d61..1073bb7aa 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 b8aba3d09..785f22d11 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 5967421b9..4716537b9 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 216e77205..ae876638e 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 23aeb2c8d..11c90fc6c 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 6ebe54be4..e9bc20686 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 4ae4ff357..57cdae602 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 84611c3e2..4c90ce319 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 532ee0485..ed2d5f69a 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 85573dfd5..7f55327ca 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 000000000..b755fe62b --- /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 000000000..39eed154c --- /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 ec6a3103c..a34139786 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 e39d950b5..30e226cd3 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 706bb21ea..11a142edc 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 0e0119127..adb8e8c38 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 a1a5b080c..7213ffbc8 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 c25b4fc2f..cd7c9e636 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 2e38de876..b9043e3dd 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 000000000..e1a05e67c --- /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 000000000..cb484e37f --- /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 e586f1179..02b53c507 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 9c86c6c8f..528c40066 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 9c15405a2..eae65c162 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 a6774e948..43bce9857 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 000000000..18048422e --- /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 6ebb97800..1d9fbbdf5 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 532ee0485..ed2d5f69a 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 000000000..ed04312ac --- /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 e068ed2ec..edb1e9ee8 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 9de06bc9e..2e0f64500 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 000000000..0112e9cb6 --- /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 b8633c7d9..d4a55bffe 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 5335f78ea..52594603d 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 7c6a3df71..dcb7d4862 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 ec1e3fa53..2effc4a09 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 ba66b6a1a..fb1330304 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 8bdc66556..5a33ec01b 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 c1bd5aca9..8bc94be77 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 00e5eefb4..5719b11bf 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 5345242d4..1c43f9145 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 87ce2d9ec..17f2ecd53 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 c95f7bd0a..87166958b 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 c4e8c916f..c74ba11a9 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 000000000..9989d0b86 --- /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 000000000..43a694f13 --- /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 000000000..3e27e66e7 --- /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 000000000..2e3ef49d0 --- /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 000000000..f6f736661 --- /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 000000000..f72508158 --- /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 000000000..e4b598713 --- /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 000000000..b1e350e7f --- /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 000000000..2d5c0d865 --- /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 000000000..62eeec900 --- /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 000000000..b64b3c6e1 --- /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 000000000..ca2ee1072 --- /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 000000000..56f45b8d1 --- /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 000000000..500fae25d --- /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 000000000..99593ab00 --- /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 000000000..5514d0813 --- /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 000000000..d1f3687f6 --- /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 000000000..9925bb640 --- /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 000000000..7ef93d74f --- /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 000000000..7587e5122 --- /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 000000000..63d8204a6 --- /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 000000000..2abd61126 --- /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 000000000..d6aac75ea --- /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 000000000..7e4a97f85 --- /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 000000000..00df748ed --- /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 000000000..434649999 --- /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 000000000..89c3effd2 --- /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 000000000..27e5e2d43 --- /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 000000000..4ffabc67b --- /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 000000000..b1cf97df6 --- /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 000000000..ee81be983 --- /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 000000000..a52af487f --- /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 000000000..7c7c4eda6 --- /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 000000000..67a2873a5 --- /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 000000000..1a37d8ede --- /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 000000000..fa2f6bbd1 --- /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 000000000..aa158359a --- /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 000000000..35a051def --- /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 000000000..59015abe8 --- /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 000000000..543cc6d4d --- /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 000000000..2a6c04acc --- /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 000000000..b1a2bf650 --- /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 000000000..ee3d55072 --- /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 000000000..3663451f7 --- /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 000000000..f1a63a2dd --- /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 000000000..5add6c930 --- /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 000000000..380aae7fe --- /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 000000000..03065f20e --- /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 000000000..d592fa9bf --- /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 000000000..ac3feb88f --- /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 000000000..e376511c8 --- /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 000000000..b661e6d7f --- /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 000000000..a6eda35b7 --- /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 000000000..4d62f91e5 --- /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 000000000..04f664ebc --- /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 000000000..e8eebbe5a --- /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 000000000..be85cb919 --- /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 000000000..e629d712f --- /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 a6f434124..d14879692 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 aae67c4e0..248459f5f 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 0ffb264df..4100888a3 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 ec9a4400e..4fef0aeb2 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 e213f4355..7bf73494a 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; -- 2.51.2