diff --git a/apps/checker/checker/grpc.go b/apps/checker/checker/grpc.go index 3a3fa35e..fbb13273 100644 --- a/apps/checker/checker/grpc.go +++ b/apps/checker/checker/grpc.go @@ -35,6 +35,11 @@ const ( 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 @@ -171,6 +176,7 @@ func CheckGRPC(timeoutMs int64, target, service string, mode GRPCTLSMode, md map // 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: diff --git a/apps/checker/checker/grpc_test.go b/apps/checker/checker/grpc_test.go index 2037e015..53649410 100644 --- a/apps/checker/checker/grpc_test.go +++ b/apps/checker/checker/grpc_test.go @@ -126,8 +126,15 @@ func TestCheckGRPCUnimplemented(t *testing.T) { if res.Message != "server does not implement grpc.health.v1.Health" { t.Fatalf("unexpected message %q", res.Message) } - if res.ServingStatus != "" { - t.Fatalf("no serving status can be known here, got %q", res.ServingStatus) + // 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, + ) } } diff --git a/apps/checker/handlers/grpc_test.go b/apps/checker/handlers/grpc_test.go index 078bb06c..7426f800 100644 --- a/apps/checker/handlers/grpc_test.go +++ b/apps/checker/handlers/grpc_test.go @@ -299,3 +299,57 @@ func TestGRPCHandler_ProbesExactlyRetryTimes(t *testing.T) { }) } } + +// 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/packages/api/src/router/checker.test.ts b/packages/api/src/router/checker.test.ts new file mode 100644 index 00000000..5df2fccd --- /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 adb8e8c3..b16ca45e 100644 --- a/packages/api/src/router/checker.ts +++ b/packages/api/src/router/checker.ts @@ -555,6 +555,24 @@ export async function testGrpc(input: z.infer) { }); } + // 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); diff --git a/packages/api/src/router/statusPage.e2e.test.ts b/packages/api/src/router/statusPage.e2e.test.ts index f65f079c..a474b731 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 cd7c9e63..4b16bf9d 100644 --- a/packages/api/src/router/statusPage.ts +++ b/packages/api/src/router/statusPage.ts @@ -1155,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"), @@ -1178,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/tinybird/endpoints/endpoint__grpc_metrics_by_interval_14d__v0.pipe b/packages/tinybird/endpoints/endpoint__grpc_metrics_by_interval_14d__v0.pipe index 43464999..1279b786 100644 --- a/packages/tinybird/endpoints/endpoint__grpc_metrics_by_interval_14d__v0.pipe +++ b/packages/tinybird/endpoints/endpoint__grpc_metrics_by_interval_14d__v0.pipe @@ -21,6 +21,9 @@ SQL > 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 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 index 89c3effd..d27424ec 100644 --- a/packages/tinybird/endpoints/endpoint__grpc_metrics_by_interval_1d__v0.pipe +++ b/packages/tinybird/endpoints/endpoint__grpc_metrics_by_interval_1d__v0.pipe @@ -21,6 +21,9 @@ SQL > 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 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 index 27e5e2d4..2d10ebcd 100644 --- a/packages/tinybird/endpoints/endpoint__grpc_metrics_by_interval_30d__v0.pipe +++ b/packages/tinybird/endpoints/endpoint__grpc_metrics_by_interval_30d__v0.pipe @@ -20,6 +20,9 @@ SQL > 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 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 index 4ffabc67..b5221535 100644 --- a/packages/tinybird/endpoints/endpoint__grpc_metrics_by_interval_7d__v0.pipe +++ b/packages/tinybird/endpoints/endpoint__grpc_metrics_by_interval_7d__v0.pipe @@ -21,6 +21,9 @@ SQL > 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 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 index b1cf97df..77ffa31e 100644 --- a/packages/tinybird/endpoints/endpoint__grpc_metrics_by_interval_90d__v0.pipe +++ b/packages/tinybird/endpoints/endpoint__grpc_metrics_by_interval_90d__v0.pipe @@ -20,6 +20,9 @@ SQL > 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 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 index ee81be98..6b8aed5d 100644 --- a/packages/tinybird/endpoints/endpoint__grpc_metrics_by_region_14d__v0.pipe +++ b/packages/tinybird/endpoints/endpoint__grpc_metrics_by_region_14d__v0.pipe @@ -6,11 +6,11 @@ SQL > % SELECT region, - round(quantile(0.5)(latency)) as p50Latency, - round(quantile(0.75)(latency)) as p75Latency, - round(quantile(0.9)(latency)) as p90Latency, - round(quantile(0.95)(latency)) as p95Latency, - round(quantile(0.99)(latency)) as p99Latency, + 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 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 index a52af487..39fde077 100644 --- a/packages/tinybird/endpoints/endpoint__grpc_metrics_by_region_1d__v0.pipe +++ b/packages/tinybird/endpoints/endpoint__grpc_metrics_by_region_1d__v0.pipe @@ -6,11 +6,11 @@ SQL > % SELECT region, - round(quantile(0.5)(latency)) as p50Latency, - round(quantile(0.75)(latency)) as p75Latency, - round(quantile(0.9)(latency)) as p90Latency, - round(quantile(0.95)(latency)) as p95Latency, - round(quantile(0.99)(latency)) as p99Latency, + 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 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 index 7c7c4eda..8b123742 100644 --- a/packages/tinybird/endpoints/endpoint__grpc_metrics_by_region_7d__v0.pipe +++ b/packages/tinybird/endpoints/endpoint__grpc_metrics_by_region_7d__v0.pipe @@ -6,11 +6,11 @@ SQL > % SELECT region, - round(quantile(0.5)(latency)) as p50Latency, - round(quantile(0.75)(latency)) as p75Latency, - round(quantile(0.9)(latency)) as p90Latency, - round(quantile(0.95)(latency)) as p95Latency, - round(quantile(0.99)(latency)) as p99Latency, + 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 diff --git a/packages/tinybird/endpoints/endpoint__grpc_metrics_global_1d__v0.pipe b/packages/tinybird/endpoints/endpoint__grpc_metrics_global_1d__v0.pipe index 67a2873a..b12112fe 100644 --- a/packages/tinybird/endpoints/endpoint__grpc_metrics_global_1d__v0.pipe +++ b/packages/tinybird/endpoints/endpoint__grpc_metrics_global_1d__v0.pipe @@ -7,13 +7,13 @@ SQL > % SELECT - round(min(latency), 0) as minLatency, - round(max(latency), 0) as maxLatency, - round(quantile(0.5)(latency), 0) as p50Latency, - round(quantile(0.75)(latency), 0) as p75Latency, - round(quantile(0.9)(latency), 0) as p90Latency, - round(quantile(0.95)(latency), 0) as p95Latency, - round(quantile(0.99)(latency), 0) as p99Latency, + 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