Skip to content

Commit

Permalink
feat: return errors as values for clickhouse (#2665)
Browse files Browse the repository at this point in the history
  • Loading branch information
chronark authored Nov 18, 2024
1 parent b21b1c4 commit 707f0d4
Show file tree
Hide file tree
Showing 21 changed files with 135 additions and 79 deletions.
6 changes: 5 additions & 1 deletion apps/api/src/routes/v1_keys_getVerifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,13 +221,17 @@ export const registerV1KeysGetVerifications = (app: App) =>
const verificationsFromAllKeys = await Promise.all(
ids.map(({ keyId, keySpaceId }) => {
return cache.verificationsByKeyId.swr(`${keyId}:${start}-${end}`, async () => {
return await analytics.getVerificationsDaily({
const res = await analytics.getVerificationsDaily({
workspaceId: authorizedWorkspaceId,
keySpaceId: keySpaceId,
keyId: keyId,
start: start ? start : now - 24 * 60 * 60 * 1000,
end: end ? end : now,
});
if (res.err) {
throw new Error(res.err.message);
}
return res.val;
});
}),
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ export default async function APIKeyDetailPage(props: {
}),
clickhouse.verifications
.latest({ workspaceId: key.workspaceId, keySpaceId: key.keyAuthId, keyId: key.id })
.then((res) => res.at(0)?.time ?? 0),
.then((res) => res.val?.at(0)?.time ?? 0),
]);

const successOverTime: { x: string; y: number }[] = [];
Expand All @@ -108,7 +108,7 @@ export default async function APIKeyDetailPage(props: {
const expiredOverTime: { x: string; y: number }[] = [];
const forbiddenOverTime: { x: string; y: number }[] = [];

for (const d of verifications.sort((a, b) => a.time - b.time)) {
for (const d of verifications.val!.sort((a, b) => a.time - b.time)) {
const x = new Date(d.time).toISOString();
switch (d.outcome) {
case "":
Expand Down Expand Up @@ -174,7 +174,7 @@ export default async function APIKeyDetailPage(props: {
expired: 0,
forbidden: 0,
};
verifications.forEach((v) => {
verifications.val!.forEach((v) => {
switch (v.outcome) {
case "VALID":
stats.valid += v.count;
Expand Down Expand Up @@ -317,7 +317,7 @@ export default async function APIKeyDetailPage(props: {
</EmptyPlaceholder>
)}

{latestVerifications.length > 0 ? (
{latestVerifications.val && latestVerifications.val.length > 0 ? (
<>
<Separator className="my-8" />
<h2 className="text-2xl font-semibold leading-none tracking-tight mt-8">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export const VerificationTable = ({ verifications }: Props) => {
</TableRow>
</TableHeader>
<TableBody className={"font-mono"}>
{verifications.map((verification, i) => {
{verifications.val?.map((verification, i) => {
/**
* Instead of rounding every row individually, we want to round consecutive colored rows together.
* For example:
Expand All @@ -54,10 +54,10 @@ export const VerificationTable = ({ verifications }: Props) => {
*/
const isStartOfColoredBlock =
verification.outcome !== "VALID" &&
(i === 0 || verifications[i - 1].outcome === "VALID");
(i === 0 || verifications.val[i - 1].outcome === "VALID");
const isEndOfColoredBlock =
verification.outcome !== "VALID" &&
(i === verifications.length - 1 || verifications[i + 1].outcome === "VALID");
(i === verifications.val.length - 1 || verifications.val[i + 1].outcome === "VALID");

return (
<TableRow
Expand Down
8 changes: 4 additions & 4 deletions apps/dashboard/app/(app)/apis/[apiId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,22 +54,22 @@ export default async function ApiPage(props: {
.where(and(eq(schema.keys.keyAuthId, api.keyAuthId!), isNull(schema.keys.deletedAt)))
.execute()
.then((res) => res.at(0)?.count ?? 0),
getVerificationsPerInterval(query),
getActiveKeysPerInterval(query),
getVerificationsPerInterval(query).then((res) => res.val!),
getActiveKeysPerInterval(query).then((res) => res.val!),
clickhouse.activeKeys
.perMonth({
workspaceId: api.workspaceId,
keySpaceId: api.keyAuthId!,
start: billingCycleStart,
end: billingCycleEnd,
})
.then((res) => res.at(0)),
.then((res) => res.val!.at(0)),
getVerificationsPerInterval({
workspaceId: api.workspaceId,
keySpaceId: api.keyAuthId!,
start: billingCycleStart,
end: billingCycleEnd,
}),
}).then((res) => res.val!),
]);

const successOverTime: { x: string; y: number }[] = [];
Expand Down
2 changes: 1 addition & 1 deletion apps/dashboard/app/(app)/identities/[identityId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ const LastUsed: React.FC<{ workspaceId: string; keySpaceId: string; keyId: strin
keySpaceId: props.keySpaceId,
keyId: props.keyId,
})
.then((res) => res.at(0)?.time ?? null);
.then((res) => res.val?.at(0)?.time ?? null);

return (
<TableCell>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,10 +122,7 @@ const AuditLogTable: React.FC<{

success: selected.success ?? undefined,
};
const logs = await clickhouse.ratelimits.logs(query).catch((err) => {
console.error(err);
throw err;
});
const logs = await clickhouse.ratelimits.logs(query).then((res) => res.val!);

if (logs.length === 0) {
return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ const LastUsed: React.FC<{
identifier: [identifier],
});

const unixMilli = lastUsed.at(0)?.time;
const unixMilli = lastUsed.val?.at(0)?.time;
if (unixMilli) {
return <span className="text-sm text-content-subtle">{ms(Date.now() - unixMilli)} ago</span>;
}
Expand Down
8 changes: 4 additions & 4 deletions apps/dashboard/app/(app)/ratelimits/[namespaceId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,17 +68,17 @@ export default async function RatelimitNamespacePage(props: {
.from(schema.ratelimitOverrides)
.where(eq(schema.ratelimitOverrides.namespaceId, namespace.id))
.execute()
.then((res) => res.at(0)?.count ?? 0),
getRatelimitsPerInterval(query),
.then((res) => res?.at(0)?.count ?? 0),
getRatelimitsPerInterval(query).then((res) => res.val!),
getRatelimitsPerInterval({
workspaceId: namespace.workspaceId,
namespaceId: namespace.id,
start: billingCycleStart,
end: billingCycleEnd,
}),
}).then((res) => res.val!),
clickhouse.ratelimits
.latest({ workspaceId: namespace.workspaceId, namespaceId: namespace.id })
.then((res) => res.at(0)?.time),
.then((res) => res.val?.at(0)?.time),
]);

const passedOverTime: { x: string; y: number }[] = [];
Expand Down
16 changes: 9 additions & 7 deletions apps/dashboard/app/(app)/ratelimits/card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,17 @@ export const RatelimitCard: React.FC<Props> = async ({ workspace, namespace }) =
const intervalMs = 1000 * 60 * 60;

const [history, lastUsed] = await Promise.all([
clickhouse.ratelimits.perMinute({
workspaceId: workspace.id,
namespaceId: namespace.id,
start: end - intervalMs,
end,
}),
clickhouse.ratelimits
.perMinute({
workspaceId: workspace.id,
namespaceId: namespace.id,
start: end - intervalMs,
end,
})
.then((res) => res.val!),
clickhouse.ratelimits
.latest({ workspaceId: workspace.id, namespaceId: namespace.id })
.then((res) => res.at(0)?.time),
.then((res) => res.val?.at(0)?.time),
]);

const totalRequests = history.reduce((sum, d) => sum + d.total, 0);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,13 @@ export default async function HistoryPage(props: {
if (!key?.keyAuth?.api) {
return notFound();
}
const history = await clickhouse.verifications.logs({
workspaceId: UNKEY_WORKSPACE_ID,
keySpaceId: key.keyAuthId,
keyId: key.id,
});
const history = await clickhouse.verifications
.logs({
workspaceId: UNKEY_WORKSPACE_ID,
keySpaceId: key.keyAuthId,
keyId: key.id,
})
.then((res) => res.val!);

return <AccessTable verifications={history} />;
}
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ const LastUsed: React.FC<{ workspaceId: string; keySpaceId: string; keyId: strin
}) => {
const lastUsed = await clickhouse.verifications
.latest({ workspaceId, keySpaceId, keyId })
.then((res) => res.at(0)?.time ?? 0);
.then((res) => res.val?.at(0)?.time ?? 0);

return (
<Metric label="Last Used" value={lastUsed ? `${ms(Date.now() - lastUsed)} ago` : "Never"} />
Expand Down
12 changes: 7 additions & 5 deletions apps/dashboard/app/(app)/settings/root-keys/[keyId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,11 +91,13 @@ export default async function RootKeyPage(props: {
if (!keyForHistory?.keyAuth?.api) {
return notFound();
}
const history = await clickhouse.verifications.latest({
workspaceId: UNKEY_WORKSPACE_ID,
keySpaceId: key.keyAuthId,
keyId: key.id,
});
const history = await clickhouse.verifications
.latest({
workspaceId: UNKEY_WORKSPACE_ID,
keySpaceId: key.keyAuthId,
keyId: key.id,
})
.then((res) => res.val!);

const apis = workspace.apis.map((api) => {
const apiPermissionsStructure = apiPermissions(api.id);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import type { Permission } from "@unkey/db";
import { type PropsWithChildren, useMemo, useState } from "react";
import { type PropsWithChildren, useState } from "react";
import { PermissionToggle } from "./permission_toggle";
import { apiPermissions } from "./permissions";

Expand Down Expand Up @@ -38,10 +38,7 @@ export function DialogAddPermissionsForAPI(
});

const [selectedApiId, setSelectedApiId] = useState<string>("");
const selectedApi = useMemo(
() => props.apis.find((api) => api.id === selectedApiId),
[selectedApiId],
);
const selectedApi = props.apis.find((api) => api.id === selectedApiId);

const isSelectionDisabled =
selectedApi && !apisWithoutPermission.some((api) => api.id === selectedApi.id);
Expand Down
2 changes: 1 addition & 1 deletion apps/dashboard/app/(app)/success/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export default async function SuccessPage() {
});
}

const activeWorkspaces = await clickhouse.business.activeWorkspaces();
const activeWorkspaces = await clickhouse.business.activeWorkspaces().then((res) => res.val!);
const chartData = activeWorkspaces.map(({ time, workspaces }) => ({
x: new Date(time).toLocaleDateString(),
y: workspaces,
Expand Down
1 change: 1 addition & 0 deletions internal/clickhouse/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
},
"dependencies": {
"@clickhouse/client-web": "^1.6.0",
"@unkey/error": "workspace:^",
"zod": "^3.23.8"
}
}
8 changes: 4 additions & 4 deletions internal/clickhouse/src/billing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,10 @@ export function getBillableRatelimits(ch: Querier) {
});

const res = await query(args);
if (!res) {
if (res.err || res.val.length === 0) {
return 0;
}
return res.at(0)?.count ?? 0;
return res.val.at(0)?.count ?? 0;
};
}
// get the billable verifications for a workspace in a specific month.
Expand Down Expand Up @@ -65,9 +65,9 @@ export function getBillableVerifications(ch: Querier) {
});

const res = await query(args);
if (!res) {
if (res.err || res.val.length === 0) {
return 0;
}
return res.at(0)?.count ?? 0;
return res.val.at(0)?.count ?? 0;
};
}
48 changes: 36 additions & 12 deletions internal/clickhouse/src/client/client.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { type ClickHouseClient, createClient } from "@clickhouse/client-web";
import { Err, Ok, type Result } from "@unkey/error";
import { z } from "zod";
import { InsertError, QueryError } from "./error";
import type { Inserter, Querier } from "./interface";

export type Config = {
url: string;
};
Expand All @@ -12,6 +13,7 @@ export class Client implements Querier, Inserter {
constructor(config: Config) {
this.client = createClient({
url: config.url,

clickhouse_settings: {
async_insert: 1,
wait_for_async_insert: 1,
Expand All @@ -32,11 +34,11 @@ export class Client implements Querier, Inserter {
// The schema of the output of each row
// Example: z.object({ id: z.string() })
schema: TOut;
}): (params: z.input<TIn>) => Promise<z.output<TOut>[]> {
return async (params: z.input<TIn>): Promise<z.output<TOut>[]> => {
}): (params: z.input<TIn>) => Promise<Result<z.output<TOut>[], QueryError>> {
return async (params: z.input<TIn>): Promise<Result<z.output<TOut>[], QueryError>> => {
const validParams = req.params?.safeParse(params);
if (validParams?.error) {
throw new Error(`Bad params: ${validParams.error.message}`);
return Err(new QueryError(`Bad params: ${validParams.error.message}`, { query: "" }));
}
const res = await this.client
.query({
Expand All @@ -48,7 +50,11 @@ export class Client implements Querier, Inserter {
throw new Error(`${err.message} ${req.query}, params: ${JSON.stringify(params)}`);
});
const rows = await res.json();
return z.array(req.schema).parse(rows);
const parsed = z.array(req.schema).safeParse(rows);
if (parsed.error) {
return Err(new QueryError(`Malformed data: ${parsed.error.message}`, { query: req.query }));
}
return Ok(parsed.data);
};
}

Expand All @@ -57,22 +63,40 @@ export class Client implements Querier, Inserter {
schema: TSchema;
}): (
events: z.input<TSchema> | z.input<TSchema>[],
) => Promise<{ executed: boolean; query_id: string }> {
) => Promise<Result<{ executed: boolean; query_id: string }, InsertError>> {
return async (events: z.input<TSchema> | z.input<TSchema>[]) => {
let validatedEvents: z.output<TSchema> | z.output<TSchema>[] | undefined = undefined;
const v = Array.isArray(events)
? req.schema.array().safeParse(events)
: req.schema.safeParse(events);
if (!v.success) {
throw new Error(v.error.message);
return Err(new InsertError(v.error.message));
}
validatedEvents = v.data;

return await this.client.insert({
table: req.table,
format: "JSONEachRow",
values: Array.isArray(validatedEvents) ? validatedEvents : [validatedEvents],
});
return this.retry(() =>
this.client
.insert({
table: req.table,
format: "JSONEachRow",
values: Array.isArray(validatedEvents) ? validatedEvents : [validatedEvents],
})
.then((res) => Ok(res))
.catch((err) => Err(new InsertError(err.message))),
);
};
}

private async retry<T>(fn: (attempt: number) => Promise<T>): Promise<T> {
let err: Error | undefined = undefined;
for (let i = 1; i <= 3; i++) {
try {
return fn(i);
} catch (e) {
console.warn(e);
err = e as Error;
}
}
throw err;
}
}
Loading

0 comments on commit 707f0d4

Please sign in to comment.