Openstatus www.openstatus.dev

feat: rum config package (#790)

* feat: rum config package

* chore: update metrics label

* feat: category bar

* chore: clean up

authored by

Maximilian Kaske and committed by
GitHub
4dfa3c9b a1b23a62

+385 -16
+1
apps/web/package.json
··· 30 30 "@openstatus/ui": "workspace:*", 31 31 "@openstatus/upstash": "workspace:*", 32 32 "@openstatus/utils": "workspace:*", 33 + "@openstatus/rum": "workspace:*", 33 34 "@sentry/integrations": "7.100.1", 34 35 "@sentry/nextjs": "7.100.1", 35 36 "@stripe/stripe-js": "2.1.6",
+112
apps/web/src/app/app/[workspaceSlug]/(dashboard)/rum/_components/category-bar.tsx
··· 1 + // TODO: move to @/components folder 2 + 3 + import React from "react"; 4 + 5 + import { 6 + Tooltip, 7 + TooltipContent, 8 + TooltipProvider, 9 + TooltipTrigger, 10 + } from "@openstatus/ui"; 11 + 12 + import { cn } from "@/lib/utils"; 13 + 14 + const MAX_VALUE_RATIO = 1.3; // avoiding Infinity as number 15 + 16 + interface CategoryBarProps { 17 + values: { 18 + color: string; 19 + min: number; 20 + max: number; 21 + }[]; 22 + marker: number; 23 + } 24 + 25 + export function CategoryBar({ values, marker }: CategoryBarProps) { 26 + const getMarkerColor = React.useCallback(() => { 27 + for (const value of values) { 28 + if (marker >= value.min && marker <= value.max) { 29 + return value.color; 30 + } 31 + } 32 + return "bg-gray-500"; 33 + }, [values, marker]); 34 + 35 + /** 36 + * Get the max value from the values array 37 + * If the max value is not finite, calculate the max value based on the ratio 38 + */ 39 + const getMaxValue = React.useCallback(() => { 40 + const maxValue = values.reduce((acc, value) => { 41 + if (Number.isFinite(value.max)) return Math.max(acc, value.max); 42 + return acc * MAX_VALUE_RATIO; 43 + }, 0); 44 + return Math.max(maxValue, marker); 45 + }, [values, marker]); 46 + 47 + const valuesWithPercentage = React.useMemo( 48 + () => 49 + values.map((value) => { 50 + const max = Number.isFinite(value.max) ? value.max : getMaxValue(); 51 + return { 52 + ...value, 53 + percentage: (max - value.min) / getMaxValue(), 54 + }; 55 + }), 56 + [values, getMaxValue], 57 + ); 58 + 59 + return ( 60 + <div className="relative w-full"> 61 + <div className="relative mb-1 flex w-full"> 62 + <div className="text-muted-foreground absolute bottom-0 left-0 flex items-center text-xs"> 63 + 0 64 + </div> 65 + {valuesWithPercentage.slice(0, values.length - 1).map((value, i) => { 66 + const width = `${(value.percentage * 100).toFixed(2)}%`; 67 + return ( 68 + <div 69 + key={i} 70 + className="flex items-center justify-end" 71 + style={{ width }} 72 + > 73 + <span className="text-muted-foreground left-1/2 translate-x-1/2 text-xs"> 74 + {value.max} 75 + </span> 76 + </div> 77 + ); 78 + })} 79 + {/* REMINDER: could be a thing - only display if maxValue !== Infinity */} 80 + <div className="text-muted-foreground absolute bottom-0 right-0 flex items-center text-xs"> 81 + {getMaxValue()} 82 + </div> 83 + </div> 84 + <div className="flex h-3 w-full overflow-hidden rounded-full"> 85 + {valuesWithPercentage.map((value, i) => { 86 + const width = `${(value.percentage * 100).toFixed(2)}%`; 87 + return <div key={i} className={cn(value.color)} style={{ width }} />; 88 + })} 89 + </div> 90 + <div 91 + className="absolute -bottom-0.5 right-1/2 w-5 -translate-x-1/2" 92 + style={{ left: `${(marker / getMaxValue()) * 100}%` }} 93 + > 94 + <TooltipProvider> 95 + <Tooltip> 96 + <TooltipTrigger asChild> 97 + <div 98 + className={cn( 99 + "ring-border mx-auto h-4 w-1 rounded-full ring-2", 100 + getMarkerColor(), 101 + )} 102 + /> 103 + </TooltipTrigger> 104 + <TooltipContent> 105 + <p>{marker}</p> 106 + </TooltipContent> 107 + </Tooltip> 108 + </TooltipProvider> 109 + </div> 110 + </div> 111 + ); 112 + }
+21 -9
apps/web/src/app/app/[workspaceSlug]/(dashboard)/rum/_components/rum-metric-card.tsx
··· 1 1 import { Card } from "@tremor/react"; 2 2 3 + import { getColorByType, webVitalsConfig } from "@openstatus/rum"; 4 + import type { WebVitalEvents, WebVitalsValues } from "@openstatus/rum"; 5 + 3 6 import { api } from "@/trpc/server"; 7 + import { CategoryBar } from "./category-bar"; 4 8 5 - export const RUMMetricCard = async ({ 6 - event, 7 - }: { 8 - event: "CLS" | "FCP" | "FID" | "INP" | "LCP" | "TTFB"; 9 - }) => { 10 - const data = await api.rumRouter.GetEventMetricsForWorkspace.query({ 11 - event: event, 12 - }); 9 + function prepareWebVitalValues(values: WebVitalsValues) { 10 + return values.map((value) => ({ 11 + ...value, 12 + color: getColorByType(value.type), 13 + })); 14 + } 15 + 16 + export const RUMMetricCard = async ({ event }: { event: WebVitalEvents }) => { 17 + const data = await api.rumRouter.GetEventMetricsForWorkspace.query({ event }); 18 + const eventConfig = webVitalsConfig[event]; 13 19 return ( 14 20 <Card> 15 - <p className="text-muted-foreground text-sm">{event}</p> 21 + <p className="text-muted-foreground text-sm"> 22 + {eventConfig.label} ({event}) 23 + </p> 16 24 <p className="text-foreground text-3xl font-semibold"> 17 25 {data?.median || 0} 18 26 </p> 27 + <CategoryBar 28 + values={prepareWebVitalValues(eventConfig.values)} 29 + marker={data?.median || 0} 30 + /> 19 31 </Card> 20 32 ); 21 33 };
+5 -7
apps/web/src/app/app/[workspaceSlug]/(dashboard)/rum/page.tsx
··· 2 2 import Link from "next/link"; 3 3 import { notFound } from "next/navigation"; 4 4 5 + import { webVitalEvents } from "@openstatus/rum"; 5 6 import { Button } from "@openstatus/ui"; 6 7 7 8 import { EmptyState } from "@/components/dashboard/empty-state"; ··· 38 39 } 39 40 40 41 return ( 41 - <div className="grid grid-cols-1 gap-2 md:grid-cols-4"> 42 - <RUMMetricCard event="CLS" /> 43 - <RUMMetricCard event="FCP" /> 44 - <RUMMetricCard event="FID" /> 45 - <RUMMetricCard event="INP" /> 46 - <RUMMetricCard event="LCP" /> 47 - <RUMMetricCard event="TTFB" /> 42 + <div className="grid grid-cols-1 gap-2 md:grid-cols-2 lg:grid-cols-4"> 43 + {webVitalEvents.map((event) => ( 44 + <RUMMetricCard key={event} event={event} /> 45 + ))} 48 46 </div> 49 47 ); 50 48 }
+17
packages/rum/package.json
··· 1 + { 2 + "name": "@openstatus/rum", 3 + "version": "1.0.0", 4 + "description": "", 5 + "main": "src/index.ts", 6 + "scripts": {}, 7 + "dependencies": { 8 + "zod": "3.22.4" 9 + }, 10 + "devDependencies": { 11 + "@openstatus/tsconfig": "workspace:*", 12 + "typescript": "5.4.5" 13 + }, 14 + "keywords": [], 15 + "author": "", 16 + "license": "ISC" 17 + }
+169
packages/rum/src/config.ts
··· 1 + import type { WebVitalsConfig } from "./types"; 2 + 3 + export const webVitalEvents = [ 4 + "CLS", 5 + "FCP", 6 + "FID", 7 + "INP", 8 + "LCP", 9 + "TTFB", 10 + ] as const; 11 + 12 + export const webVitalsConfig: WebVitalsConfig = { 13 + CLS: { 14 + unit: "", 15 + label: "Cumulative Layout Shift", 16 + description: 17 + "CLS measures the sum of all individual layout shift scores for every unexpected layout shift that occurs during the entire lifespan of the page.", 18 + values: [ 19 + { 20 + type: "good", 21 + label: "Good", 22 + min: 0, 23 + max: 0.1, 24 + }, 25 + { 26 + type: "needs-improvement", 27 + label: "Needs Improvement", 28 + min: 0.1, 29 + max: 0.25, 30 + }, 31 + { 32 + type: "poor", 33 + label: "Poor", 34 + min: 0.25, 35 + max: Infinity, 36 + }, 37 + ], 38 + }, 39 + FCP: { 40 + unit: "ms", 41 + label: "First Contentful Paint", 42 + description: 43 + "FCP measures the time from when the page starts loading to when any part of the page's content is rendered on the screen.", 44 + values: [ 45 + { 46 + type: "good", 47 + label: "Good", 48 + min: 0, 49 + max: 1000, 50 + }, 51 + { 52 + type: "needs-improvement", 53 + label: "Needs Improvement", 54 + min: 1000, 55 + max: 2500, 56 + }, 57 + { 58 + type: "poor", 59 + label: "Poor", 60 + min: 2500, 61 + max: Infinity, 62 + }, 63 + ], 64 + }, 65 + FID: { 66 + unit: "ms", 67 + label: "First Input Delay", 68 + description: 69 + "FID measures the time from when a user first interacts with a page to the time when the browser is actually able to respond to that interaction.", 70 + values: [ 71 + { 72 + type: "good", 73 + label: "Good", 74 + min: 0, 75 + max: 100, 76 + }, 77 + { 78 + type: "needs-improvement", 79 + label: "Needs Improvement", 80 + min: 100, 81 + max: 300, 82 + }, 83 + { 84 + type: "poor", 85 + label: "Poor", 86 + min: 300, 87 + max: Infinity, 88 + }, 89 + ], 90 + }, 91 + INP: { 92 + unit: "ms", 93 + label: "Input Delay", 94 + description: 95 + "INP measures the time from when a user first interacts with a page to the time when the browser is actually able to respond to that interaction.", 96 + values: [ 97 + { 98 + type: "good", 99 + label: "Good", 100 + min: 0, 101 + max: 50, 102 + }, 103 + { 104 + type: "needs-improvement", 105 + label: "Needs Improvement", 106 + min: 50, 107 + max: 250, 108 + }, 109 + { 110 + type: "poor", 111 + label: "Poor", 112 + min: 250, 113 + max: Infinity, 114 + }, 115 + ], 116 + }, 117 + LCP: { 118 + unit: "ms", 119 + label: "Largest Contentful Paint", 120 + description: 121 + "LCP measures the time from when the page starts loading to when the largest content element is rendered on the screen.", 122 + values: [ 123 + { 124 + type: "good", 125 + label: "Good", 126 + min: 0, 127 + max: 2500, 128 + }, 129 + { 130 + type: "needs-improvement", 131 + label: "Needs Improvement", 132 + min: 2500, 133 + max: 4000, 134 + }, 135 + { 136 + type: "poor", 137 + label: "Poor", 138 + min: 4000, 139 + max: Infinity, 140 + }, 141 + ], 142 + }, 143 + TTFB: { 144 + unit: "ms", 145 + label: "Time to First Byte", 146 + description: 147 + "TTFB measures the time from when the browser starts requesting a page to when the first byte of the page is received by the browser.", 148 + values: [ 149 + { 150 + type: "good", 151 + label: "Good", 152 + min: 0, 153 + max: 200, 154 + }, 155 + { 156 + type: "needs-improvement", 157 + label: "Needs Improvement", 158 + min: 200, 159 + max: 500, 160 + }, 161 + { 162 + type: "poor", 163 + label: "Poor", 164 + min: 500, 165 + max: Infinity, 166 + }, 167 + ], 168 + }, 169 + };
+3
packages/rum/src/index.ts
··· 1 + export * from "./config"; 2 + export * from "./utils"; 3 + export * from "./types";
+22
packages/rum/src/types.ts
··· 1 + import type { webVitalEvents } from "./config"; 2 + 3 + export type WebVitalEvents = (typeof webVitalEvents)[number]; 4 + 5 + export type WebVitalsValueTypes = "good" | "needs-improvement" | "poor"; 6 + 7 + export type WebVitalsValues = { 8 + type: WebVitalsValueTypes; 9 + label: string; 10 + min: number; 11 + max: number; 12 + }[]; 13 + 14 + export type WebVitalsConfig = Record< 15 + WebVitalEvents, 16 + { 17 + unit: string; 18 + label: string; 19 + description: string; 20 + values: WebVitalsValues; 21 + } 22 + >;
+15
packages/rum/src/utils.ts
··· 1 + import type { WebVitalsValueTypes } from "./types"; 2 + 3 + export function getColorByType(type: WebVitalsValueTypes) { 4 + switch (type) { 5 + case "good": 6 + return "bg-green-500"; 7 + case "needs-improvement": 8 + return "bg-yellow-500"; 9 + case "poor": 10 + return "bg-rose-500"; 11 + default: 12 + const _check: never = type; 13 + return _check; 14 + } 15 + }
+4
packages/rum/tsconfig.json
··· 1 + { 2 + "extends": "@openstatus/tsconfig/base.json", 3 + "include": ["src", "*.ts"] 4 + }
+16
pnpm-lock.yaml
··· 272 272 '@openstatus/react': 273 273 specifier: workspace:* 274 274 version: link:../../packages/react 275 + '@openstatus/rum': 276 + specifier: workspace:* 277 + version: link:../../packages/rum 275 278 '@openstatus/tinybird': 276 279 specifier: workspace:* 277 280 version: link:../../packages/tinybird ··· 890 893 tsup: 891 894 specifier: 7.2.0 892 895 version: 7.2.0(postcss@8.4.31)(ts-node@10.9.1(@types/node@20.8.0)(typescript@5.4.5))(typescript@5.4.5) 896 + typescript: 897 + specifier: 5.4.5 898 + version: 5.4.5 899 + 900 + packages/rum: 901 + dependencies: 902 + zod: 903 + specifier: 3.22.4 904 + version: 3.22.4 905 + devDependencies: 906 + '@openstatus/tsconfig': 907 + specifier: workspace:* 908 + version: link:../tsconfig 893 909 typescript: 894 910 specifier: 5.4.5 895 911 version: 5.4.5