From 247d4673e2e43e2e12e575d9570c1c3080b03dd2 Mon Sep 17 00:00:00 2001 From: Thibault Le Ouay Date: Thu, 27 Aug 2026 15:00:32 +0200 Subject: [PATCH] feat: gRPC health check monitors (#2613) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: gRPC health check monitors 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 * fix pr * fix pr --------- Co-authored-by: Claude Opus 5 (1M context) --- apps/checker/checker/grpc.go | 365 ++ apps/checker/checker/grpc_internal_test.go | 109 + apps/checker/checker/grpc_test.go | 280 + apps/checker/checker/testserver_test.go | 123 + apps/checker/cmd/server/main.go | 7 +- apps/checker/go.mod | 3 +- apps/checker/go.sum | 2 - apps/checker/handlers/checker.go | 20 +- apps/checker/handlers/grpc.go | 428 ++ apps/checker/handlers/grpc_test.go | 355 ++ apps/checker/handlers/icmp.go | 24 +- apps/checker/handlers/otel_wiring_test.go | 30 + apps/checker/handlers/ping.go | 14 +- apps/checker/handlers/tcp.go | 24 +- 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.test.ts | 109 + packages/api/src/router/checker.ts | 157 + packages/api/src/router/monitor.ts | 27 +- .../api/src/router/statusPage.e2e.test.ts | 67 + packages/api/src/router/statusPage.ts | 87 +- 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 + .../src/monitor/__tests__/monitor.test.ts | 76 + 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 | 11 +- packages/services/src/monitor/update.ts | 6 + .../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 | 32 + ...oint__grpc_metrics_by_interval_1d__v0.pipe | 32 + ...int__grpc_metrics_by_interval_30d__v0.pipe | 30 + ...oint__grpc_metrics_by_interval_7d__v0.pipe | 32 + ...int__grpc_metrics_by_interval_90d__v0.pipe | 30 + ...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 + 165 files changed, 13573 insertions(+), 983 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/api/src/router/checker.test.ts 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..fbb13273b --- /dev/null +++ b/apps/checker/checker/grpc.go @@ -0,0 +1,365 @@ +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" + // Not a grpc.health.v1 enum value: the server answered, it just has no + // health service. It still needs a status, because a NULL servingStatus is + // what the metrics pipes use to mean "never reached the server", and this + // check did — with a real latency worth counting. + ServingStatusUnimplemented = "UNIMPLEMENTED" +) + +// 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 + } + + // Every failure path must set GRPCCode: its zero value is codes.OK, and + // callers persist it verbatim, so leaving it unset records a check that + // never reached the server under the code for success. Unavailable is what + // grpc-go itself answers for a malformed-but-parseable target, so the whole + // "never got on the wire" class stays one code; the error message is what + // separates a bad target from a refused connection. + host, _, err := net.SplitHostPort(target) + if err != nil { + return GRPCResult{GRPCCode: int64(codes.Unavailable)}, + 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(), + GRPCCode: int64(codes.Unavailable), + }, 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.ServingStatus = ServingStatusUnimplemented + 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..536494107 --- /dev/null +++ b/apps/checker/checker/grpc_test.go @@ -0,0 +1,280 @@ +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) + } + // Deliberately not empty. The metrics pipes read a NULL servingStatus as + // "never reached the server" and drop the row from every latency + // aggregate — but this check did reach the server and timed a real round + // trip, so it has to carry a status to stay in them. + if res.ServingStatus != checker.ServingStatusUnimplemented { + t.Fatalf( + "expected %q to keep the row in latency metrics, got %q", + checker.ServingStatusUnimplemented, 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()) + } + if res.GRPCCode != int64(codes.Unavailable) { + t.Fatalf("expected Unavailable, got %d", res.GRPCCode) + } +} + +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) { + res, err := checker.CheckGRPC(5000, "api.example.com", "", checker.GRPCTLSModeTLS, nil) + if err == nil { + t.Fatal("expected a portless target to be rejected") + } + // GRPCCode's zero value is codes.OK and callers persist it verbatim, so an + // unset code records a check that never ran as a successful one. + if res.GRPCCode != int64(codes.Unavailable) { + t.Fatalf("expected Unavailable, got %d", res.GRPCCode) + } +} + +// A target that clears net.SplitHostPort but that grpc-go cannot parse is the +// only route into the grpc.NewClient error branch: a merely unreachable target +// constructs fine and fails later, on the call. +func TestCheckGRPCUnparseableTarget(t *testing.T) { + res, err := checker.CheckGRPC(5000, "ho%zzst:443", "", checker.GRPCTLSModePlaintext, nil) + if err == nil { + t.Fatal("expected an unparseable target to be rejected") + } + if res.Completed { + t.Fatal("expected Completed to be false") + } + if res.GRPCCode != int64(codes.Unavailable) { + t.Fatalf("expected Unavailable, got %d", res.GRPCCode) + } +} + +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..deee0d1f1 100644 --- a/apps/checker/go.mod +++ b/apps/checker/go.mod @@ -6,7 +6,6 @@ require ( cloud.google.com/go/auth v0.18.2 cloud.google.com/go/cloudtasks v1.13.7 connectrpc.com/connect v1.19.1 - github.com/cenkalti/backoff/v4 v4.3.0 github.com/cenkalti/backoff/v5 v5.0.3 github.com/gin-gonic/gin v1.12.0 github.com/google/uuid v1.6.0 @@ -24,6 +23,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 +81,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/go.sum b/apps/checker/go.sum index 07a5d0817..bb6dff56a 100644 --- a/apps/checker/go.sum +++ b/apps/checker/go.sum @@ -16,8 +16,6 @@ github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uS github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= -github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= -github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= diff --git a/apps/checker/handlers/checker.go b/apps/checker/handlers/checker.go index 5a22eccab..0c9c1e3fc 100644 --- a/apps/checker/handlers/checker.go +++ b/apps/checker/handlers/checker.go @@ -6,7 +6,7 @@ import ( "net/http" "time" - "github.com/cenkalti/backoff/v4" + "github.com/cenkalti/backoff/v5" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/rs/zerolog/log" @@ -117,28 +117,28 @@ func (h Handler) HTTPCheckerHandler(c *gin.Context) { retry = int(req.Retry) } - op := func() error { + op := func() (struct{}, error) { called++ res, err := checker.Http(ctx, requestClient, req) if err != nil { - return fmt.Errorf("unable to ping: %w", err) + return struct{}{}, fmt.Errorf("unable to ping: %w", err) } // In TB we need to store them as string timingAsString, err := json.Marshal(res.Timing) if err != nil { - return fmt.Errorf("error while parsing timing data %s: %w", req.URL, err) + return struct{}{}, fmt.Errorf("error while parsing timing data %s: %w", req.URL, err) } headersAsString, err := json.Marshal(res.Headers) if err != nil { - return fmt.Errorf("error while parsing headers %s: %w", req.URL, err) + return struct{}{}, fmt.Errorf("error while parsing headers %s: %w", req.URL, err) } id, err := uuid.NewV7() if err != nil { - return fmt.Errorf("error while generating uuid %w", err) + return struct{}{}, fmt.Errorf("error while generating uuid %w", err) } var requestStatus = "" @@ -173,12 +173,12 @@ func (h Handler) HTTPCheckerHandler(c *gin.Context) { var isSuccessfull bool = true isSuccessfull, err = EvaluateHTTPAssertions(req.RawAssertions, data, res) if err != nil { - return err + return struct{}{}, err } // let's retry at least once if the status code is not successful. if !isSuccessfull && called < retry { - return fmt.Errorf("unable to ping: %v with status %v", res, res.Status) + return struct{}{}, fmt.Errorf("unable to ping: %v with status %v", res, res.Status) } result = res @@ -271,10 +271,10 @@ func (h Handler) HTTPCheckerHandler(c *gin.Context) { c.Set("event", t) } - return nil + return struct{}{}, nil } - if err := backoff.Retry(op, backoff.WithMaxRetries(backoff.NewExponentialBackOff(), uint64(retry))); err != nil { + if _, err := backoff.Retry(ctx, op, backoff.WithBackOff(backoff.NewExponentialBackOff()), backoff.WithMaxTries(uint(retry))); err != nil { id, e := uuid.NewV7() if e != nil { log.Ctx(ctx).Error().Err(e).Msg("failed to send event to tinybird") diff --git a/apps/checker/handlers/grpc.go b/apps/checker/handlers/grpc.go new file mode 100644 index 000000000..a0f324d3d --- /dev/null +++ b/apps/checker/handlers/grpc.go @@ -0,0 +1,428 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "net/http" + "strconv" + "time" + + "github.com/cenkalti/backoff/v5" + "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 + } + + // CheckGRPC hands back what it learned alongside its error: whatever phases + // completed, plus the gRPC status code. That is what separates a DNS failure + // from a connect or TLS one, and a timeout from a refused connection. op() + // drops `res` when it returns an error to the retry loop, so keep the last + // attempt's result. + var lastResult checker.GRPCResult + + op := func() (struct{}, error) { + res, err := grpcCheck(req) + lastResult = res + 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 struct{}{}, fmt.Errorf("unable to check grpc %s", err) + } + + timingAsString, err := json.Marshal(res.Timing) + if err != nil { + return struct{}{}, fmt.Errorf("error while parsing timing data %s: %w", req.URI, err) + } + + id, err := uuid.NewV7() + if err != nil { + return struct{}{}, 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 struct{}{}, nil + } + + if _, err := backoff.Retry(ctx, op, backoff.WithBackOff(backoff.NewExponentialBackOff()), backoff.WithMaxTries(uint(retry))); err != nil { + id, e := uuid.NewV7() + if e != nil { + log.Ctx(ctx).Error().Err(e).Msg("failed to send event to tinybird") + return + } + // A marshal failure leaves the column empty rather than dropping the row: + // the rest of the failure record is still worth ingesting. + timingAsString, timingErr := json.Marshal(lastResult.Timing) + if timingErr != nil { + log.Ctx(ctx).Error().Err(timingErr).Msg("error while parsing timing data") + } + + 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, + Timing: string(timingAsString), + GRPCCode: lastResult.GRPCCode, + 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 + response.Timing = lastResult.Timing + response.GRPCCode = lastResult.GRPCCode + } + + 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 + + var retry int + if req.Retry != 0 { + retry = int(req.Retry) + } else { + retry = 3 + } + + // Same as GRPCHandler: op() discards `res` when it hands an error back to the + // retry loop, so keep what the last attempt learned. + var lastResult checker.GRPCResult + + op := func() (struct{}, error) { + timestamp := time.Now().UTC().UnixMilli() + res, err := grpcCheck(req) + lastResult = res + if err != nil { + return struct{}{}, 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 struct{}{}, 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 struct{}{}, nil + } + + _, err := backoff.Retry(ctx, op, backoff.WithBackOff(backoff.NewExponentialBackOff()), backoff.WithMaxTries(uint(retry))) + if err != nil { + response.JobType = "grpc" + response.Region = h.Region + response.ErrorMessage = err.Error() + response.Error = 1 + response.Timing = lastResult.Timing + response.GRPCCode = lastResult.GRPCCode + } + + 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..7426f800d --- /dev/null +++ b/apps/checker/handlers/grpc_test.go @@ -0,0 +1,355 @@ +package handlers_test + +import ( + "encoding/json" + "io" + "net" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "sync/atomic" + "testing" + + "github.com/gin-gonic/gin" + "github.com/openstatushq/openstatus/apps/checker/checker" + "github.com/openstatushq/openstatus/apps/checker/handlers" + "github.com/openstatushq/openstatus/apps/checker/pkg/tinybird" + "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"]) + + // A failed check is when the phase breakdown matters most: it is what + // separates "DNS never resolved" from "connected, TLS refused". + timing, ok := res["timing"].(map[string]any) + require.True(t, ok, "timing must be present on the failure response") + assert.Greater(t, timing["dnsStart"], float64(0), "resolution was attempted") + assert.Greater(t, timing["dnsDone"], float64(0), "127.0.0.1 resolves") + assert.Greater(t, timing["connectStart"], float64(0), "the dial was attempted") + assert.Equal(t, float64(0), timing["connectDone"], "the port is closed, so connect never completed") + + // OK(0) on a check that never reached the server would make a refused + // connection indistinguishable from a timeout. + assert.NotZero(t, res["grpcCode"], "a transport failure carries its status code, not OK") +} + +// capturingTinybird records the payloads the handler ships, so a test can +// assert on the ingested row rather than only the HTTP response. +func capturingTinybird(t *testing.T, sent *[][]byte) tinybird.Client { + t.Helper() + hclient := &http.Client{Transport: RoundTripFunc(func(req *http.Request) *http.Response { + if req.Body != nil { + body, err := io.ReadAll(req.Body) + require.NoError(t, err) + *sent = append(*sent, body) + } + + return &http.Response{ + StatusCode: http.StatusAccepted, + Body: io.NopCloser(strings.NewReader(`{}`)), + } + })} + + return tinybird.NewClient(hclient, "apiKey") +} + +func TestGRPCHandler_FailureEventCarriesTiming(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()) + + var sent [][]byte + h := handlers.Handler{TbClient: capturingTinybird(t, &sent), 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", strings.NewReader(body)) + r.Header.Set("Authorization", "Basic test") + router.ServeHTTP(w, r) + + assert.Equal(t, http.StatusOK, w.Code) + require.Len(t, sent, 1, "the exhausted-retries path ships exactly one row") + + var event struct { + Timing string `json:"timing"` + RequestStatus string `json:"requestStatus"` + GRPCCode int64 `json:"grpcCode"` + } + require.NoError(t, json.Unmarshal(sent[0], &event)) + assert.Equal(t, "error", event.RequestStatus) + assert.NotZero(t, event.GRPCCode, "a transport failure carries its status code, not OK") + + var timing checker.Timing + require.NoError(t, json.Unmarshal([]byte(event.Timing), &timing)) + assert.Greater(t, timing.DnsStart, int64(0), "resolution was attempted") + assert.Greater(t, timing.ConnectStart, int64(0), "the dial was attempted") + assert.Zero(t, timing.ConnectDone, "the port is closed, so connect never 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"]) +} + +// `retry` counts attempts, not extra tries on top of the first: v5's +// WithMaxTries(n) runs op exactly n times, so the cron/API path and the +// private-location path in pkg/job probe a monitor the same number of times. +// Counting accepted connections is exact here because every attempt builds a +// fresh client, and a connection closed mid-handshake leaves grpc-go nothing +// to reuse. +func TestGRPCHandler_ProbesExactlyRetryTimes(t *testing.T) { + for _, retry := range []int{1, 2, 3} { + t.Run("retry="+strconv.Itoa(retry), func(t *testing.T) { + var accepts int64 + ln, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + done := make(chan struct{}) + go func() { + defer close(done) + for { + conn, err := ln.Accept() + if err != nil { + return + } + atomic.AddInt64(&accepts, 1) + conn.Close() + } + }() + + h := handlers.Handler{TbClient: testTinybird(t), Secret: "test", Region: "local"} + router := gin.New() + router.POST("/checker/grpc", h.GRPCHandler) + + body := `{"uri":"` + ln.Addr().String() + `","tls":"plaintext","timeout":500,"retry":` + + strconv.Itoa(retry) + `,"status":"error","workspaceId":"1","monitorId":"1"}` + w := httptest.NewRecorder() + r, _ := http.NewRequest(http.MethodPost, "/checker/grpc", strings.NewReader(body)) + r.Header.Set("Authorization", "Basic test") + router.ServeHTTP(w, r) + + require.NoError(t, ln.Close()) + <-done + + assert.Equal(t, int64(retry), atomic.LoadInt64(&accepts), + "retry:%d must probe %d times", retry, retry) + }) + } +} + +// grpcTestServerNoHealth starts a plaintext gRPC server with no services +// registered, so Health/Check answers UNIMPLEMENTED. +func grpcTestServerNoHealth(t *testing.T) string { + t.Helper() + + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + server := grpc.NewServer() + go func() { + _ = server.Serve(listener) + }() + t.Cleanup(server.Stop) + + return listener.Addr().String() +} + +// A server that answers UNIMPLEMENTED reached the wire and timed a real round +// trip, so its row must carry a serving status. A NULL one means "never reached +// the server" to the metrics pipes, which would drop it from every latency +// quantile alongside genuine transport failures. +func TestGRPCHandler_UnimplementedRowCarriesServingStatus(t *testing.T) { + target := grpcTestServerNoHealth(t) + + var sent [][]byte + h := handlers.Handler{ + TbClient: capturingTinybird(t, &sent), + Secret: "test", + Region: "local", + } + router := gin.New() + router.POST("/checker/grpc", h.GRPCHandler) + + // status:"error" matches the outcome, so the handler skips its status-change + // callback and the test makes no outbound request. + 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) + require.Len(t, sent, 1, "a completed check ships exactly one row") + + var event struct { + ServingStatus string `json:"servingStatus"` + RequestStatus string `json:"requestStatus"` + } + require.NoError(t, json.Unmarshal(sent[0], &event)) + assert.Equal(t, "UNIMPLEMENTED", event.ServingStatus) + // Still an unhealthy check — only its visibility to the pipes changed. + assert.Equal(t, "error", event.RequestStatus) +} diff --git a/apps/checker/handlers/icmp.go b/apps/checker/handlers/icmp.go index c50b90daf..9efa0dac6 100644 --- a/apps/checker/handlers/icmp.go +++ b/apps/checker/handlers/icmp.go @@ -7,7 +7,7 @@ import ( "strconv" "time" - "github.com/cenkalti/backoff/v4" + "github.com/cenkalti/backoff/v5" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/openstatushq/openstatus/apps/checker/checker" @@ -111,15 +111,15 @@ func (h Handler) ICMPHandler(c *gin.Context) { retry = 3 } - op := func() error { + op := func() (struct{}, error) { res, err := checker.PingICMP(req.Timeout, req.URI) if err != nil { - return fmt.Errorf("unable to check icmp %s", err) + return struct{}{}, fmt.Errorf("unable to check icmp %s", err) } timingAsString, err := json.Marshal(res.Timing) if err != nil { - return fmt.Errorf("error while parsing timing data %s: %w", req.URI, err) + return struct{}{}, fmt.Errorf("error while parsing timing data %s: %w", req.URI, err) } latency := res.Latency @@ -136,7 +136,7 @@ func (h Handler) ICMPHandler(c *gin.Context) { id, err := uuid.NewV7() if err != nil { - return fmt.Errorf("error while generating uuid %w", err) + return struct{}{}, fmt.Errorf("error while generating uuid %w", err) } timestamp := time.Now().UTC().UnixMilli() @@ -210,10 +210,10 @@ func (h Handler) ICMPHandler(c *gin.Context) { log.Ctx(ctx).Error().Err(err).Msg("failed to send event to tinybird") } - return nil + return struct{}{}, nil } - if err := backoff.Retry(op, backoff.WithMaxRetries(backoff.NewExponentialBackOff(), uint64(retry))); err != nil { + if _, err := backoff.Retry(ctx, op, backoff.WithBackOff(backoff.NewExponentialBackOff()), backoff.WithMaxTries(uint(retry))); err != nil { id, e := uuid.NewV7() if e != nil { log.Ctx(ctx).Error().Err(e).Msg("failed to send event to tinybird") @@ -302,11 +302,11 @@ func (h Handler) ICMPHandlerRegion(c *gin.Context) { var response checker.ICMPResponse - op := func() error { + op := func() (struct{}, error) { timestamp := time.Now().UTC().UnixMilli() res, err := checker.PingICMP(req.Timeout, req.URI) if err != nil { - return fmt.Errorf("unable to check icmp %s", err) + return struct{}{}, fmt.Errorf("unable to check icmp %s", err) } response = checker.ICMPResponse{ @@ -323,7 +323,7 @@ func (h Handler) ICMPHandlerRegion(c *gin.Context) { timingAsString, err := json.Marshal(res.Timing) if err != nil { - return fmt.Errorf("error while parsing timing data %s: %w", req.URI, err) + return struct{}{}, fmt.Errorf("error while parsing timing data %s: %w", req.URI, err) } data := ICMPData{ @@ -349,10 +349,10 @@ func (h Handler) ICMPHandlerRegion(c *gin.Context) { } } - return nil + return struct{}{}, nil } - err := backoff.Retry(op, backoff.WithMaxRetries(backoff.NewExponentialBackOff(), 3)) + _, err := backoff.Retry(ctx, op, backoff.WithBackOff(backoff.NewExponentialBackOff()), backoff.WithMaxTries(3)) if err != nil { response.Error = 1 } 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/handlers/ping.go b/apps/checker/handlers/ping.go index ddc3032fc..9283d481d 100644 --- a/apps/checker/handlers/ping.go +++ b/apps/checker/handlers/ping.go @@ -6,7 +6,7 @@ import ( "net/http" "time" - "github.com/cenkalti/backoff/v4" + "github.com/cenkalti/backoff/v5" "github.com/gin-gonic/gin" "github.com/openstatushq/openstatus/apps/checker/checker" "github.com/openstatushq/openstatus/apps/checker/request" @@ -85,7 +85,7 @@ func (h Handler) PingRegionHandler(c *gin.Context) { var res checker.Response - op := func() error { + op := func() (struct{}, error) { headers := make([]struct { Key string `json:"key"` @@ -109,17 +109,17 @@ func (h Handler) PingRegionHandler(c *gin.Context) { r, err := checker.Http(c.Request.Context(), requestClient, input) if err != nil { - return fmt.Errorf("unable to ping: %w", err) + return struct{}{}, fmt.Errorf("unable to ping: %w", err) } timingAsString, err := json.Marshal(r.Timing) if err != nil { - return fmt.Errorf("error while parsing timing data %s: %w", req.URL, err) + return struct{}{}, fmt.Errorf("error while parsing timing data %s: %w", req.URL, err) } headersAsString, err := json.Marshal(r.Headers) if err != nil { - return nil + return struct{}{}, nil } tbData := PingResponse{ @@ -143,9 +143,9 @@ func (h Handler) PingRegionHandler(c *gin.Context) { } } - return nil + return struct{}{}, nil } - if err := backoff.Retry(op, backoff.WithMaxRetries(backoff.NewExponentialBackOff(), 3)); err != nil { + if _, err := backoff.Retry(ctx, op, backoff.WithBackOff(backoff.NewExponentialBackOff()), backoff.WithMaxTries(3)); err != nil { c.JSON(http.StatusOK, gin.H{"message": "url not reachable"}) return diff --git a/apps/checker/handlers/tcp.go b/apps/checker/handlers/tcp.go index 678277cb5..12a06a8fd 100644 --- a/apps/checker/handlers/tcp.go +++ b/apps/checker/handlers/tcp.go @@ -7,7 +7,7 @@ import ( "strconv" "time" - "github.com/cenkalti/backoff/v4" + "github.com/cenkalti/backoff/v5" "github.com/gin-gonic/gin" "github.com/google/uuid" "github.com/openstatushq/openstatus/apps/checker/checker" @@ -108,16 +108,16 @@ func (h Handler) TCPHandler(c *gin.Context) { retry = 3 } - op := func() error { + op := func() (struct{}, error) { res, err := checker.PingTCP(int(req.Timeout), req.URI) if err != nil { - return fmt.Errorf("unable to check tcp %s", err) + return struct{}{}, fmt.Errorf("unable to check tcp %s", err) } timingAsString, err := json.Marshal(res) if err != nil { - return fmt.Errorf("error while parsing timing data %s: %w", req.URI, err) + return struct{}{}, fmt.Errorf("error while parsing timing data %s: %w", req.URI, err) } latency := res.TCPDone - res.TCPStart @@ -135,7 +135,7 @@ func (h Handler) TCPHandler(c *gin.Context) { id, err := uuid.NewV7() if err != nil { - return fmt.Errorf("error while generating uuid %w", err) + return struct{}{}, fmt.Errorf("error while generating uuid %w", err) } data := TCPData{ @@ -204,10 +204,10 @@ func (h Handler) TCPHandler(c *gin.Context) { log.Ctx(ctx).Error().Err(err).Msg("failed to send event to tinybird") } - return nil + return struct{}{}, nil } - if err := backoff.Retry(op, backoff.WithMaxRetries(backoff.NewExponentialBackOff(), uint64(retry))); err != nil { + if _, err := backoff.Retry(ctx, op, backoff.WithBackOff(backoff.NewExponentialBackOff()), backoff.WithMaxTries(uint(retry))); err != nil { id, e := uuid.NewV7() if e != nil { @@ -295,13 +295,13 @@ func (h Handler) TCPHandlerRegion(c *gin.Context) { var response checker.TCPResponse - op := func() error { + op := func() (struct{}, error) { called++ timestamp := time.Now().UTC().UnixMilli() res, err := checker.PingTCP(int(req.Timeout), req.URI) if err != nil { - return fmt.Errorf("unable to check tcp %s", err) + return struct{}{}, fmt.Errorf("unable to check tcp %s", err) } response = checker.TCPResponse{ @@ -317,7 +317,7 @@ func (h Handler) TCPHandlerRegion(c *gin.Context) { timingAsString, err := json.Marshal(res) if err != nil { - return fmt.Errorf("error while parsing timing data %s: %w", req.URI, err) + return struct{}{}, fmt.Errorf("error while parsing timing data %s: %w", req.URI, err) } latency := res.TCPDone - res.TCPStart @@ -341,10 +341,10 @@ func (h Handler) TCPHandlerRegion(c *gin.Context) { } } - return nil + return struct{}{}, nil } - err := backoff.Retry(op, backoff.WithMaxRetries(backoff.NewExponentialBackOff(), 3)) + _, err := backoff.Retry(ctx, op, backoff.WithBackOff(backoff.NewExponentialBackOff()), backoff.WithMaxTries(3)) if err != nil { response.Error = 1 } 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 c13353f3d..519de499c 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.test.ts b/packages/api/src/router/checker.test.ts new file mode 100644 index 000000000..5df2fccd3 --- /dev/null +++ b/packages/api/src/router/checker.test.ts @@ -0,0 +1,109 @@ +import { expect } from "@std/expect"; +import { afterEach, describe, test } from "@std/testing/bdd"; + +import { testGrpc } from "./checker"; + +const originalFetch = globalThis.fetch; + +afterEach(() => { + globalThis.fetch = originalFetch; +}); + +/** Stub the checker with one canned JSON body. */ +function stubChecker(body: unknown) { + globalThis.fetch = (() => + Promise.resolve( + new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + )) as typeof globalThis.fetch; +} + +/** + * The shape GRPCHandlerRegion returns for a completed RPC. `state` is absent — + * the handler never sends it — so grpcOutput prefaults it to "success". + */ +function completedResponse( + overrides: Record, +): Record { + return { + jobType: "grpc", + region: "ams", + timestamp: 1_700_000_000_000, + timing: { + dnsStart: 1, + dnsDone: 2, + connectStart: 2, + connectDone: 3, + tlsHandshakeStart: 3, + tlsHandshakeDone: 4, + firstByteStart: 4, + firstByteDone: 5, + transferStart: 5, + transferDone: 6, + }, + latency: 5, + completed: true, + ...overrides, + }; +} + +describe("testGrpc", () => { + test("accepts a SERVING target", async () => { + // `error` is omitempty in Go, so a healthy check omits it entirely. + stubChecker(completedResponse({ servingStatus: "SERVING" })); + + const result = await testGrpc({ + url: "api.example.com:443", + region: "ams", + }); + expect(result.state).toBe("success"); + }); + + test("rejects a completed check that answered NOT_SERVING", async () => { + stubChecker( + completedResponse({ + servingStatus: "NOT_SERVING", + error: 1, + errorMessage: "service reports NOT_SERVING", + }), + ); + + await expect( + testGrpc({ url: "api.example.com:443", region: "ams" }), + ).rejects.toThrow("service reports NOT_SERVING"); + }); + + test("rejects a server with no health service", async () => { + stubChecker( + completedResponse({ + error: 1, + errorMessage: "server does not implement grpc.health.v1.Health", + }), + ); + + await expect( + testGrpc({ url: "api.example.com:443", region: "ams" }), + ).rejects.toThrow("does not implement"); + }); + + test("falls back to the serving status when no message is sent", async () => { + stubChecker( + completedResponse({ servingStatus: "SERVICE_UNKNOWN", error: 1 }), + ); + + await expect( + testGrpc({ url: "api.example.com:443", region: "ams" }), + ).rejects.toThrow("SERVICE_UNKNOWN"); + }); + + test("still rejects a transport failure", async () => { + // The only shape that carries `state` explicitly. + stubChecker({ message: "uri not reachable" }); + + await expect( + testGrpc({ url: "api.example.com:443", region: "ams" }), + ).rejects.toThrow("uri not reachable"); + }); +}); diff --git a/packages/api/src/router/checker.ts b/packages/api/src/router/checker.ts index 0e0119127..b16ca45e2 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,81 @@ 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, + }); + } + + // Only a transport failure comes back as `state: "error"`. An RPC that + // completed but answered NOT_SERVING / SERVICE_UNKNOWN — or a server with no + // health service at all — returns the full response, where `state` is absent + // and prefaults to "success". `error` is omitempty, so it is present only + // when the check failed. Mirrors testHttp rejecting a non-2XX status: the + // target is reachable, but saving it would create a monitor that is already + // down. + if (result.data.error === 1) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: + result.data.errorMessage || + `The health check did not report SERVING${ + result.data.servingStatus ? `: ${result.data.servingStatus}` : "" + }`, + }); + } + + 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 +594,7 @@ export async function triggerChecker( | z.infer | z.infer | z.infer + | z.infer | null = null; if (process.env.NODE_ENV !== "production") { @@ -562,6 +688,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 +739,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 +773,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..639232409 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", + }); } } @@ -357,6 +370,8 @@ export const monitorRouter = createTRPCRouter({ body: input.body, assertions: input.assertions, active: input.active, + grpcService: input.grpcService, + grpcTls: input.grpcTls, }; await updateMonitorGeneral({ ctx: toServiceCtx(ctx), @@ -410,6 +425,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", + }); } } @@ -422,6 +445,8 @@ export const monitorRouter = createTRPCRouter({ body: input.body, assertions: input.assertions, active: input.active, + grpcService: input.grpcService, + grpcTls: input.grpcTls, }; return await createMonitor({ ctx: toServiceCtx(ctx), diff --git a/packages/api/src/router/statusPage.e2e.test.ts b/packages/api/src/router/statusPage.e2e.test.ts index f65f079cb..a474b7317 100644 --- a/packages/api/src/router/statusPage.e2e.test.ts +++ b/packages/api/src/router/statusPage.e2e.test.ts @@ -1067,6 +1067,8 @@ describe("statusPage exposes page component names, not internal monitor names", let noDescriptionComponentId: number; let clearedDescriptionMonitorId: number; let clearedDescriptionComponentId: number; + let grpcMonitorId: number; + let grpcComponentId: number; const internalName = "Internal Monitor Name"; const internalDescription = "Internal monitor description"; @@ -1195,6 +1197,37 @@ describe("statusPage exposes page component names, not internal monitor names", .returning() .get(); clearedDescriptionComponentId = clearedDescriptionComponent.id; + + // getMonitor dispatches its Tinybird reads on jobType; a gRPC monitor must + // find a matching entry like every other supported type. + const grpcMonitor = await db + .insert(monitor) + .values({ + workspaceId: 1, + name: "gRPC monitor", + jobType: "grpc", + periodicity: "1m", + url: "api.example.com:443", + active: true, + public: true, + }) + .returning() + .get(); + grpcMonitorId = grpcMonitor.id; + + const grpcComponent = await db + .insert(pageComponent) + .values({ + workspaceId: 1, + pageId: publicNamePageId, + type: "monitor", + monitorId: grpcMonitorId, + name: "gRPC component", + order: 3, + }) + .returning() + .get(); + grpcComponentId = grpcComponent.id; }); afterAll(async () => { @@ -1207,6 +1240,8 @@ describe("statusPage exposes page component names, not internal monitor names", await db .delete(pageComponent) .where(eq(pageComponent.id, clearedDescriptionComponentId)); + await db.delete(pageComponent).where(eq(pageComponent.id, grpcComponentId)); + await db.delete(monitor).where(eq(monitor.id, grpcMonitorId)); await db.delete(monitor).where(eq(monitor.id, publicNameMonitorId)); await db.delete(monitor).where(eq(monitor.id, noDescriptionMonitorId)); await db.delete(monitor).where(eq(monitor.id, clearedDescriptionMonitorId)); @@ -1310,6 +1345,38 @@ describe("statusPage exposes page component names, not internal monitor names", expect(component?.monitor?.description).toBe(componentDescription); }); + test("getMonitor resolves metrics procedures for a gRPC monitor", async () => { + const caller = await createCaller(); + + // Tinybird is noop under test, so a resolved dispatch and a missing one both + // end up with empty chart data. What separates them is the TypeError that + // indexing proceduresByType with an absent job type throws — which + // withTinybirdFallback catches and files as a Tinybird outage rather than + // surfacing. Assert it never happens. + const logged: string[] = []; + const originalError = console.error; + console.error = (...args: unknown[]) => { + logged.push(args.map(String).join(" ")); + }; + + let result: Awaited>; + try { + result = await caller.statusPage.getMonitor({ + slug: publicNameSlug, + id: grpcMonitorId, + }); + } finally { + console.error = originalError; + } + + expect(result?.name).toBe("gRPC component"); + expect( + logged.filter((line) => + line.includes("Cannot read properties of undefined"), + ), + ).toEqual([]); + }); + test("getMonitor returns the page component name and description", async () => { const caller = await createCaller(); const result = await caller.statusPage.getMonitor({ diff --git a/packages/api/src/router/statusPage.ts b/packages/api/src/router/statusPage.ts index c25b4fc2f..4b16bf9dc 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) || []; @@ -1141,8 +1155,6 @@ export const statusPageRouter = createTRPCRouter({ if (!_monitor.public) return null; if (_monitor.deletedAt) return null; - const type = _monitor.jobType as "http" | "tcp" | "dns" | "icmp"; - const proceduresByType = { http: { latency: getMetricsLatencyProcedure("7d", "http"), @@ -1164,32 +1176,47 @@ export const statusPageRouter = createTRPCRouter({ regions: getMetricsRegionsProcedure("7d", "icmp"), uptime: getUptimeProcedure("7d", "icmp"), }, + grpc: { + latency: getMetricsLatencyProcedure("7d", "grpc"), + regions: getMetricsRegionsProcedure("7d", "grpc"), + uptime: getUptimeProcedure("7d", "grpc"), + }, }; + // `udp` and `ssl` are monitor job types with no Tinybird pipes. Looking the + // key up instead of asserting the type means such a monitor renders with + // empty charts — the same shape a Tinybird outage produces — rather than + // throwing on a missing key. + const procedures = + proceduresByType[_monitor.jobType as keyof typeof proceduresByType] ?? + null; + const fromDate = startOfDay(subDays(new Date(), 7)).toISOString(); const toDate = endOfDay(new Date()).toISOString(); // Slow/erroring Tinybird → empty chart data so the page still renders. - const metrics = await withTinybirdFallback(() => - Promise.all([ - proceduresByType[type].latency({ - monitorId: _monitor.id.toString(), - fromDate, - toDate, - }), - proceduresByType[type].regions({ - monitorId: _monitor.id.toString(), - fromDate, - toDate, - }), - proceduresByType[type].uptime({ - monitorId: _monitor.id.toString(), - interval: 240, - fromDate, - toDate, - }), - ]), - ); + const metrics = !procedures + ? { ok: false as const, data: null } + : await withTinybirdFallback(() => + Promise.all([ + procedures.latency({ + monitorId: _monitor.id.toString(), + fromDate, + toDate, + }), + procedures.regions({ + monitorId: _monitor.id.toString(), + fromDate, + toDate, + }), + procedures.uptime({ + monitorId: _monitor.id.toString(), + interval: 240, + fromDate, + toDate, + }), + ]), + ); const [latency, regions, uptime] = metrics.data ?? [ { data: [] }, 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 36736ef19..a4ed875f2 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 @@ -91,3 +97,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/__tests__/monitor.test.ts b/packages/services/src/monitor/__tests__/monitor.test.ts index fb654dfc0..25fc75c6b 100644 --- a/packages/services/src/monitor/__tests__/monitor.test.ts +++ b/packages/services/src/monitor/__tests__/monitor.test.ts @@ -705,6 +705,82 @@ describe("updateMonitorGeneral", () => { }); }); }); + + test("persists the gRPC service / TLS mode", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...teamCtx, db: tx }; + const row = await createMonitor({ + ctx, + input: { + name: `${TEST_PREFIX}-gen-grpc`, + jobType: "grpc", + url: "api.example.com:443", + method: "GET", + headers: [], + assertions: [], + active: false, + }, + }); + expect(row.grpcService).toBe(null); + expect(row.grpcTls).toBe("tls"); + + const updated = await updateMonitorGeneral({ + ctx, + input: { + id: row.id, + name: `${TEST_PREFIX}-gen-grpc`, + jobType: "grpc", + url: "api.example.com:443", + method: "GET", + headers: [], + assertions: [], + active: true, + grpcService: "checkout.v1.CheckoutService", + grpcTls: "plaintext", + }, + }); + + expect(updated.grpcService).toBe("checkout.v1.CheckoutService"); + expect(updated.grpcTls).toBe("plaintext"); + }); + }); + + test("omitting the gRPC fields leaves them untouched", async () => { + await withTestTransaction(async (tx) => { + const ctx = { ...teamCtx, db: tx }; + const row = await createMonitor({ + ctx, + input: { + name: `${TEST_PREFIX}-gen-grpc-keep`, + jobType: "grpc", + url: "api.example.com:443", + method: "GET", + headers: [], + assertions: [], + active: false, + grpcService: "checkout.v1.CheckoutService", + grpcTls: "plaintext", + }, + }); + + const updated = await updateMonitorGeneral({ + ctx, + input: { + id: row.id, + name: `${TEST_PREFIX}-gen-grpc-keep-2`, + jobType: "grpc", + url: "api.example.com:443", + method: "GET", + headers: [], + assertions: [], + active: true, + }, + }); + + expect(updated.grpcService).toBe("checkout.v1.CheckoutService"); + expect(updated.grpcTls).toBe("plaintext"); + }); + }); }); describe("bulkUpdateMonitors", () => { 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 0ccf5eb11..f6f653d58 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(), }); @@ -103,6 +108,10 @@ export const UpdateMonitorGeneralInput = z.object({ body: z.string().optional(), assertions: z.array(assertion).default([]), active: z.boolean().default(true), + // gRPC-only fields. `undefined` leaves the stored value untouched so a + // caller that omits them (e.g. an HTTP monitor) never clears the column. + grpcService: z.string().optional(), + grpcTls: z.enum(grpcTlsModes).optional(), }); export type UpdateMonitorGeneralInput = z.infer< typeof UpdateMonitorGeneralInput diff --git a/packages/services/src/monitor/update.ts b/packages/services/src/monitor/update.ts index c4e8c916f..a66e5ead1 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; } @@ -129,6 +131,10 @@ export async function updateMonitorGeneral(args: { body: input.body, active: input.active, assertions: serialiseAssertions(input.assertions), + ...(input.grpcService !== undefined + ? { grpcService: input.grpcService } + : {}), + ...(input.grpcTls !== undefined ? { grpcTls: input.grpcTls } : {}), updatedAt: new Date(), }) .where(eq(monitor.id, existing.id)) 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..1279b7863 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__grpc_metrics_by_interval_14d__v0.pipe @@ -0,0 +1,32 @@ +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) }} + -- A transport failure resets latency to 0; including those rows drags + -- every quantile toward zero. Only completed RPCs carry a servingStatus. + AND servingStatus IS NOT NULL + {% 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..d27424ece --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__grpc_metrics_by_interval_1d__v0.pipe @@ -0,0 +1,32 @@ +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) }} + -- A transport failure resets latency to 0; including those rows drags + -- every quantile toward zero. Only completed RPCs carry a servingStatus. + AND servingStatus IS NOT NULL + {% 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..2d10ebcda --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__grpc_metrics_by_interval_30d__v0.pipe @@ -0,0 +1,30 @@ +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) }} + -- A transport failure resets latency to 0; including those rows drags + -- every quantile toward zero. Only completed RPCs carry a servingStatus. + AND servingStatus IS NOT NULL + {% 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..b52215357 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__grpc_metrics_by_interval_7d__v0.pipe @@ -0,0 +1,32 @@ +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) }} + -- A transport failure resets latency to 0; including those rows drags + -- every quantile toward zero. Only completed RPCs carry a servingStatus. + AND servingStatus IS NOT NULL + {% 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..77ffa31e1 --- /dev/null +++ b/packages/tinybird/endpoints/endpoint__grpc_metrics_by_interval_90d__v0.pipe @@ -0,0 +1,30 @@ +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) }} + -- A transport failure resets latency to 0; including those rows drags + -- every quantile toward zero. Only completed RPCs carry a servingStatus. + AND servingStatus IS NOT NULL + {% 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..6b8aed5d2 --- /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(quantileIf(0.5)(latency, servingStatus IS NOT NULL)) as p50Latency, + round(quantileIf(0.75)(latency, servingStatus IS NOT NULL)) as p75Latency, + round(quantileIf(0.9)(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, + 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..39fde0770 --- /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(quantileIf(0.5)(latency, servingStatus IS NOT NULL)) as p50Latency, + round(quantileIf(0.75)(latency, servingStatus IS NOT NULL)) as p75Latency, + round(quantileIf(0.9)(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, + 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..8b1237425 --- /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(quantileIf(0.5)(latency, servingStatus IS NOT NULL)) as p50Latency, + round(quantileIf(0.75)(latency, servingStatus IS NOT NULL)) as p75Latency, + round(quantileIf(0.9)(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, + 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..b12112fe2 --- /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(minIf(latency, servingStatus IS NOT NULL), 0) as minLatency, + round(maxIf(latency, servingStatus IS NOT NULL), 0) as maxLatency, + round(quantileIf(0.5)(latency, servingStatus IS NOT NULL), 0) as p50Latency, + round(quantileIf(0.75)(latency, servingStatus IS NOT NULL), 0) as p75Latency, + round(quantileIf(0.9)(latency, servingStatus IS NOT NULL), 0) as p90Latency, + round(quantileIf(0.95)(latency, servingStatus IS NOT NULL), 0) as p95Latency, + round(quantileIf(0.99)(latency, servingStatus IS NOT NULL), 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