From 4beac41818b89727cd991848a8c643d744bc1703 Mon Sep 17 00:00:00 2001 From: Max Rozen <3822106+rozenmd@users.noreply.github.com> Date: Tue, 13 Jun 2023 14:45:04 +0200 Subject: [PATCH] fix: add 24h read/write metrics to d1 info (#3434) * fix: add 24h read/write metrics to d1 info * add test * add test * filter by db.uuid * fix: we can only fetch metrics for new dbs * fix: use toLocaleString to get human-readable numbers for now * fix: don't prettify json output * file_size -> database_size * remove date-fns, fix up some logic --- .changeset/six-horses-punch.md | 5 ++ package-lock.json | 32 ++++--- .../__tests__/fetch-graphql-result.test.ts | 77 +++++++++++++++++ packages/wrangler/src/cfetch/index.ts | 20 +++++ packages/wrangler/src/d1/info.tsx | 86 +++++++++++++++++-- packages/wrangler/src/d1/types.ts | 31 +++++++ 6 files changed, 230 insertions(+), 21 deletions(-) create mode 100644 .changeset/six-horses-punch.md create mode 100644 packages/wrangler/src/__tests__/fetch-graphql-result.test.ts diff --git a/.changeset/six-horses-punch.md b/.changeset/six-horses-punch.md new file mode 100644 index 000000000000..bf7410311388 --- /dev/null +++ b/.changeset/six-horses-punch.md @@ -0,0 +1,5 @@ +--- +"wrangler": patch +--- + +fix: add the number of read queries and write queries in the last 24 hours to the `d1 info` command diff --git a/package-lock.json b/package-lock.json index 03d1f3565f12..53cf71db94c8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2471,9 +2471,9 @@ "dev": true }, "node_modules/@babel/runtime": { - "version": "7.20.6", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.6.tgz", - "integrity": "sha512-Q+8MqP7TiHMWzSfwiJwXCjyf4GYA4Dgw3emg/7xmwsdLJOZUp+nMqcOwOzzYheuM1rhDu8FSj2l0aoMygEuXuA==", + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.22.5.tgz", + "integrity": "sha512-ecjvYlnAaZ/KVneE/OdKYBYfgXV3Ptu6zQWmgEF7vwKhQnvVS6bjMD2XYgj+SNvQ1GfK/pjgokfPkC/2CO8CuA==", "dependencies": { "regenerator-runtime": "^0.13.11" }, @@ -10138,10 +10138,13 @@ "license": "BSD-3-Clause" }, "node_modules/date-fns": { - "version": "2.29.3", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.29.3.tgz", - "integrity": "sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA==", + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", + "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", "dev": true, + "dependencies": { + "@babel/runtime": "^7.21.0" + }, "engines": { "node": ">=0.11" }, @@ -34655,9 +34658,9 @@ "dev": true }, "@babel/runtime": { - "version": "7.20.6", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.20.6.tgz", - "integrity": "sha512-Q+8MqP7TiHMWzSfwiJwXCjyf4GYA4Dgw3emg/7xmwsdLJOZUp+nMqcOwOzzYheuM1rhDu8FSj2l0aoMygEuXuA==", + "version": "7.22.5", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.22.5.tgz", + "integrity": "sha512-ecjvYlnAaZ/KVneE/OdKYBYfgXV3Ptu6zQWmgEF7vwKhQnvVS6bjMD2XYgj+SNvQ1GfK/pjgokfPkC/2CO8CuA==", "requires": { "regenerator-runtime": "^0.13.11" } @@ -40810,10 +40813,13 @@ "version": "1.4.0" }, "date-fns": { - "version": "2.29.3", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.29.3.tgz", - "integrity": "sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA==", - "dev": true + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", + "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", + "dev": true, + "requires": { + "@babel/runtime": "^7.21.0" + } }, "date-time": { "version": "3.1.0", diff --git a/packages/wrangler/src/__tests__/fetch-graphql-result.test.ts b/packages/wrangler/src/__tests__/fetch-graphql-result.test.ts new file mode 100644 index 000000000000..fb84e1d1a70c --- /dev/null +++ b/packages/wrangler/src/__tests__/fetch-graphql-result.test.ts @@ -0,0 +1,77 @@ +import { rest } from "msw"; +import { fetchGraphqlResult } from "../cfetch"; +import { mockAccountId, mockApiToken } from "./helpers/mock-account-id"; +import { mockOAuthFlow } from "./helpers/mock-oauth-flow"; +import { msw } from "./helpers/msw"; + +describe("fetchGraphqlResult", () => { + mockAccountId({ accountId: null }); + mockApiToken(); + const { mockOAuthServerCallback } = mockOAuthFlow(); + + it("should make a request against the graphql endpoint by default", async () => { + mockOAuthServerCallback(); + msw.use( + rest.post("*/graphql", async (req, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ + data: { + viewer: { + __typename: "viewer", + }, + }, + errors: null, + }) + ); + }) + ); + expect( + await fetchGraphqlResult({ + body: JSON.stringify({ + query: `{ + viewer { + __typename + } + }`, + }), + }) + ).toEqual({ data: { viewer: { __typename: "viewer" } }, errors: null }); + }); + + it("should accept a request with no init, but return no data", async () => { + mockOAuthServerCallback(); + const now = new Date().toISOString(); + msw.use( + rest.post("*/graphql", async (req, res, ctx) => { + return res( + ctx.status(200), + ctx.json({ + data: null, + errors: [ + { + message: "failed to recognize JSON request: 'EOF'", + path: null, + extensions: { + timestamp: now, + }, + }, + ], + }) + ); + }) + ); + expect(await fetchGraphqlResult()).toEqual({ + data: null, + errors: [ + { + message: "failed to recognize JSON request: 'EOF'", + path: null, + extensions: { + timestamp: now, + }, + }, + ], + }); + }); +}); diff --git a/packages/wrangler/src/cfetch/index.ts b/packages/wrangler/src/cfetch/index.ts index bda9e41ae0c2..34a169c910fe 100644 --- a/packages/wrangler/src/cfetch/index.ts +++ b/packages/wrangler/src/cfetch/index.ts @@ -43,6 +43,26 @@ export async function fetchResult( } } +/** + * Make a fetch request to the GraphQL API, and return the JSON response. + */ +export async function fetchGraphqlResult( + init: RequestInit = {}, + abortSignal?: AbortSignal +): Promise { + const json = await fetchInternal( + "/graphql", + { ...init, method: "POST" }, //Cloudflare API v4 doesn't allow GETs to /graphql + undefined, + abortSignal + ); + if (json) { + return json; + } else { + throw new Error("A request to the Cloudflare API (/graphql) failed."); + } +} + /** * Make a fetch request for a list of values, * extracting the `result` from the JSON response, diff --git a/packages/wrangler/src/d1/info.tsx b/packages/wrangler/src/d1/info.tsx index fe76e2a44b91..e45c28a01202 100644 --- a/packages/wrangler/src/d1/info.tsx +++ b/packages/wrangler/src/d1/info.tsx @@ -1,7 +1,7 @@ import Table from "ink-table"; import prettyBytes from "pretty-bytes"; import React from "react"; -import { fetchResult } from "../cfetch"; +import { fetchGraphqlResult, fetchResult } from "../cfetch"; import { withConfig } from "../config"; import { logger } from "../logger"; import { requireAuth } from "../user"; @@ -11,7 +11,7 @@ import type { CommonYargsArgv, StrictYargsOptionsToInterface, } from "../yargs-types"; -import type { Database } from "./types"; +import type { D1MetricsGraphQLResponse, Database } from "./types"; export function Options(d1ListYargs: CommonYargsArgv) { return d1ListYargs @@ -46,16 +46,86 @@ export const Handler = withConfig( }, } ); + + const output: Record = { ...result }; + if (output["file_size"]) { + output["database_size"] = output["file_size"]; + delete output["file_size"]; + } + if (result.version === "beta") { + const today = new Date(); + const yesterday = new Date(new Date(today).setDate(today.getDate() - 1)); + + const graphqlResult = await fetchGraphqlResult({ + method: "POST", + body: JSON.stringify({ + query: `query getD1MetricsOverviewQuery($accountTag: string, $filter: ZoneWorkersRequestsFilter_InputObject) { + viewer { + accounts(filter: {accountTag: $accountTag}) { + d1AnalyticsAdaptiveGroups(limit: 10000, filter: $filter) { + sum { + readQueries + writeQueries + } + dimensions { + datetimeHour + } + } + } + } + }`, + operationName: "getD1MetricsOverviewQuery", + variables: { + accountTag: accountId, + filter: { + AND: [ + { + datetimeHour_geq: yesterday.toISOString(), + datetimeHour_leq: today.toISOString(), + databaseId: db.uuid, + }, + ], + }, + }, + }), + headers: { + "Content-Type": "application/json", + }, + }); + + const metrics = { readQueries: 0, writeQueries: 0 }; + if (graphqlResult) { + graphqlResult.data?.viewer?.accounts[0]?.d1AnalyticsAdaptiveGroups?.forEach( + (row) => { + metrics.readQueries += row?.sum?.readQueries ?? 0; + metrics.writeQueries += row?.sum?.writeQueries ?? 0; + } + ); + output.read_queries_24h = metrics.readQueries; + output.write_queries_24h = metrics.writeQueries; + } + } + if (json) { - logger.log(JSON.stringify(result, null, 2)); + logger.log(JSON.stringify(output, null, 2)); } else { // Snip off the "uuid" property from the response and use those as the header - const entries = Object.entries(result).filter(([k, _v]) => k !== "uuid"); - const data = entries.map(([k, v]) => ({ - [db.binding || ""]: k, - [db.uuid]: k === "file_size" ? prettyBytes(Number(v)) : v, - })); + const entries = Object.entries(output).filter(([k, _v]) => k !== "uuid"); + const data = entries.map(([k, v]) => { + let value; + if (k === "database_size") { + value = prettyBytes(Number(v)); + } else if (k === "read_queries_24h" || k === "write_queries_24h") { + value = v.toLocaleString(); + } else { + value = v; + } + return { + [db.binding || ""]: k, + [db.uuid]: value, + }; + }); logger.log(renderToString()); } diff --git a/packages/wrangler/src/d1/types.ts b/packages/wrangler/src/d1/types.ts index ec2cce5ab293..5fa24c792ebe 100644 --- a/packages/wrangler/src/d1/types.ts +++ b/packages/wrangler/src/d1/types.ts @@ -30,3 +30,34 @@ export type Migration = { name: string; applied_at: string; }; + +export interface D1Metrics { + sum?: { + readQueries?: number; + writeQueries?: number; + queryBatchResponseBytes?: number; + }; + quantiles?: { + queryBatchTimeMsP90?: number; + }; + avg?: { + queryBatchTimeMs?: number; + }; + dimensions: { + databaseId?: string; + date?: string; + datetime?: string; + datetimeMinute?: string; + datetimeFiveMinutes?: string; + datetimeFifteenMinutes?: string; + datetimeHour?: string; + }; +} + +export interface D1MetricsGraphQLResponse { + data: { + viewer: { + accounts: { d1AnalyticsAdaptiveGroups?: D1Metrics[] }[]; + }; + }; +}