Openstatus www.openstatus.dev

๐Ÿ”ฅ Improve rum page (#843)

* ๐Ÿšง wip rum

* ๐Ÿšง wip rum

* ๐Ÿšง wip rum

* ๐Ÿšง wip rum

* ๐Ÿ”ฅ rum

authored by

Thibault Le Ouay and committed by
GitHub
e6990cf3 9a16cc8d

+383 -67
+2
apps/ingest-worker/src/index.ts
··· 119 119 const data = z.array(schemaV1).parse(JSON.parse(rawText)); 120 120 const userAgent = c.req.header("user-agent") || ""; 121 121 122 + const timestamp = Date.now(); 122 123 const country = c.req.header("cf-ipcountry") || ""; 123 124 const city = c.req.raw.cf?.city || ""; 124 125 const region_code = c.req.raw.cf?.regionCode || ""; ··· 130 131 const device = getDevice(d.screen, os); 131 132 return tbIngestWebVitals.parse({ 132 133 ...d, 134 + timestamp, 133 135 device, 134 136 ...d.data, 135 137 browser,
+7 -5
apps/ingest-worker/src/utils.ts
··· 45 45 return "laptop"; 46 46 } 47 47 return "desktop"; 48 - } else if (MOBILE_OS.includes(os)) { 48 + } 49 + if (MOBILE_OS.includes(os)) { 49 50 if (os === "Amazon OS" || +width > MOBILE_SCREEN_WIDTH) { 50 51 return "tablet"; 51 52 } ··· 54 55 55 56 if (+width >= DESKTOP_SCREEN_WIDTH) { 56 57 return "desktop"; 57 - } else if (+width >= LAPTOP_SCREEN_WIDTH) { 58 + } 59 + if (+width >= LAPTOP_SCREEN_WIDTH) { 58 60 return "laptop"; 59 - } else if (+width >= MOBILE_SCREEN_WIDTH) { 61 + } 62 + if (+width >= MOBILE_SCREEN_WIDTH) { 60 63 return "tablet"; 61 - } else { 62 - return "mobile"; 63 64 } 65 + return "mobile"; 64 66 }
+12
apps/web/src/app/app/[workspaceSlug]/(dashboard)/rum/_components/data-table-wrapper.tsx
··· 1 + import { columns } from "@/components/data-table/rum/columns"; 2 + import { DataTable } from "@/components/data-table/rum/data-table"; 3 + import type { responseRumPageQuery } from "@openstatus/tinybird/src/validation"; 4 + import type { z } from "zod"; 5 + 6 + export const DataTableWrapper = ({ 7 + data, 8 + }: { 9 + data: z.infer<typeof responseRumPageQuery>[]; 10 + }) => { 11 + return <DataTable columns={columns} data={data} />; 12 + };
+18
apps/web/src/app/app/[workspaceSlug]/(dashboard)/rum/_components/request-button/action.ts
··· 1 + "use server"; 2 + 3 + import { auth } from "@/lib/auth"; 4 + import { analytics, trackAnalytics } from "@openstatus/analytics"; 5 + import { Redis } from "@upstash/redis"; 6 + const redis = Redis.fromEnv(); 7 + 8 + export const RequestAccessToRum = async () => { 9 + const session = await auth(); 10 + if (!session?.user) return; 11 + 12 + await redis.sadd("rum_access_requested", session.user.email); 13 + await analytics.identify(session.user.id, { email: session.user.email }); 14 + await trackAnalytics({ 15 + event: "User RUM Beta Requested", 16 + email: session.user.email || "", 17 + }); 18 + };
+34
apps/web/src/app/app/[workspaceSlug]/(dashboard)/rum/_components/request-button/request-button.tsx
··· 1 + "use client"; 2 + import { auth } from "@/lib/auth"; 3 + import { Button } from "@openstatus/ui"; 4 + import { Redis } from "@upstash/redis"; 5 + import { useState } from "react"; 6 + import { RequestAccessToRum } from "./action"; 7 + 8 + export const RequestButton = async ({ 9 + hasRequestAccess, 10 + }: { 11 + hasRequestAccess: number; 12 + }) => { 13 + const [accessRequested, setAccessRequested] = useState(hasRequestAccess); 14 + if (accessRequested) { 15 + return ( 16 + <Button> 17 + <span>Access requested</span> 18 + </Button> 19 + ); 20 + } 21 + 22 + return ( 23 + <Button 24 + onClick={async () => { 25 + // const session = await auth(); 26 + // if (!session?.user) return; 27 + await RequestAccessToRum(); 28 + setAccessRequested(1); 29 + }} 30 + > 31 + Request access 32 + </Button> 33 + ); 34 + };
+10 -19
apps/web/src/app/app/[workspaceSlug]/(dashboard)/rum/_components/route-table.tsx
··· 1 1 import { 2 2 Table, 3 + TableBody, 3 4 TableCaption, 5 + TableCell, 4 6 TableHead, 5 7 TableHeader, 6 8 TableRow, 7 9 } from "@openstatus/ui"; 8 10 9 11 import { api } from "@/trpc/server"; 12 + import { timeFormater } from "./util"; 13 + import { DataTableWrapper } from "./data-table-wrapper"; 10 14 11 - const RouteTable = async () => { 12 - const data = await api.rumRouter.GetAggregatedPerPage.query(); 15 + const RouteTable = async ({ dsn }: { dsn: string }) => { 16 + const data = await api.tinybird.rumMetricsForApplicationPerPage.query({ 17 + dsn: dsn, 18 + }); 13 19 if (!data) { 14 20 return null; 15 21 } 22 + console.log(data.length); 16 23 return ( 17 24 <div className=""> 18 - <h2 className="font-semibold text-lg">Page Performance</h2> 19 - <div className=""> 20 - <Table> 21 - <TableCaption>An overview of your page performance.</TableCaption> 22 - <TableHeader> 23 - <TableRow className="sticky top-0"> 24 - <TableHead className="w-4 max-w-6">Page</TableHead> 25 - <TableHead>Total Events</TableHead> 26 - <TableHead>CLS</TableHead> 27 - <TableHead>FCP</TableHead> 28 - <TableHead>INP</TableHead> 29 - <TableHead>LCP</TableHead> 30 - <TableHead>TTFB</TableHead> 31 - </TableRow> 32 - </TableHeader> 33 - </Table> 34 - </div> 25 + <DataTableWrapper data={data} /> 35 26 </div> 36 27 ); 37 28 };
+27 -6
apps/web/src/app/app/[workspaceSlug]/(dashboard)/rum/_components/rum-metric-card.tsx
··· 5 5 6 6 import { api } from "@/trpc/server"; 7 7 import { CategoryBar } from "./category-bar"; 8 + import { date } from "zod"; 9 + import { latencyFormatter } from "@/components/ping-response-analysis/utils"; 8 10 9 11 function prepareWebVitalValues(values: WebVitalsValues) { 10 12 return values.map((value) => ({ ··· 13 15 })); 14 16 } 15 17 16 - export const RUMMetricCard = async ({ event }: { event: WebVitalEvents }) => { 17 - const data = await api.rumRouter.GetEventMetricsForWorkspace.query({ event }); 18 + const RUMCard = async ({ 19 + event, 20 + value, 21 + }: { 22 + event: WebVitalEvents; 23 + value: number; 24 + }) => { 18 25 const eventConfig = webVitalsConfig[event]; 19 26 return ( 20 27 <Card> 21 - {/* <p className="text-muted-foreground text-sm"> 28 + <p className="text-muted-foreground text-sm"> 22 29 {eventConfig.label} ({event}) 23 30 </p> 24 31 <p className="font-semibold text-3xl text-foreground"> 25 - {data?.median.toFixed(2) || 0} 32 + {event !== "CLS" ? value.toFixed(0) : value.toFixed(2) || 0} 26 33 </p> 27 34 <CategoryBar 28 35 values={prepareWebVitalValues(eventConfig.values)} 29 - marker={data?.median || 0} 30 - /> */} 36 + marker={value || 0} 37 + /> 31 38 </Card> 32 39 ); 33 40 }; 41 + 42 + export const RUMMetricCards = async ({ dsn }: { dsn: string }) => { 43 + const data = await api.tinybird.totalRumMetricsForApplication.query({ 44 + dsn: dsn, 45 + }); 46 + return ( 47 + <div className="grid grid-cols-1 gap-2 lg:grid-cols-4 md:grid-cols-2"> 48 + <RUMCard event="CLS" value={data?.cls || 0} /> 49 + <RUMCard event="FCP" value={data?.fcp || 0} /> 50 + <RUMCard event="LCP" value={data?.lcp || 0} /> 51 + <RUMCard event="TTFB" value={data?.ttfb || 0} /> 52 + </div> 53 + ); 54 + };
+6
apps/web/src/app/app/[workspaceSlug]/(dashboard)/rum/_components/util.ts
··· 1 + export const timeFormater = (time: number) => { 2 + if (time < 1000) { 3 + return `${new Intl.NumberFormat("us").format(time).toString()}ms`; 4 + } 5 + return `${new Intl.NumberFormat("us").format(time / 1000).toString()}s`; 6 + };
+22 -22
apps/web/src/app/app/[workspaceSlug]/(dashboard)/rum/page.tsx
··· 8 8 import { EmptyState } from "@/components/dashboard/empty-state"; 9 9 import { api } from "@/trpc/server"; 10 10 import { RouteTable } from "./_components/route-table"; 11 - import { RUMMetricCard } from "./_components/rum-metric-card"; 11 + import { RUMMetricCards } from "./_components/rum-metric-card"; 12 + import { Redis } from "@upstash/redis"; 13 + import { auth } from "@/lib/auth"; 14 + import { RequestButton } from "./_components/request-button/request-button"; 12 15 13 16 export const dynamic = "force-dynamic"; 14 17 18 + const redis = Redis.fromEnv(); 19 + 15 20 export default async function RUMPage() { 16 - const workspace = await api.workspace.getWorkspace.query(); 17 - if (!workspace) { 18 - return notFound(); 19 - } 21 + const applications = await api.workspace.getApplicationWorkspaces.query(); 20 22 21 - if (workspace.dsn === null) { 23 + const session = await auth(); 24 + if (!session?.user) return null; 25 + 26 + const accessRequested = await redis.sismember( 27 + "rum_access_requested", 28 + session.user.email 29 + ); 30 + 31 + if (applications.length === 0) { 22 32 return ( 23 33 <EmptyState 24 34 icon="ratio" 25 35 title="Real User Monitoring" 26 36 description="The feature is currently in beta and will be released soon." 27 - action={ 28 - <Button asChild> 29 - <Link 30 - href="mailto:ping@openstatus.dev?subject=Real User Monitoring beta tester" 31 - target="_blank" 32 - > 33 - Contact Us 34 - </Link> 35 - </Button> 36 - } 37 + action={<RequestButton hasRequestAccess={accessRequested} />} 37 38 /> 38 39 ); 39 40 } 40 - 41 + // ATM We can only have access to one application 41 42 return ( 42 43 <> 43 - <div className="grid grid-cols-1 gap-2 lg:grid-cols-5 md:grid-cols-2"> 44 - {webVitalEvents 44 + <RUMMetricCards dsn={applications[0].dsn || ""} /> 45 + {/* {webVitalEvents 45 46 // Remove FID from the list of events because it's deprecated by google 46 47 .filter((v) => v !== "FID") 47 48 .map((event) => ( 48 49 <RUMMetricCard key={event} event={event} /> 49 - ))} 50 - </div> 50 + ))} */} 51 51 <div> 52 - <RouteTable /> 52 + <RouteTable dsn={applications[0].dsn || ""} /> 53 53 </div> 54 54 </> 55 55 );
+66
apps/web/src/components/data-table/rum/columns.tsx
··· 1 + "use client"; 2 + 3 + import type { ColumnDef } from "@tanstack/react-table"; 4 + import Link from "next/link"; 5 + 6 + import type { responseRumPageQuery } from "@openstatus/tinybird/src/validation"; 7 + import type { z } from "zod"; 8 + 9 + export const columns: ColumnDef<z.infer<typeof responseRumPageQuery>>[] = [ 10 + { 11 + accessorKey: "path", 12 + header: "Page", 13 + cell: ({ row }) => { 14 + return ( 15 + <Link 16 + href={"./status-reports/overview"} 17 + className="w-8 max-w-8 hover:underline" 18 + > 19 + <span className="truncate">{row.getValue("path")}</span> 20 + </Link> 21 + ); 22 + }, 23 + }, 24 + { 25 + accessorKey: "totalSession", 26 + header: "Total Session", 27 + cell: ({ row }) => { 28 + return <>{row.original.totalSession}</>; 29 + }, 30 + }, 31 + { 32 + accessorKey: "cls", 33 + header: "CLS", 34 + cell: ({ row }) => { 35 + return <code>{row.original.cls.toFixed(2)}</code>; 36 + }, 37 + }, 38 + { 39 + accessorKey: "fcp", 40 + header: "FCP", 41 + cell: ({ row }) => { 42 + return <code>{row.original.fcp.toFixed(0)}</code>; 43 + }, 44 + }, 45 + { 46 + accessorKey: "lcp", 47 + header: "LCP", 48 + cell: ({ row }) => { 49 + return <code>{row.original.lcp.toFixed(0)}</code>; 50 + }, 51 + }, 52 + { 53 + accessorKey: "ttfb", 54 + header: "TTFB", 55 + cell: ({ row }) => { 56 + return <code>{row.original.ttfb.toFixed(0)}</code>; 57 + }, 58 + }, 59 + // { 60 + // accessorKey: "updatedAt", 61 + // header: "Last Updated", 62 + // cell: ({ row }) => { 63 + // return <span>{formatDate(row.getValue("updatedAt"))}</span>; 64 + // }, 65 + // }, 66 + ];
+81
apps/web/src/components/data-table/rum/data-table.tsx
··· 1 + "use client"; 2 + 3 + import type { ColumnDef } from "@tanstack/react-table"; 4 + import { 5 + flexRender, 6 + getCoreRowModel, 7 + useReactTable, 8 + } from "@tanstack/react-table"; 9 + import * as React from "react"; 10 + 11 + import { 12 + Table, 13 + TableBody, 14 + TableCell, 15 + TableHead, 16 + TableHeader, 17 + TableRow, 18 + } from "@openstatus/ui"; 19 + 20 + interface DataTableProps<TData, TValue> { 21 + columns: ColumnDef<TData, TValue>[]; 22 + data: TData[]; 23 + } 24 + 25 + export function DataTable<TData, TValue>({ 26 + columns, 27 + data, 28 + }: DataTableProps<TData, TValue>) { 29 + const table = useReactTable({ 30 + data, 31 + columns, 32 + getCoreRowModel: getCoreRowModel(), 33 + }); 34 + 35 + return ( 36 + <div className="rounded-md border"> 37 + <Table> 38 + <TableHeader className="bg-muted/50"> 39 + {table.getHeaderGroups().map((headerGroup) => ( 40 + <TableRow key={headerGroup.id} className="hover:bg-transparent"> 41 + {headerGroup.headers.map((header) => { 42 + return ( 43 + <TableHead key={header.id}> 44 + {header.isPlaceholder 45 + ? null 46 + : flexRender( 47 + header.column.columnDef.header, 48 + header.getContext() 49 + )} 50 + </TableHead> 51 + ); 52 + })} 53 + </TableRow> 54 + ))} 55 + </TableHeader> 56 + <TableBody> 57 + {table.getRowModel().rows?.length ? ( 58 + table.getRowModel().rows.map((row) => ( 59 + <TableRow 60 + key={row.id} 61 + data-state={row.getIsSelected() && "selected"} 62 + > 63 + {row.getVisibleCells().map((cell) => ( 64 + <TableCell key={cell.id}> 65 + {flexRender(cell.column.columnDef.cell, cell.getContext())} 66 + </TableCell> 67 + ))} 68 + </TableRow> 69 + )) 70 + ) : ( 71 + <TableRow> 72 + <TableCell colSpan={columns.length} className="h-24 text-center"> 73 + No results. 74 + </TableCell> 75 + </TableRow> 76 + )} 77 + </TableBody> 78 + </Table> 79 + </div> 80 + ); 81 + }
+1 -1
package.json
··· 19 19 "turbo": "1.13.3", 20 20 "typescript": "5.4.5" 21 21 }, 22 - "packageManager": "pnpm@9.1.2", 22 + "packageManager": "pnpm@9.1.3", 23 23 "name": "openstatus", 24 24 "workspaces": [ 25 25 "apps/*",
+1 -1
packages/analytics/src/type.ts
··· 18 18 } 19 19 | { event: "User Upgraded"; email: string } 20 20 | { event: "User Signed In" } 21 - | { event: "User Vercel Beta" } 21 + | { event: "User RUM Beta Requested"; email: string } 22 22 | { event: "Notification Created"; provider: string } 23 23 | { event: "Subscribe to Status Page"; slug: string } 24 24 | { event: "Invitation Created"; emailTo: string; workspaceId: number };
+12 -1
packages/api/src/router/tinybird/index.ts
··· 29 29 url: z.string().url().optional(), 30 30 region: z.enum(flyRegions).optional(), 31 31 cronTimestamp: z.number().int().optional(), 32 - }), 32 + }) 33 33 ) 34 34 .query(async (opts) => { 35 35 return await tb.endpointResponseDetails("7d")(opts.input); 36 + }), 37 + 38 + totalRumMetricsForApplication: protectedProcedure 39 + .input(z.object({ dsn: z.string() })) 40 + .query(async (opts) => { 41 + return await tb.applicationRUMMetrics()(opts.input); 42 + }), 43 + rumMetricsForApplicationPerPage: protectedProcedure 44 + .input(z.object({ dsn: z.string() })) 45 + .query(async (opts) => { 46 + return await tb.applicationRUMMetricsPerPage()(opts.input); 36 47 }), 37 48 });
+14 -4
packages/api/src/router/workspace.ts
··· 5 5 6 6 import { and, eq, sql } from "@openstatus/db"; 7 7 import { 8 + application, 8 9 monitor, 9 10 notification, 10 11 page, 12 + selectApplicationSchema, 11 13 selectWorkspaceSchema, 12 14 user, 13 15 usersToWorkspaces, ··· 75 77 return selectWorkspaceSchema.parse(result); 76 78 }), 77 79 80 + getApplicationWorkspaces: protectedProcedure.query(async (opts) => { 81 + const result = await opts.ctx.db.query.application.findMany({ 82 + where: eq(application.workspaceId, opts.ctx.workspace.id), 83 + }); 84 + 85 + return selectApplicationSchema.array().parse(result); 86 + }), 87 + 78 88 getUserWorkspaces: protectedProcedure.query(async (opts) => { 79 89 const result = await opts.ctx.db.query.usersToWorkspaces.findMany({ 80 90 where: eq(usersToWorkspaces.userId, opts.ctx.user.id), ··· 114 124 await opts.ctx.db.query.usersToWorkspaces.findFirst({ 115 125 where: and( 116 126 eq(usersToWorkspaces.userId, opts.ctx.user.id), 117 - eq(usersToWorkspaces.workspaceId, opts.ctx.workspace.id), 127 + eq(usersToWorkspaces.workspaceId, opts.ctx.workspace.id) 118 128 ), 119 129 }); 120 130 ··· 131 141 .where( 132 142 and( 133 143 eq(usersToWorkspaces.userId, opts.input.id), 134 - eq(usersToWorkspaces.workspaceId, opts.ctx.workspace.id), 135 - ), 144 + eq(usersToWorkspaces.workspaceId, opts.ctx.workspace.id) 145 + ) 136 146 ) 137 147 .run(); 138 148 }), ··· 144 154 await opts.ctx.db.query.usersToWorkspaces.findFirst({ 145 155 where: and( 146 156 eq(usersToWorkspaces.userId, opts.ctx.user.id), 147 - eq(usersToWorkspaces.workspaceId, opts.ctx.workspace.id), 157 + eq(usersToWorkspaces.workspaceId, opts.ctx.workspace.id) 148 158 ), 149 159 }); 150 160
-4
packages/db/.env.example
··· 1 1 DATABASE_URL=file:./../../openstatus-dev.db 2 2 DATABASE_AUTH_TOKEN=any-token 3 - 4 - CLICKHOUSE_URL=http://localhost:8123 5 - CLICKHOUSE_USERNAME=default 6 - CLICKHOUSE_PASSWORD=
+1
packages/db/src/schema/applications/index.ts
··· 1 1 export * from "./application"; 2 + export * from "./validation";
+4
packages/db/src/schema/applications/validation.ts
··· 1 + import { createSelectSchema } from "drizzle-zod"; 2 + import { application } from "./application"; 3 + 4 + export const selectApplicationSchema = createSelectSchema(application);
+53 -4
packages/tinybird/src/os-client.ts
··· 4 4 import { flyRegions } from "@openstatus/utils"; 5 5 6 6 import type { tbIngestWebVitalsArray } from "./validation"; 7 - import { tbIngestWebVitals } from "./validation"; 7 + import { responseRumPageQuery, tbIngestWebVitals } from "./validation"; 8 8 9 9 const isProd = process.env.NODE_ENV === "production"; 10 10 ··· 112 112 opts?: { 113 113 cache?: RequestCache | undefined; 114 114 revalidate: number | undefined; 115 - }, // RETHINK: not the best way to handle it 115 + } // RETHINK: not the best way to handle it 116 116 ) => { 117 117 try { 118 118 const res = await this.tb.buildPipe({ ··· 171 171 172 172 endpointStatusPeriod( 173 173 period: "7d" | "45d", 174 - timezone: "UTC" = "UTC", // "EST" | "PST" | "CET" 174 + timezone: "UTC" = "UTC" // "EST" | "PST" | "CET" 175 175 ) { 176 176 const parameters = z.object({ monitorId: z.string() }); 177 177 ··· 180 180 opts?: { 181 181 cache?: RequestCache | undefined; 182 182 revalidate: number | undefined; 183 - }, // RETHINK: not the best way to handle it 183 + } // RETHINK: not the best way to handle it 184 184 ) => { 185 185 try { 186 186 const res = await this.tb.buildPipe({ ··· 335 335 datasource: "web_vitals__v0", 336 336 event: tbIngestWebVitals, 337 337 })(data); 338 + } 339 + 340 + applicationRUMMetrics() { 341 + const parameters = z.object({ dsn: z.string() }); 342 + 343 + return async (props: z.infer<typeof parameters>) => { 344 + try { 345 + const res = await this.tb.buildPipe({ 346 + pipe: "rum_total_query", 347 + parameters, 348 + data: z.object({ 349 + cls: z.number(), 350 + fcp: z.number(), 351 + fid: z.number(), 352 + lcp: z.number(), 353 + ttfb: z.number(), 354 + }), 355 + opts: { 356 + next: { 357 + revalidate: MIN_CACHE, 358 + }, 359 + }, 360 + })(props); 361 + return res.data[0]; 362 + } catch (e) { 363 + console.error(e); 364 + } 365 + }; 366 + } 367 + applicationRUMMetricsPerPage() { 368 + const parameters = z.object({ dsn: z.string() }); 369 + 370 + return async (props: z.infer<typeof parameters>) => { 371 + try { 372 + const res = await this.tb.buildPipe({ 373 + pipe: "rum_page_query", 374 + parameters, 375 + data: responseRumPageQuery, 376 + opts: { 377 + next: { 378 + revalidate: MIN_CACHE, 379 + }, 380 + }, 381 + })(props); 382 + return res.data; 383 + } catch (e) { 384 + console.error(e); 385 + } 386 + }; 338 387 } 339 388 } 340 389
+12
packages/tinybird/src/validation.ts
··· 21 21 region_code: z.string().default(""), 22 22 timezone: z.string().default(""), 23 23 os: z.string(), 24 + timestamp: z.number().int(), 25 + }); 26 + 27 + export const responseRumPageQuery = z.object({ 28 + href: z.string().url(), 29 + path: z.string(), 30 + totalSession: z.number(), 31 + cls: z.number(), 32 + fcp: z.number(), 33 + fid: z.number(), 34 + lcp: z.number(), 35 + ttfb: z.number(), 24 36 }); 25 37 26 38 export const tbIngestWebVitalsArray = z.array(tbIngestWebVitals);