Openstatus www.openstatus.dev

feat: tracking events on trpc and api (#1136)

* feat: tracking events on trpc and api

* fix: lock file

* chore: add noop

* chore: create middleware for modularity

* fix: env

* fix: revert middleware due to tsc ctx undefined

* chore: add env

* chore: remove unused env

* chore: missing env

* fix: test

* feat: add event props to api

* chore: blog post

* chore: update og image

* fix: invalid json body

* fix: typos

authored by

Maximilian Kaske and committed by
GitHub
832b3a2b b71c81af

+877 -746
+3
apps/server/.env.example
··· 11 11 12 12 SCREENSHOT_SERVICE_URL=http://your.endpoint 13 13 QSTASH_TOKEN=your_token 14 + 15 + OPENPANEL_CLIENT_ID= 16 + OPENPANEL_CLIENT_SECRET=
+2
apps/server/.env.test
··· 11 11 RESEND_API_KEY=test 12 12 SQLD_HTTP_AUTH=basic:token 13 13 SCREENSHOT_SERVICE_URL=http://your.endpoint 14 + OPENPANEL_CLIENT_ID=test 15 + OPENPANEL_CLIENT_SECRET=test
-2
apps/server/src/env.ts
··· 11 11 UPSTASH_REDIS_REST_TOKEN: z.string().min(1), 12 12 FLY_REGION: z.enum(flyRegions), 13 13 CRON_SECRET: z.string(), 14 - JITSU_WRITE_KEY: z.string().optional(), 15 - JITSU_HOST: z.string().optional(), 16 14 SCREENSHOT_SERVICE_URL: z.string(), 17 15 QSTASH_TOKEN: z.string(), 18 16 NODE_ENV: z.string().default("development"),
+3
apps/server/src/v1/incidents/put.ts
··· 3 3 import { and, db, eq } from "@openstatus/db"; 4 4 import { incidentTable } from "@openstatus/db/src/schema/incidents"; 5 5 6 + import { Events } from "@openstatus/analytics"; 6 7 import { HTTPException } from "hono/http-exception"; 7 8 import { openApiErrorResponses } from "../../libs/errors/openapi-error-responses"; 9 + import { trackMiddleware } from "../middleware"; 8 10 import type { incidentsApi } from "./index"; 9 11 import { IncidentSchema, ParamsSchema } from "./schema"; 10 12 ··· 13 15 tags: ["incident"], 14 16 description: "Update an incident", 15 17 path: "/:id", 18 + middleware: [trackMiddleware(Events.UpdateIncident)], 16 19 request: { 17 20 params: ParamsSchema, 18 21 body: {
+4 -2
apps/server/src/v1/index.ts
··· 3 3 import { cors } from "hono/cors"; 4 4 import { logger } from "hono/logger"; 5 5 6 + import type { WorkspacePlan } from "@openstatus/db/src/schema"; 6 7 import type { Limits } from "@openstatus/db/src/schema/plan/schema"; 7 8 import { handleError, handleZodError } from "../libs/errors"; 8 9 import { checkAPI } from "./check"; 9 10 import { incidentsApi } from "./incidents"; 10 - import { middleware } from "./middleware"; 11 + import { secureMiddleware } from "./middleware"; 11 12 import { monitorsApi } from "./monitors"; 12 13 import { notificationsApi } from "./notifications"; 13 14 import { pageSubscribersApi } from "./pageSubscribers"; ··· 20 21 workspaceId: string; 21 22 workspacePlan: { 22 23 title: "Hobby" | "Starter" | "Growth" | "Pro"; 24 + id: WorkspacePlan; 23 25 description: string; 24 26 price: number; 25 27 }; ··· 72 74 /** 73 75 * Authentification Middleware 74 76 */ 75 - api.use("/*", middleware); 77 + api.use("/*", secureMiddleware); 76 78 api.use("/*", logger()); 77 79 78 80 /**
+38 -1
apps/server/src/v1/middleware.ts
··· 1 1 import { verifyKey } from "@unkey/api"; 2 2 import type { Context, Next } from "hono"; 3 3 4 + import { 5 + type EventProps, 6 + parseInputToProps, 7 + setupAnalytics, 8 + } from "@openstatus/analytics"; 4 9 import { db, eq } from "@openstatus/db"; 5 10 import { selectWorkspaceSchema, workspace } from "@openstatus/db/src/schema"; 6 11 import { getPlanConfig } from "@openstatus/db/src/schema/plan/utils"; ··· 8 13 import { env } from "../env"; 9 14 import type { Variables } from "./index"; 10 15 11 - export async function middleware( 16 + export async function secureMiddleware( 12 17 c: Context<{ Variables: Variables }, "/*">, 13 18 next: Next, 14 19 ) { ··· 35 40 console.error("Workspace not found"); 36 41 throw new HTTPException(401, { message: "Unauthorized" }); 37 42 } 43 + 38 44 const _work = selectWorkspaceSchema.parse(_workspace); 45 + 39 46 c.set("workspacePlan", getPlanConfig(_workspace.plan)); 40 47 c.set("workspaceId", `${result.ownerId}`); 41 48 c.set("limits", _work.limits); 42 49 43 50 await next(); 51 + } 52 + 53 + export function trackMiddleware(event: EventProps, eventProps?: string[]) { 54 + return async (c: Context<{ Variables: Variables }, "/*">, next: Next) => { 55 + await next(); 56 + 57 + // REMINDER: only track the event if the request was successful 58 + if (!c.error) { 59 + // We have checked the request to be valid already 60 + let json: unknown; 61 + if (c.req.raw.bodyUsed) { 62 + try { 63 + json = await c.req.json(); 64 + } catch { 65 + json = {}; 66 + } 67 + } 68 + const additionalProps = parseInputToProps(json, eventProps); 69 + 70 + // REMINDER: use setTimeout to avoid blocking the response 71 + setTimeout(async () => { 72 + const analytics = await setupAnalytics({ 73 + userId: `api_${c.get("workspaceId")}`, 74 + workspaceId: c.get("workspaceId"), 75 + plan: c.get("workspacePlan").id, 76 + }); 77 + await analytics.track({ ...event, additionalProps }); 78 + }, 0); 79 + } 80 + }; 44 81 } 45 82 46 83 /**
+3
apps/server/src/v1/monitors/delete.ts
··· 3 3 import { db, eq } from "@openstatus/db"; 4 4 import { monitor } from "@openstatus/db/src/schema"; 5 5 6 + import { Events } from "@openstatus/analytics"; 6 7 import { HTTPException } from "hono/http-exception"; 7 8 import { openApiErrorResponses } from "../../libs/errors/openapi-error-responses"; 9 + import { trackMiddleware } from "../middleware"; 8 10 import type { monitorsApi } from "./index"; 9 11 import { ParamsSchema } from "./schema"; 10 12 ··· 16 18 request: { 17 19 params: ParamsSchema, 18 20 }, 21 + middleware: [trackMiddleware(Events.DeleteMonitor)], 19 22 responses: { 20 23 200: { 21 24 content: {
+3 -11
apps/server/src/v1/monitors/post.ts
··· 1 1 import { createRoute, z } from "@hono/zod-openapi"; 2 2 3 - import { trackAnalytics } from "@openstatus/analytics"; 3 + import { Events } from "@openstatus/analytics"; 4 4 import { and, db, eq, isNull, sql } from "@openstatus/db"; 5 5 import { monitor } from "@openstatus/db/src/schema"; 6 6 ··· 8 8 import { serialize } from "../../../../../packages/assertions/src"; 9 9 10 10 import { getLimit } from "@openstatus/db/src/schema/plan/utils"; 11 - import { env } from "../../env"; 12 11 import { openApiErrorResponses } from "../../libs/errors/openapi-error-responses"; 12 + import { trackMiddleware } from "../middleware"; 13 13 import type { monitorsApi } from "./index"; 14 14 import { MonitorSchema } from "./schema"; 15 15 import { getAssertions } from "./utils"; ··· 19 19 tags: ["monitor"], 20 20 description: "Create a monitor", 21 21 path: "/", 22 + middleware: [trackMiddleware(Events.CreateMonitor, ["url", "jobType"])], 22 23 request: { 23 24 body: { 24 25 description: "The monitor to create", ··· 92 93 }) 93 94 .returning() 94 95 .get(); 95 - if (env.JITSU_WRITE_KEY) { 96 - trackAnalytics({ 97 - event: "Monitor Created", 98 - url: input.url, 99 - periodicity: input.periodicity, 100 - api: true, 101 - workspaceId: String(workspaceId), 102 - }); 103 - } 104 96 105 97 const data = MonitorSchema.parse(_newMonitor); 106 98
+3
apps/server/src/v1/monitors/put.ts
··· 3 3 import { and, db, eq } from "@openstatus/db"; 4 4 import { monitor } from "@openstatus/db/src/schema"; 5 5 6 + import { Events } from "@openstatus/analytics"; 6 7 import { HTTPException } from "hono/http-exception"; 7 8 import { serialize } from "../../../../../packages/assertions/src/serializing"; 8 9 import { openApiErrorResponses } from "../../libs/errors/openapi-error-responses"; 10 + import { trackMiddleware } from "../middleware"; 9 11 import type { monitorsApi } from "./index"; 10 12 import { MonitorSchema, ParamsSchema } from "./schema"; 11 13 import { getAssertions } from "./utils"; ··· 15 17 tags: ["monitor"], 16 18 description: "Update a monitor", 17 19 path: "/:id", 20 + middleware: [trackMiddleware(Events.UpdateMonitor)], 18 21 request: { 19 22 params: ParamsSchema, 20 23 body: {
+3
apps/server/src/v1/notifications/post.ts
··· 1 1 import { createRoute } from "@hono/zod-openapi"; 2 2 3 + import { Events } from "@openstatus/analytics"; 3 4 import { and, db, eq, inArray, isNull, sql } from "@openstatus/db"; 4 5 import { 5 6 NotificationDataSchema, ··· 11 12 import { getLimit } from "@openstatus/db/src/schema/plan/utils"; 12 13 import { HTTPException } from "hono/http-exception"; 13 14 import { openApiErrorResponses } from "../../libs/errors/openapi-error-responses"; 15 + import { trackMiddleware } from "../middleware"; 14 16 import type { notificationsApi } from "./index"; 15 17 import { NotificationSchema } from "./schema"; 16 18 ··· 19 21 tags: ["notification"], 20 22 description: "Create a notification", 21 23 path: "/", 24 + middleware: [trackMiddleware(Events.CreateNotification)], 22 25 request: { 23 26 body: { 24 27 description: "The notification to create",
+3
apps/server/src/v1/pages/post.ts
··· 4 4 import { db } from "@openstatus/db/src/db"; 5 5 import { monitor, monitorsToPages, page } from "@openstatus/db/src/schema"; 6 6 7 + import { Events } from "@openstatus/analytics"; 7 8 import { getLimit } from "@openstatus/db/src/schema/plan/utils"; 8 9 import { HTTPException } from "hono/http-exception"; 9 10 import { openApiErrorResponses } from "../../libs/errors/openapi-error-responses"; 11 + import { trackMiddleware } from "../middleware"; 10 12 import { isNumberArray } from "../utils"; 11 13 import type { pagesApi } from "./index"; 12 14 import { PageSchema } from "./schema"; ··· 16 18 tags: ["page"], 17 19 description: "Create a status page", 18 20 path: "/", 21 + middleware: [trackMiddleware(Events.CreatePage, ["slug"])], 19 22 request: { 20 23 body: { 21 24 description: "The status page to create",
+3
apps/server/src/v1/pages/put.ts
··· 1 1 import { createRoute } from "@hono/zod-openapi"; 2 2 3 + import { Events } from "@openstatus/analytics"; 3 4 import { and, eq, inArray, isNull, sql } from "@openstatus/db"; 4 5 import { db } from "@openstatus/db/src/db"; 5 6 import { monitor, monitorsToPages, page } from "@openstatus/db/src/schema"; 6 7 import { HTTPException } from "hono/http-exception"; 7 8 import { openApiErrorResponses } from "../../libs/errors/openapi-error-responses"; 9 + import { trackMiddleware } from "../middleware"; 8 10 import { isNumberArray } from "../utils"; 9 11 import type { pagesApi } from "./index"; 10 12 import { PageSchema, ParamsSchema } from "./schema"; ··· 14 16 tags: ["page"], 15 17 description: "Update a status page", 16 18 path: "/:id", 19 + middleware: [trackMiddleware(Events.UpdatePage)], 17 20 request: { 18 21 params: ParamsSchema, 19 22 body: {
-7
apps/web/.env.example
··· 18 18 DATABASE_URL=file:./../../openstatus-dev.db 19 19 DATABASE_AUTH_TOKEN=any-token 20 20 21 - # JITSU - no need to touch on local development 22 - # JITSU_HOST="https://your-jitsu-domain.com" 23 - # JITSU_WRITE_KEY="jitsu-key:jitsu-secret" 24 - 25 21 # Solves 'UNABLE_TO_GET_ISSUER_CERT_LOCALLY', see https://github.com/nextauthjs/next-auth/issues/3580 26 22 # NODE_TLS_REJECT_UNAUTHORIZED="0" 27 23 ··· 54 50 CRON_SECRET= 55 51 56 52 EXTERNAL_API_URL= 57 - 58 - NEXT_PUBLIC_POSTHOG_KEY= 59 - NEXT_PUBLIC_POSTHOG_HOST= 60 53 61 54 PLAYGROUND_UNKEY_API_KEY= 62 55
+3 -8
apps/web/content-collections.ts
··· 28 28 dark: "github-dark-dimmed", 29 29 light: "github-light", 30 30 }, 31 + grid: true, 32 + keepBackground: false, 31 33 // biome-ignore lint/suspicious/noExplicitAny: <explanation> 32 34 onVisitLine(node: any) { 33 35 // Prevent lines from collapsing in `display: grid` mode, and ··· 35 37 if (node.children.length === 0) { 36 38 node.children = [{ type: "text", value: " " }]; 37 39 } 38 - }, 39 - // biome-ignore lint/suspicious/noExplicitAny: <explanation> 40 - onVisitHighlightedLine(node: any) { 41 - node.properties.className.push("highlighted"); 42 - }, 43 - // biome-ignore lint/suspicious/noExplicitAny: <explanation> 44 - onVisitHighlightedWord(node: any) { 45 - node.properties.className = ["word"]; 46 40 }, 47 41 }, 48 42 ]; ··· 61 55 url: z.string().optional(), 62 56 avatar: z.string().optional(), 63 57 }), 58 + tag: z.enum(["company", "engineering", "education"]), 64 59 }), 65 60 transform: async (document, context) => { 66 61 const mdx = await compileMDX(context, document, {
-3
apps/web/package.json
··· 62 62 "next-plausible": "3.12.4", 63 63 "next-themes": "0.2.1", 64 64 "nuqs": "2.2.3", 65 - "posthog-js": "1.136.1", 66 - "posthog-node": "4.0.1", 67 65 "random-word-slugs": "0.1.7", 68 66 "react": "19.0.0", 69 67 "react-day-picker": "8.10.1", ··· 99 97 "postcss": "8.4.38", 100 98 "rehype-autolink-headings": "7.1.0", 101 99 "rehype-slug": "5.1.0", 102 - "remark-gfm": "3.0.1", 103 100 "tailwindcss": "3.4.3", 104 101 "typescript": "5.6.2", 105 102 "unified": "10.1.2"
apps/web/public/assets/posts/event-analytics-implementation/event-analytics-implementation.png

This is a binary file and will not be displayed.

+6 -2
apps/web/src/app/(content)/blog/[slug]/page.tsx
··· 29 29 } 30 30 const { title, publishedAt, description, slug, image } = post; 31 31 32 + const encodedTitle = encodeURIComponent(title); 33 + const encodedDescription = encodeURIComponent(description); 34 + const encodedImage = encodeURIComponent(image); 35 + 32 36 return { 33 37 ...defaultMetadata, 34 38 title, ··· 42 46 url: `https://www.openstatus.dev/blog/${slug}`, 43 47 images: [ 44 48 { 45 - url: `https://openstatus.dev/api/og/post?title=${title}&image=${image}`, 49 + url: `https://openstatus.dev/api/og/post?title=${encodedTitle}&image=${encodedImage}&description=${encodedDescription}`, 46 50 }, 47 51 ], 48 52 }, ··· 51 55 title, 52 56 description, 53 57 images: [ 54 - `https://openstatus.dev/api/og/post?title=${title}&image=${image}`, 58 + `https://openstatus.dev/api/og/post?title=${encodedTitle}&image=${encodedImage}&description=${encodedDescription}`, 55 59 ], 56 60 }, 57 61 };
+6 -2
apps/web/src/app/(content)/changelog/[slug]/page.tsx
··· 33 33 34 34 const { title, publishedAt, description, slug, image } = post; 35 35 36 + const encodedTitle = encodeURIComponent(title); 37 + const encodedDescription = encodeURIComponent(description); 38 + const encodedImage = encodeURIComponent(image); 39 + 36 40 return { 37 41 ...defaultMetadata, 38 42 title, ··· 46 50 url: `https://www.openstatus.dev/changelog/${slug}`, 47 51 images: [ 48 52 { 49 - url: `https://openstatus.dev/api/og/post?title=${title}&description=${description}&image=${image}`, 53 + url: `https://openstatus.dev/api/og/post?title=${encodedTitle}&description=${encodedDescription}&image=${encodedImage}`, 50 54 }, 51 55 ], 52 56 }, ··· 55 59 title, 56 60 description, 57 61 images: [ 58 - `https://openstatus.dev/api/og/post?title=${title}&description=${description}&image=${image}`, 62 + `https://openstatus.dev/api/og/post?title=${encodedTitle}&description=${encodedDescription}&image=${encodedImage}`, 59 63 ], 60 64 }, 61 65 };
+8 -3
apps/web/src/app/api/og/_components/basic-layout.tsx
··· 17 17 <div tw="flex flex-col h-full w-full px-24"> 18 18 <div tw="flex flex-col flex-1 justify-end"> 19 19 <div tw="flex flex-col px-12"> 20 - <h1 style={{ fontFamily: "Cal" }} tw="text-6xl"> 20 + <h3 style={{ fontFamily: "Cal" }} tw="text-5xl"> 21 21 {title} 22 - </h1> 22 + </h3> 23 23 {description ? ( 24 - <p tw="text-slate-600 text-3xl">{description}</p> 24 + <p 25 + tw="text-slate-600 text-3xl" 26 + style={{ lineClamp: 2, display: "block" }} 27 + > 28 + {description} 29 + </p> 25 30 ) : null} 26 31 </div> 27 32 </div>
+1 -1
apps/web/src/app/api/og/post/route.tsx
··· 34 34 {image ? ( 35 35 <img 36 36 alt="" 37 - style={{ objectFit: "cover", height: 350 }} // h-80 = 320px 37 + style={{ objectFit: "cover", height: 330 }} // h-80 = 320px 38 38 tw="flex w-full" 39 39 src={new URL(image, DEFAULT_URL).toString()} 40 40 />
+4 -8
apps/web/src/app/app/layout.tsx
··· 1 1 import { SessionProvider } from "next-auth/react"; 2 2 3 - import { PHProvider } from "@/lib/posthog/provider"; 4 - 5 3 import { Bubble } from "@/components/support/bubble"; 6 4 7 5 export default function AuthLayout({ ··· 10 8 children: React.ReactNode; 11 9 }) { 12 10 return ( 13 - <PHProvider> 14 - <SessionProvider> 15 - {children} 16 - <Bubble /> 17 - </SessionProvider> 18 - </PHProvider> 11 + <SessionProvider> 12 + {children} 13 + <Bubble /> 14 + </SessionProvider> 19 15 ); 20 16 }
+3 -5
apps/web/src/app/status-page/[domain]/_components/actions.ts
··· 2 2 3 3 import { z } from "zod"; 4 4 5 - import { trackAnalytics } from "@openstatus/analytics"; 5 + import { Events, setupAnalytics } from "@openstatus/analytics"; 6 6 import { and, eq, sql } from "@openstatus/db"; 7 7 import { db } from "@openstatus/db/src/db"; 8 8 import { page, pageSubscriber } from "@openstatus/db/src/schema"; ··· 87 87 }) 88 88 .execute(); 89 89 90 - await trackAnalytics({ 91 - event: "Subscribe to Status Page", 92 - slug: pageData.slug, 93 - }); 90 + const analytics = await setupAnalytics({}); 91 + analytics.track({ ...Events.SubscribePage, slug: pageData.slug }); 94 92 } 95 93 96 94 const passwordSchema = z.object({
+10 -6
apps/web/src/components/content/article.tsx
··· 2 2 import Image from "next/image"; 3 3 import Link from "next/link"; 4 4 5 - import { Avatar, AvatarFallback, AvatarImage } from "@openstatus/ui"; 5 + import { Avatar, AvatarFallback, AvatarImage, Badge } from "@openstatus/ui"; 6 6 7 7 import { Mdx } from "@/components/content/mdx"; 8 8 import { formatDate } from "@/lib/utils"; ··· 40 40 > 41 41 {post.author.name} 42 42 </Link> 43 - <p> 44 - {formatDate(post.publishedAt)} 45 - <span className="mx-1 text-muted-foreground/70">&bull;</span> 46 - {post.readingTime} 47 - </p> 43 + <div className="flex items-center gap-1.5 flex-wrap"> 44 + <time className="font-mono">{formatDate(post.publishedAt)}</time> 45 + <span className="text-muted-foreground/70">&bull;</span> 46 + <span className="font-mono">{post.readingTime}</span> 47 + <span className="text-muted-foreground/70">&bull;</span> 48 + <Badge variant="outline" className="font-normal capitalize"> 49 + {post.tag} 50 + </Badge> 51 + </div> 48 52 </div> 49 53 </div> 50 54 </div>
+1 -1
apps/web/src/components/content/mdx.tsx
··· 13 13 // FIXME: weird behaviour when `prose-headings:font-cal` and on mouse movement font gets bigger 14 14 <div 15 15 className={cn( 16 - "prose prose-slate dark:prose-invert prose-pre:my-0 prose-img:rounded-lg prose-pre:rounded-lg prose-img:border prose-pre:border prose-img:border-border prose-pre:border-border prose-headings:font-cal prose-headings:font-normal", 16 + "prose prose-slate dark:prose-invert prose-pre:my-0 prose-img:rounded-lg prose-pre:bg-background prose-pre:rounded-lg prose-img:border prose-pre:border prose-img:border-border prose-pre:border-border prose-headings:font-cal prose-headings:font-normal", 17 17 className, 18 18 )} 19 19 >
+3 -2
apps/web/src/components/content/pre.tsx
··· 1 1 "use client"; 2 2 3 + import { cn } from "@/lib/utils"; 3 4 import { Button } from "@openstatus/ui/src/components/button"; 4 5 import { Clipboard, ClipboardCopy } from "lucide-react"; 5 6 import React from "react"; 6 7 export interface PreProps extends React.HTMLAttributes<HTMLPreElement> {} 7 8 8 - export default function Pre({ children, ...props }: PreProps) { 9 + export default function Pre({ children, className, ...props }: PreProps) { 9 10 const [copied, setCopied] = React.useState(false); 10 11 const ref = React.useRef<HTMLPreElement>(null); 11 12 ··· 43 44 <ClipboardCopy className="h-5 w-5" /> 44 45 )} 45 46 </Button> 46 - <pre ref={ref} {...props}> 47 + <pre ref={ref} className={cn("[&_code]:grid", className)} {...props}> 47 48 {children} 48 49 </pre> 49 50 </div>
+1
apps/web/src/components/data-table/monitor/data-table-row-actions.tsx
··· 109 109 createdAt: undefined, 110 110 }; 111 111 112 + // Create a clone function in the api 112 113 await api.monitor.create.mutate(cloneMonitorData); 113 114 114 115 toast.success("Monitor cloned!");
+2
apps/web/src/content/posts/2023-year-review.mdx
··· 6 6 author: 7 7 name: Thibault Le Ouay Ducasse 8 8 url: https://bsky.app/profile/thibaultleouay.dev 9 + avatar: /assets/authors/thibault.jpeg 9 10 publishedAt: 2023-12-29 10 11 image: /assets/posts/2023-year-review/title.png 12 + tag: company 11 13 --- 12 14 13 15 It has been a wild six months for us at OpenStatus. In late June, we began
+2
apps/web/src/content/posts/dynamic-breadcrumb-nextjs.mdx
··· 5 5 author: 6 6 name: Thibault Le Ouay Ducasse 7 7 url: https://bsky.app/profile/thibaultleouay.dev 8 + avatar: /assets/authors/thibault.jpeg 8 9 publishedAt: 2024-08-19 9 10 image: /assets/posts/dynamic-breadcrumb-nextjs/breadcrumb.png 11 + tag: engineering 10 12 --- 11 13 12 14 In this post, we'll dive into the process of creating dynamic breadcrumbs in Next.js using parallel routes. Our goal is to build a breadcrumb component that automatically updates based on the current page and its hierarchy, all while leveraging server-side rendering for optimal performance.
+195
apps/web/src/content/posts/event-analytics-implementation.mdx
··· 1 + --- 2 + title: How We Implemented Event Analytics with OpenPanel 3 + description: 4 + Leveraging Hono OpenAPI middleware and tRPC metadata + middleware to implement event analytics easily. 5 + author: 6 + name: Maximilian Kaske 7 + url: https://x.com/mxkaske 8 + avatar: /assets/authors/max.png 9 + publishedAt: 2024-12-27 10 + image: /assets/posts/event-analytics-implementation/event-analytics-implementation.png 11 + tag: engineering 12 + --- 13 + 14 + We had never really tracked events properly. We had some basic tracking in place, but it was not very useful. It is time to improve that with [OpenPanel](https://openpanel.dev?ref=openstatus). 15 + 16 + After some research, we finally settled on leveraging tRPC and Hono middlewares. Shoutout to [Midday](https://midday.ai?ref=openstatus) for the (tRPC) inspiration. They use a similar approach with [next-safe-action](https://next-safe-action.dev?ref=openstatus) for their server actions. 17 + 18 + This post is not a step-by-step guide but instead presents the core concepts and ideas behind the implementation. Please refer to the [Hono](https://hono.dev?ref=openstatus) or [tRPC](https://trpc.io?ref=openstatus) documentation for more detailed information and our [GitHub](https://openstatus.dev/github) repository for the full implementation. 19 + 20 + --- 21 + 22 + First, let's start with the basics. We need to define the events we want to track, like `page_created`, `user_created`, etc. 23 + 24 + ```ts 25 + // packages/analytics/src/events.ts 26 + export type EventProps = { 27 + name: string; 28 + channel: string; 29 + }; 30 + 31 + export const Events = { 32 + CreatePage: { 33 + name: "page_created", 34 + channel: "page", 35 + }, 36 + UpdatePage: { 37 + name: "page_upated", 38 + channel: "page", 39 + }, 40 + // ... add more events 41 + } as const satisfies Record<string, EventProps>; 42 + ``` 43 + 44 + Next, we need to initialize OpenPanel (see [installation](https://openpanel.dev/docs/sdks/javascript?ref=openstatus)) and set up the analytics in our application. 45 + 46 + ```ts 47 + // packages/analytics/src/index.ts 48 + import { 49 + OpenPanel, 50 + type PostEventPayload, 51 + type IdentifyPayload, 52 + } from "@openpanel/sdk"; 53 + import { type EventProps } from "@openstatus/analytics"; 54 + 55 + const op = new OpenPanel({ 56 + clientId: process.env.OPENPANEL_CLIENT_ID, 57 + clientSecret: process.env.OPENPANEL_CLIENT_SECRET, 58 + }); 59 + 60 + export async function setupAnalytics(props: Partial<IdentifyPayload>) { 61 + if (props.profileId) { 62 + await op.identify(props): 63 + } 64 + 65 + return { 66 + track: (opts: EventProps & PostEventPayload["properties"]) => { 67 + const { name, ...rest } = opts; 68 + return op.track(name, rest); 69 + }, 70 + }; 71 + } 72 + ``` 73 + 74 + Now that we have the basic setup in place, we can start implementing the tracking in our application. We will use the tRPC middleware and metadata to track events. Below, we define the `trackEvent` middleware that will track the event after the procedure has been executed. An `enforceUserSession` middleware can be added to include the user's ID for tracking. 75 + 76 + ```ts {8,12,35} /trackEvent/ 77 + // packages/trpc/src/index.ts 78 + import { after } from "next/server"; 79 + import { initTRPC } from "@trpc/server"; 80 + import { setupAnalytics, type EventProps } from "@openstatus/analytics"; 81 + import type { User } from "@openstatus/auth"; 82 + 83 + type Context = { user?: User }; 84 + type Meta = { track?: EventProps }; 85 + 86 + export const t = initTRPC 87 + .context<Context>() 88 + .meta<Meta>() 89 + .create({ /* ... */ }); 90 + 91 + 92 + const trackEvent = t.middleware(async opts => { 93 + const result = await opts.next(opts.ctx); 94 + 95 + if (!result.ok) return result; 96 + 97 + if (opts.meta.track) { 98 + after(async () => { 99 + const identify = opts.ctx.user ? { userId: opts.ctx.user.id } : {}; 100 + const analytics = await setupAnalytics(identify); 101 + await analytics.track(opts.meta.track); 102 + }) 103 + } 104 + return result; 105 + }); 106 + 107 + const enforceUserSession = t.middleware(async opts => { 108 + // ... set user to ctx 109 + }); 110 + 111 + export const protectedProcedure = t.procedure 112 + .use(enforceUserSession) 113 + .use(trackEvent); 114 + ``` 115 + 116 + The `after` function (similar to `waitUntil`) will execute the tracking after the procedure has been executed and won't block the response. 117 + 118 + The `next()` return value has an `ok` boolean property to check if the procedure was successful. If not, we don't want to track the event. 119 + 120 + How will we use it in a procedure? Adding a `meta` property will allow us to track the event by defining the event we want to track. 121 + 122 + ```ts {8} 123 + // packages/trpc/src/router/page.ts 124 + import { Events } from '@openstatus/analytics'; 125 + import { insertPageSchema } from "@openstatus/db"; 126 + import { createTRPCRouter, protectedProcedure } from "../trpc"; 127 + 128 + export const pageRouter = createTRPCRouter({ 129 + create: protectedProcedure 130 + .meta({ track: Events.CreatePage }) 131 + .input(insertPageSchema) 132 + .mutation(async (opts) => { /* ... */ }) 133 + }); 134 + ``` 135 + 136 + Voilà! Each time you want to add tracking to a new procedure, you only need to add the `meta` property with the event you want to track. The middleware handles the rest. 137 + 138 + --- 139 + 140 + Now, how do we track the events within our API? Let's start by adding the `trackMiddleware` function and only track the event if the response has been finalized. 141 + 142 + ```ts /trackMiddleware/ 143 + // app/server/src/middleware.ts 144 + import { setupAnalytics, type EventProps } from "@openstatus/analytics"; 145 + import type { Context, Next } from "hono"; 146 + import type { User } from "@openstatus/auth"; 147 + 148 + export function trackMiddleware(event: EventProps) { 149 + return async (c: Context<{ Variables: { user?: User } }, "/*">, next: Next) => { 150 + await next(); 151 + 152 + if (!c.error) { 153 + setTimeout(async () => { 154 + const analytics = await setupAnalytics({ 155 + profileId: c.get("user")?.id, 156 + }); 157 + await analytics.track(event); 158 + }, 0); 159 + } 160 + }; 161 + } 162 + ``` 163 + 164 + Depending on where you are running the server, you might want to replace `setTimeout` with `waitUntil` (cf workers, Vercel) or other functions that extend the lifetime of the request without blocking the response. 165 + 166 + And again, we check if there was an `error` before tracking the event. We don't want to track unsuccessful events. 167 + 168 + The [`@hono/zod-openapi`](https://github.com/honojs/middleware/tree/main/packages/zod-openapi?ref=openstatus) routes have a `middleware` property that allows you to add middleware to the route. This is where we will add the tracking middleware. 169 + 170 + ```ts {11} 171 + // apps/web/src/pages/post.ts 172 + import { createRoute } from "@hono/zod-openapi"; 173 + import { Events } from "@openstatus/analytics"; 174 + import { trackMiddleware } from "../middleware"; 175 + 176 + const postRoute = createRoute({ 177 + method: "post", 178 + tags: ["page"], 179 + description: "Create a new Page", 180 + path: "/", 181 + middleware: [trackMiddleware(Events.CreatePage)], 182 + request: { /* ... */ }, 183 + responses: { /* ... */}, 184 + }); 185 + 186 + // ... 187 + ``` 188 + 189 + --- 190 + 191 + And that's it. With minimal code changes and the help of middlewares, we have implemented event tracking in our application. You can swap [OpenPanel](https://openpanel.dev?ref=openstatus) with any other analytics provider like PostHog, but give it a try, it's an amazing tool! 192 + 193 + By the way, this approach can be used for audit log tracking for example as well. 194 + 195 + Check out our [GitHub](https://openstatus.dev/github) repository for the full implementation and don't forget to leave a star if you found this helpful.
+2
apps/web/src/content/posts/migration-auth-clerk-to-next-auth.mdx
··· 5 5 author: 6 6 name: Thibault Le Ouay Ducasse 7 7 url: https://bsky.app/profile/thibaultleouay.dev 8 + avatar: /assets/authors/thibault.jpeg 8 9 publishedAt: 2024-05-15 9 10 image: /assets/posts/migration-auth-clerk-to-next-auth/authjs.png 11 + tag: engineering 10 12 --- 11 13 12 14 We recently switched from [Clerk](https://clerk.com) to NextAuth
+2
apps/web/src/content/posts/migration-backend-from-vercel-to-fly.mdx
··· 6 6 author: 7 7 name: Thibault Le Ouay Ducasse 8 8 url: https://bsky.app/profile/thibaultleouay.dev 9 + avatar: /assets/authors/thibault.jpeg 9 10 publishedAt: 2023-10-29 10 11 image: /assets/posts/migration-backend-from-vercel-to-fly/fly.png 12 + tag: company 11 13 --- 12 14 13 15 In this article, we are going to see the reasons that made us change our backend
+2
apps/web/src/content/posts/migration-planetscale-to-turso.mdx
··· 6 6 author: 7 7 name: Thibault Le Ouay Ducasse 8 8 url: https://bsky.app/profile/thibaultleouay.dev 9 + avatar: /assets/authors/thibault.jpeg 9 10 publishedAt: 2023-08-20 11 + tag: company 10 12 --- 11 13 12 14 ## What are we building ? 🏗️
+2
apps/web/src/content/posts/monitoring-latency-cf-workers-fly-koyeb-raylway-render.mdx
··· 7 7 author: 8 8 name: Thibault Le Ouay Ducasse 9 9 url: https://bsky.app/profile/thibaultleouay.dev 10 + avatar: /assets/authors/thibault.jpeg 10 11 publishedAt: 2024-02-19 11 12 image: /assets/posts/monitoring-latency/all-hosting-providers.png 13 + tag: education 12 14 --- 13 15 14 16 > ⚠️ We are using the default settings for each provider and conducting
+2
apps/web/src/content/posts/monitoring-latency-vercel-edge-vs-serverless.mdx
··· 6 6 author: 7 7 name: Thibault Le Ouay Ducasse 8 8 url: https://bsky.app/profile/thibaultleouay.dev 9 + avatar: /assets/authors/thibault.jpeg 9 10 publishedAt: 2024-03-14 10 11 image: /assets/posts/monitoring-vercel/serverless-vs-edge.png 12 + tag: education 11 13 --- 12 14 13 15 In our previous
+2
apps/web/src/content/posts/our-new-pricing-explained.mdx
··· 6 6 author: 7 7 name: Thibault Le Ouay Ducasse 8 8 url: https://bsky.app/profile/thibaultleouay.dev 9 + avatar: /assets/authors/thibault.jpeg 9 10 publishedAt: 2024-01-15 10 11 image: /assets/posts/our-new-pricing-explained/pricing-hard.png 12 + tag: company 11 13 --- 12 14 13 15 We began the new year with two goals: to increase profitability and attract more
+2
apps/web/src/content/posts/pricing-update-july-2024.mdx
··· 4 4 author: 5 5 name: Thibault Le Ouay Ducasse 6 6 url: https://bsky.app/profile/thibaultleouay.dev 7 + avatar: /assets/authors/thibault.jpeg 7 8 publishedAt: 2024-07-24 8 9 image: /assets/posts/pricing-update-july-2024/pricing.png 10 + tag: company 9 11 --- 10 12 11 13
+2
apps/web/src/content/posts/q1-2024-update.mdx
··· 4 4 author: 5 5 name: Thibault Le Ouay Ducasse 6 6 url: https://bsky.app/profile/thibaultleouay.dev 7 + avatar: /assets/authors/thibault.jpeg 7 8 publishedAt: 2024-04-29 8 9 image: /assets/posts/q1-2024-update/q1-2024-update.png 10 + tag: company 9 11 --- 10 12 11 13 A lot has happened in the first quarter of 2024. We have been working hard to
+2
apps/web/src/content/posts/reflecting-1-year-building-openstatus.mdx
··· 5 5 author: 6 6 name: Thibault Le Ouay 7 7 url: https://bsky.app/profile/thibaultleouay.dev 8 + avatar: /assets/authors/thibault.jpeg 8 9 publishedAt: 2024-09-26 9 10 image: /assets/posts/reflecting-1-year-building-openstatus/first-year.png 11 + tag: company 10 12 --- 11 13 12 14 In one year, we've grown our open-source synthetic monitoring platform to more than 6,000 GitHub stars and 5,000 users. This article will reflect on our first year of building OpenStatus.
+2
apps/web/src/content/posts/secure-api-with-unkey.mdx
··· 4 4 author: 5 5 name: Maximilian Kaske 6 6 url: https://twitter.com/mxkaske 7 + avatar: /assets/authors/max.png 7 8 publishedAt: 2023-10-01 8 9 image: /assets/posts/secure-api-with-unkey/unkey.png 10 + tag: engineering 9 11 --- 10 12 11 13 ## Introduction
+2
apps/web/src/content/posts/the-first-48-hours.mdx
··· 5 5 author: 6 6 name: Maximilian Kaske 7 7 url: https://twitter.com/mxkaske 8 + avatar: /assets/authors/max.png 8 9 publishedAt: 2023-08-02 10 + tag: company 9 11 --- 10 12 11 13 ## 48 hours of Rollercoaster 🎢
-4
apps/web/src/env.ts
··· 30 30 NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY: z.string(), 31 31 NEXT_PUBLIC_URL: z.string(), 32 32 NEXT_PUBLIC_SENTRY_DSN: z.string(), 33 - NEXT_PUBLIC_POSTHOG_KEY: z.string(), 34 - NEXT_PUBLIC_POSTHOG_HOST: z.string(), 35 33 }, 36 34 runtimeEnv: { 37 35 TINY_BIRD_API_KEY: process.env.TINY_BIRD_API_KEY, ··· 44 42 STRIPE_WEBHOOK_SECRET_KEY: process.env.STRIPE_WEBHOOK_SECRET_KEY, 45 43 NEXT_PUBLIC_URL: process.env.NEXT_PUBLIC_URL, 46 44 NEXT_PUBLIC_SENTRY_DSN: process.env.NEXT_PUBLIC_SENTRY_DSN, 47 - NEXT_PUBLIC_POSTHOG_KEY: process.env.NEXT_PUBLIC_POSTHOG_KEY, 48 - NEXT_PUBLIC_POSTHOG_HOST: process.env.NEXT_PUBLIC_POSTHOG_HOST, 49 45 UNKEY_TOKEN: process.env.UNKEY_TOKEN, 50 46 UNKEY_API_ID: process.env.UNKEY_API_ID, 51 47 GCP_PROJECT_ID: process.env.GCP_PROJECT_ID,
+11 -14
apps/web/src/lib/auth/index.ts
··· 1 1 import type { DefaultSession } from "next-auth"; 2 2 import NextAuth from "next-auth"; 3 3 4 - import { analytics, trackAnalytics } from "@openstatus/analytics"; 4 + import { Events, setupAnalytics } from "@openstatus/analytics"; 5 5 import { db, eq } from "@openstatus/db"; 6 6 import { user } from "@openstatus/db/src/schema"; 7 7 import { sendEmail } from "@openstatus/emails/src/send"; 8 8 9 - import { identifyUser } from "@/lib/posthog/provider"; 10 9 import { WelcomeEmail } from "@openstatus/emails/emails/welcome"; 11 10 import { adapter } from "./adapter"; 12 11 import { GitHubProvider, GoogleProvider, ResendProvider } from "./providers"; ··· 89 88 react: WelcomeEmail(), 90 89 }); 91 90 92 - const { id: userId, email } = params.user; 91 + const analytics = await setupAnalytics({ 92 + userId: `usr_${params.user.id}`, 93 + email: params.user.id, 94 + }); 93 95 94 - if (process.env.NODE_ENV !== "development") { 95 - await analytics.identify(userId, { email, userId }); 96 - await trackAnalytics({ event: "User Created", userId, email }); 97 - await identifyUser({ user: params.user }); 98 - } 96 + await analytics.track(Events.CreateUser); 99 97 }, 100 98 101 99 async signIn(params) { 102 100 if (params.isNewUser) return; 103 101 if (!params.user.id || !params.user.email) return; 104 102 105 - const { id: userId, email } = params.user; 103 + const analytics = await setupAnalytics({ 104 + userId: `usr_${params.user.id}`, 105 + email: params.user.id, 106 + }); 106 107 107 - if (process.env.NODE_ENV !== "development") { 108 - await analytics.identify(userId, { userId, email }); 109 - await identifyUser({ user: params.user }); 110 - await trackAnalytics({ event: "User Signed In" }); 111 - } 108 + await analytics.track(Events.SignInUser); 112 109 }, 113 110 }, 114 111 pages: {
-14
apps/web/src/lib/posthog/client.ts
··· 1 - import { PostHog } from "posthog-node"; 2 - 3 - import { env } from "@/env"; 4 - 5 - // REMINDER: SSR actions 6 - 7 - export default function PostHogClient() { 8 - const posthogClient = new PostHog(env.NEXT_PUBLIC_POSTHOG_KEY, { 9 - host: env.NEXT_PUBLIC_POSTHOG_HOST, 10 - flushAt: 1, 11 - flushInterval: 0, 12 - }); 13 - return posthogClient; 14 - }
-37
apps/web/src/lib/posthog/pageview.tsx
··· 1 - // app/PostHogPageView.tsx 2 - "use client"; 3 - 4 - import { usePathname, useSearchParams } from "next/navigation"; 5 - import { usePostHog } from "posthog-js/react"; 6 - import { Suspense, useEffect } from "react"; 7 - 8 - function PostHogPageView(): null { 9 - const pathname = usePathname(); 10 - const searchParams = useSearchParams(); 11 - const posthog = usePostHog(); 12 - useEffect(() => { 13 - // Track pageviews 14 - if (pathname && posthog) { 15 - let url = window.origin + pathname; 16 - if (searchParams.toString()) { 17 - url = `${url}?${searchParams.toString()}`; 18 - } 19 - posthog.capture("$pageview", { 20 - $current_url: url, 21 - }); 22 - } 23 - }, [pathname, searchParams, posthog]); 24 - 25 - return null; 26 - } 27 - 28 - // Wrap this in Suspense to avoid the `useSearchParams` usage above 29 - // from deopting the whole app into client-side rendering 30 - // See https://nextjs.org/docs/messages/deopted-into-client-rendering 31 - export function SuspendedPostHogPageView() { 32 - return ( 33 - <Suspense fallback={null}> 34 - <PostHogPageView /> 35 - </Suspense> 36 - ); 37 - }
-46
apps/web/src/lib/posthog/provider.tsx
··· 1 - "use client"; 2 - 3 - import type { User } from "next-auth"; 4 - import type { CaptureOptions, Properties } from "posthog-js"; 5 - import posthog from "posthog-js"; 6 - import { PostHogProvider } from "posthog-js/react"; 7 - import { SuspendedPostHogPageView } from "./pageview"; 8 - 9 - import { env } from "@/env"; 10 - import { useEffect } from "react"; 11 - 12 - export function PHProvider({ children }: { children: React.ReactNode }) { 13 - if (process.env.NODE_ENV !== "production") return children; 14 - 15 - useEffect(() => { 16 - posthog.init(env.NEXT_PUBLIC_POSTHOG_KEY, { 17 - api_host: env.NEXT_PUBLIC_POSTHOG_HOST, 18 - capture_pageview: false, // Disable automatic pageview capture, as we capture manually 19 - capture_pageleave: true, // Enable automatic pageleave capture 20 - disable_session_recording: false, // Enable automatic session recording 21 - }); 22 - }, []); 23 - 24 - return ( 25 - <PostHogProvider client={posthog}> 26 - <SuspendedPostHogPageView /> 27 - {children} 28 - </PostHogProvider> 29 - ); 30 - } 31 - 32 - export function trackEvent({ 33 - name, 34 - props, 35 - opts, 36 - }: { 37 - name: string; 38 - props: Properties; 39 - opts: CaptureOptions; 40 - }) { 41 - posthog.capture(name, props, opts); 42 - } 43 - 44 - export function identifyUser({ user }: { user: User }) { 45 - posthog.identify(user.id, { email: user.email }); 46 - }
+8
apps/web/src/styles/globals.css
··· 102 102 body { 103 103 @apply bg-background text-foreground; 104 104 } 105 + 106 + [data-highlighted-chars] { 107 + @apply bg-muted rounded; 108 + } 109 + 110 + [data-highlighted-line] { 111 + @apply bg-muted rounded; 112 + } 105 113 }
+2 -2
packages/analytics/.env.example
··· 1 - JITSU_HOST="https://your-jitsu-domain.com" 2 - JITSU_WRITE_KEY="jitsu-key:jitsu-secret" 1 + OPENPANEL_CLIENT_ID= 2 + OPENPANEL_CLIENT_SECRET=
+4 -4
packages/analytics/env.ts
··· 3 3 4 4 export const env = createEnv({ 5 5 server: { 6 - JITSU_HOST: z.string().optional(), 7 - JITSU_WRITE_KEY: z.string().optional(), 6 + OPENPANEL_CLIENT_ID: z.string(), 7 + OPENPANEL_CLIENT_SECRET: z.string(), 8 8 }, 9 9 runtimeEnv: { 10 - JITSU_HOST: process.env.JITSU_HOST, 11 - JITSU_WRITE_KEY: process.env.JITSU_WRITE_KEY, 10 + OPENPANEL_CLIENT_ID: process.env.OPENPANEL_CLIENT_ID, 11 + OPENPANEL_CLIENT_SECRET: process.env.OPENPANEL_CLIENT_SECRET, 12 12 }, 13 13 });
+1
packages/analytics/package.json
··· 5 5 "main": "src/index.ts", 6 6 "dependencies": { 7 7 "@jitsu/js": "1.9.2", 8 + "@openpanel/sdk": "^1.0.0", 8 9 "@t3-oss/env-core": "0.7.0", 9 10 "zod": "3.23.8" 10 11 },
+151
packages/analytics/src/events.ts
··· 1 + export type EventProps = { 2 + name: string; 3 + channel: string; 4 + }; 5 + 6 + export const Events = { 7 + CreateUser: { 8 + name: "user_created", 9 + channel: "registration", 10 + }, 11 + SkipOnboarding: { 12 + name: "onboarding_skipped", 13 + channel: "onboarding", 14 + }, 15 + CompleteOnboarding: { 16 + name: "onboarding_completed", 17 + channel: "onboarding", 18 + }, 19 + SignInUser: { 20 + name: "user_signed_in", 21 + channel: "login", 22 + }, 23 + SignOutUser: { 24 + name: "user_signed_out", 25 + channel: "login", 26 + }, 27 + CreateMonitor: { 28 + name: "monitor_created", 29 + channel: "monitor", 30 + }, 31 + UpdateMonitor: { 32 + name: "monitor_updated", 33 + channel: "monitor", 34 + }, 35 + DeleteMonitor: { 36 + name: "monitor_deleted", 37 + channel: "monitor", 38 + }, 39 + CloneMonitor: { 40 + name: "monitor_cloned", 41 + channel: "monitor", 42 + }, 43 + TestMonitor: { 44 + name: "monitor_tested", 45 + channel: "monitor", 46 + }, 47 + CreatePage: { 48 + name: "page_created", 49 + channel: "page", 50 + }, 51 + UpdatePage: { 52 + name: "page_updated", 53 + channel: "page", 54 + }, 55 + DeletePage: { 56 + name: "page_deleted", 57 + channel: "page", 58 + }, 59 + SubscribePage: { 60 + name: "user_subscribed", 61 + channel: "page", 62 + }, 63 + CreateReport: { 64 + name: "report_created", 65 + channel: "report", 66 + }, 67 + UpdateReport: { 68 + name: "report_updated", 69 + channel: "report", 70 + }, 71 + DeleteReport: { 72 + name: "report_deleted", 73 + channel: "report", 74 + }, 75 + CreateMaintenance: { 76 + name: "maintenance_created", 77 + channel: "maintenance", 78 + }, 79 + UpdateMaintenance: { 80 + name: "maintenance_updated", 81 + channel: "maintenance", 82 + }, 83 + DeleteMaintenance: { 84 + name: "maintenance_deleted", 85 + channel: "maintenance", 86 + }, 87 + CreateNotification: { 88 + name: "notification_created", 89 + channel: "notification", 90 + }, 91 + UpdateNotification: { 92 + name: "notification_updated", 93 + channel: "notification", 94 + }, 95 + DeleteNotification: { 96 + name: "notification_deleted", 97 + channel: "notification", 98 + }, 99 + AcknowledgeIncident: { 100 + name: "incident_acknowledged", 101 + channel: "incident", 102 + }, 103 + ResolveIncident: { 104 + name: "incident_resolved", 105 + channel: "incident", 106 + }, 107 + UpdateIncident: { 108 + name: "incident_updated", 109 + channel: "incident", 110 + }, 111 + DeleteIncident: { 112 + name: "incident_deleted", 113 + channel: "incident", 114 + }, 115 + InviteUser: { 116 + name: "user_invited", 117 + channel: "team", 118 + }, 119 + DeleteInvite: { 120 + name: "invitation_deleted", 121 + channel: "team", 122 + }, 123 + AcceptInvite: { 124 + name: "invitation_accepted", 125 + channel: "team", 126 + }, 127 + RemoveUser: { 128 + name: "user_removed", 129 + channel: "team", 130 + }, 131 + CreateAPI: { 132 + name: "api_key_created", 133 + channel: "api_key", 134 + }, 135 + RevokeAPI: { 136 + name: "api_key_revoked", 137 + channel: "api_key", 138 + }, 139 + UpgradeWorkspace: { 140 + name: "workspace_updated", 141 + channel: "billing", 142 + }, 143 + StripePortal: { 144 + name: "stripe_portal", 145 + channel: "billing", 146 + }, 147 + DowngradeWorkspace: { 148 + name: "workspace_downgraded", 149 + channel: "billing", 150 + }, 151 + } as const satisfies Record<string, EventProps>;
+3 -17
packages/analytics/src/index.ts
··· 1 - import { emptyAnalytics, jitsuAnalytics } from "@jitsu/js"; 2 - 3 - import { env } from "../env"; 4 - import type { AnalyticsEvents } from "./type"; 5 - 6 - export const analytics = 7 - env.JITSU_HOST && env.JITSU_WRITE_KEY 8 - ? jitsuAnalytics({ 9 - host: env.JITSU_HOST, 10 - writeKey: env.JITSU_WRITE_KEY, 11 - }) 12 - : emptyAnalytics; 13 - 14 - export const trackAnalytics = (args: AnalyticsEvents) => 15 - env.JITSU_HOST && env.JITSU_WRITE_KEY 16 - ? analytics.track(args) 17 - : emptyAnalytics.track(args); 1 + export * from "./events"; 2 + export * from "./server"; 3 + export * from "./utils";
+64
packages/analytics/src/server.ts
··· 1 + import { OpenPanel, type PostEventPayload } from "@openpanel/sdk"; 2 + import { env } from "../env"; 3 + import type { EventProps } from "./events"; 4 + 5 + const op = new OpenPanel({ 6 + clientId: env.OPENPANEL_CLIENT_ID, 7 + clientSecret: env.OPENPANEL_CLIENT_SECRET, 8 + }); 9 + 10 + op.setGlobalProperties({ 11 + env: process.env.VERCEL_ENV || process.env.NODE_ENV || "localhost", 12 + // app_version 13 + }); 14 + 15 + export type IdentifyProps = { 16 + userId?: string; 17 + fullName?: string | null; 18 + email?: string; 19 + workspaceId?: string; 20 + plan?: string; 21 + }; 22 + 23 + export async function setupAnalytics(props: IdentifyProps) { 24 + if (process.env.NODE_ENV !== "production") { 25 + return noop(); 26 + } 27 + 28 + if (props.userId) { 29 + const [firstName, lastName] = props.fullName?.split(" ") || []; 30 + await op.identify({ 31 + profileId: props.userId, 32 + email: props.email, 33 + firstName: firstName, 34 + lastName: lastName, 35 + properties: { 36 + workspaceId: props.workspaceId, 37 + plan: props.plan, 38 + }, 39 + }); 40 + } 41 + 42 + return { 43 + track: (opts: EventProps & PostEventPayload["properties"]) => { 44 + const { name, ...rest } = opts; 45 + return op.track(name, rest); 46 + }, 47 + }; 48 + } 49 + 50 + /** 51 + * Noop analytics for development environment 52 + */ 53 + async function noop() { 54 + return { 55 + track: ( 56 + opts: EventProps & PostEventPayload["properties"], 57 + ): Promise<unknown> => { 58 + return new Promise((resolve) => { 59 + console.log(`>>> Track Event: ${opts.name}`); 60 + resolve(null); 61 + }); 62 + }, 63 + }; 64 + }
-24
packages/analytics/src/type.ts
··· 1 - export type AnalyticsEvents = 2 - | { 3 - event: "User Created"; 4 - userId: string; 5 - email: string; 6 - } 7 - | { 8 - event: "Monitor Created"; 9 - url: string; 10 - periodicity: string; 11 - api?: boolean; 12 - workspaceId?: string; 13 - } 14 - | { 15 - event: "Page Created"; 16 - slug: string; 17 - api?: boolean; 18 - } 19 - | { event: "User Upgraded"; email: string } 20 - | { event: "User Signed In" } 21 - | { event: "User RUM Beta Requested"; email: string } 22 - | { event: "Notification Created"; provider: string } 23 - | { event: "Subscribe to Status Page"; slug: string } 24 - | { event: "Invitation Created"; emailTo: string; workspaceId: number };
+18
packages/analytics/src/utils.ts
··· 1 + export function parseInputToProps( 2 + json: unknown, 3 + eventProps?: string[], 4 + ): Record<string, unknown> { 5 + if (typeof json !== "object" || json === null) return {}; 6 + 7 + if (!eventProps) return {}; 8 + 9 + return eventProps.reduce( 10 + (acc, prop) => { 11 + if (prop in json) { 12 + acc[prop] = json[prop as keyof typeof json]; 13 + } 14 + return acc; 15 + }, 16 + {} as Record<string, unknown>, 17 + ); 18 + }
+2
packages/api/.env.test
··· 1 1 RESEND_API_KEY='test' 2 + OPENPANEL_CLIENT_ID='test' 3 + OPENPANEL_CLIENT_SECRET='test'
-61
packages/api/src/analytics.ts
··· 1 - import { analytics, trackAnalytics } from "@openstatus/analytics"; 2 - import type { User } from "@openstatus/db/src/schema"; 3 - 4 - export async function trackNewPage(user: User, config: { slug: string }) { 5 - await analytics.identify(user.id, { 6 - userId: user.id, 7 - email: user.email, 8 - }); 9 - await trackAnalytics({ 10 - event: "Page Created", 11 - ...config, 12 - }); 13 - } 14 - 15 - export async function trackNewMonitor( 16 - user: User, 17 - config: { url: string; periodicity: string }, 18 - ) { 19 - await analytics.identify(user.id, { 20 - userId: user.id, 21 - email: user.email, 22 - }); 23 - await trackAnalytics({ 24 - event: "Monitor Created", 25 - ...config, 26 - }); 27 - } 28 - 29 - export async function trackNewUser() {} 30 - 31 - export async function trackNewWorkspace() {} 32 - 33 - export async function trackNewNotification( 34 - user: User, 35 - config: { provider: string }, 36 - ) { 37 - await analytics.identify(user.id, { 38 - userId: user.id, 39 - email: user.email, 40 - }); 41 - await trackAnalytics({ 42 - event: "Notification Created", 43 - ...config, 44 - }); 45 - } 46 - 47 - export async function trackNewStatusReport() {} 48 - 49 - export async function trackNewInvitation( 50 - user: User, 51 - config: { emailTo: string; workspaceId: number }, 52 - ) { 53 - await analytics.identify(user.id, { 54 - userId: user.id, 55 - email: user.email, 56 - }); 57 - await trackAnalytics({ 58 - event: "Invitation Created", 59 - ...config, 60 - }); 61 - }
-4
packages/api/src/env.ts
··· 8 8 TEAM_ID_VERCEL: z.string(), 9 9 VERCEL_AUTH_BEARER_TOKEN: z.string(), 10 10 TINY_BIRD_API_KEY: z.string(), 11 - JITSU_HOST: z.string().optional(), 12 - JITSU_WRITE_KEY: z.string().optional(), 13 11 }, 14 12 15 13 runtimeEnv: { ··· 18 16 TEAM_ID_VERCEL: process.env.TEAM_ID_VERCEL, 19 17 VERCEL_AUTH_BEARER_TOKEN: process.env.VERCEL_AUTH_BEARER_TOKEN, 20 18 TINY_BIRD_API_KEY: process.env.TINY_BIRD_API_KEY, 21 - JITSU_HOST: process.env.JITSU_HOST, 22 - JITSU_WRITE_KEY: process.env.JITSU_WRITE_KEY, 23 19 }, 24 20 skipValidation: process.env.NODE_ENV === "test", 25 21 });
-2
packages/api/src/lambda.ts
··· 1 - import { rumRouter } from "./router/rum"; 2 1 import { stripeRouter } from "./router/stripe"; 3 2 import { createTRPCRouter } from "./trpc"; 4 3 5 4 // Deployed to /trpc/lambda/** 6 5 export const lambdaRouter = createTRPCRouter({ 7 6 stripeRouter: stripeRouter, 8 - rumRouter: rumRouter, 9 7 }); 10 8 11 9 export { stripe } from "./router/stripe/shared";
+4
packages/api/src/router/incident.ts
··· 3 3 import { and, eq, isNull, schema } from "@openstatus/db"; 4 4 import { selectIncidentSchema } from "@openstatus/db/src/schema"; 5 5 6 + import { Events } from "@openstatus/analytics"; 6 7 import { createTRPCRouter, protectedProcedure } from "../trpc"; 7 8 8 9 export const incidentRouter = createTRPCRouter({ ··· 65 66 }), 66 67 67 68 acknowledgeIncident: protectedProcedure 69 + .meta({ track: Events.AcknowledgeIncident }) 68 70 .input(z.object({ id: z.number() })) 69 71 .mutation(async (opts) => { 70 72 const currentIncident = await opts.ctx.db ··· 98 100 return true; 99 101 }), 100 102 resolvedIncident: protectedProcedure 103 + .meta({ track: Events.ResolveIncident }) 101 104 .input(z.object({ id: z.number() })) 102 105 .mutation(async (opts) => { 103 106 const currentIncident = await opts.ctx.db ··· 135 138 }), 136 139 137 140 delete: protectedProcedure 141 + .meta({ track: Events.DeleteIncident }) 138 142 .input(z.object({ id: z.number() })) 139 143 .mutation(async (opts) => { 140 144 const incidentToDelete = await opts.ctx.db
+4 -7
packages/api/src/router/invitation.ts
··· 1 1 import { TRPCError } from "@trpc/server"; 2 2 import { z } from "zod"; 3 3 4 + import { Events } from "@openstatus/analytics"; 4 5 import { and, eq, gte, isNull } from "@openstatus/db"; 5 6 import { 6 7 insertInvitationSchema, ··· 9 10 user, 10 11 usersToWorkspaces, 11 12 } from "@openstatus/db/src/schema"; 12 - import { allPlans } from "@openstatus/db/src/schema/plan/config"; 13 13 14 - import { trackNewInvitation } from "../analytics"; 15 14 import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; 16 15 17 16 export const invitationRouter = createTRPCRouter({ 18 17 create: protectedProcedure 19 18 .input(insertInvitationSchema.pick({ email: true })) 19 + .meta({ track: Events.InviteUser }) 20 20 .mutation(async (opts) => { 21 21 const { email } = opts.input; 22 22 ··· 88 88 }); 89 89 } 90 90 91 - await trackNewInvitation(opts.ctx.user, { 92 - emailTo: email, 93 - workspaceId: opts.ctx.workspace.id, 94 - }); 95 - 96 91 return _invitation; 97 92 }), 98 93 99 94 delete: protectedProcedure 100 95 .input(z.object({ id: z.number() })) 96 + .meta({ track: Events.DeleteInvite }) 101 97 .mutation(async (opts) => { 102 98 await opts.ctx.db 103 99 .delete(invitation) ··· 140 136 */ 141 137 acceptInvitation: publicProcedure 142 138 .input(z.object({ token: z.string() })) 139 + .meta({ track: Events.AcceptInvite }) 143 140 .output( 144 141 z.object({ 145 142 message: z.string(),
+4
packages/api/src/router/maintenance.ts
··· 8 8 selectMaintenanceSchema, 9 9 } from "@openstatus/db/src/schema"; 10 10 11 + import { Events } from "@openstatus/analytics"; 11 12 import { TRPCError } from "@trpc/server"; 12 13 import { createTRPCRouter, protectedProcedure } from "../trpc"; 13 14 14 15 export const maintenanceRouter = createTRPCRouter({ 15 16 create: protectedProcedure 17 + .meta({ track: Events.CreateMaintenance }) 16 18 .input(insertMaintenanceSchema) 17 19 .mutation(async (opts) => { 18 20 const _maintenance = await opts.ctx.db ··· 111 113 return _maintenances; 112 114 }), 113 115 update: protectedProcedure 116 + .meta({ track: Events.UpdateMaintenance }) 114 117 .input(insertMaintenanceSchema) 115 118 .mutation(async (opts) => { 116 119 if (!opts.input.id) { ··· 175 178 return _maintenance; 176 179 }), 177 180 delete: protectedProcedure 181 + .meta({ track: Events.DeleteMaintenance }) 178 182 .input(z.object({ id: z.number() })) 179 183 .mutation(async (opts) => { 180 184 return await opts.ctx.db
+4 -6
packages/api/src/router/monitor.ts
··· 26 26 selectPublicMonitorSchema, 27 27 } from "@openstatus/db/src/schema"; 28 28 29 - import { trackNewMonitor } from "../analytics"; 29 + import { Events } from "@openstatus/analytics"; 30 30 import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; 31 31 32 32 export const monitorRouter = createTRPCRouter({ 33 33 create: protectedProcedure 34 + .meta({ track: Events.CreateMonitor, trackProps: ["url", "jobType"] }) 34 35 .input(insertMonitorSchema) 35 36 .output(selectMonitorSchema) 36 37 .mutation(async (opts) => { ··· 167 168 await opts.ctx.db.insert(monitorsToPages).values(values).run(); 168 169 } 169 170 170 - await trackNewMonitor(opts.ctx.user, { 171 - url: newMonitor.url, 172 - periodicity: newMonitor.periodicity, 173 - }); 174 - 175 171 return selectMonitorSchema.parse(newMonitor); 176 172 }), 177 173 ··· 262 258 }), 263 259 264 260 update: protectedProcedure 261 + .meta({ track: Events.UpdateMonitor }) 265 262 .input(insertMonitorSchema) 266 263 .mutation(async (opts) => { 267 264 if (!opts.input.id) return; ··· 535 532 }), 536 533 537 534 delete: protectedProcedure 535 + .meta({ track: Events.DeleteMonitor }) 538 536 .input(z.object({ id: z.number() })) 539 537 .mutation(async (opts) => { 540 538 const monitorToDelete = await opts.ctx.db
+4 -8
packages/api/src/router/notification.ts
··· 12 12 selectNotificationSchema, 13 13 } from "@openstatus/db/src/schema"; 14 14 15 + import { Events } from "@openstatus/analytics"; 15 16 import { SchemaError } from "@openstatus/error"; 16 - import { trackNewNotification } from "../analytics"; 17 - import { env } from "../env"; 18 17 import { createTRPCRouter, protectedProcedure } from "../trpc"; 19 18 20 19 export const notificationRouter = createTRPCRouter({ 21 20 create: protectedProcedure 21 + .meta({ track: Events.CreateNotification }) 22 22 .input(insertNotificationSchema) 23 23 .mutation(async (opts) => { 24 24 const { monitors, ...props } = opts.input; ··· 63 63 await opts.ctx.db.insert(notificationsToMonitors).values(values); 64 64 } 65 65 66 - if (env.JITSU_HOST !== undefined && env.JITSU_WRITE_KEY !== undefined) { 67 - await trackNewNotification(opts.ctx.user, { 68 - provider: _notification.provider, 69 - }); 70 - } 71 - 72 66 return _notification; 73 67 }), 74 68 75 69 update: protectedProcedure 70 + .meta({ track: Events.UpdateNotification }) 76 71 .input(insertNotificationSchema) 77 72 .mutation(async (opts) => { 78 73 if (!opts.input.id) return; ··· 160 155 }), 161 156 162 157 deleteNotification: protectedProcedure 158 + .meta({ track: Events.DeleteNotification }) 163 159 .input(z.object({ id: z.number() })) 164 160 .mutation(async (opts) => { 165 161 await opts.ctx.db
+137 -130
packages/api/src/router/page.ts
··· 15 15 workspace, 16 16 } from "@openstatus/db/src/schema"; 17 17 18 - import { trackNewPage } from "../analytics"; 18 + import { Events } from "@openstatus/analytics"; 19 19 import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc"; 20 20 21 21 export const pageRouter = createTRPCRouter({ 22 - create: protectedProcedure.input(insertPageSchema).mutation(async (opts) => { 23 - const { monitors, workspaceId, id, ...pageProps } = opts.input; 22 + create: protectedProcedure 23 + .meta({ track: Events.CreatePage, trackProps: ["slug"] }) 24 + .input(insertPageSchema) 25 + .mutation(async (opts) => { 26 + const { monitors, workspaceId, id, ...pageProps } = opts.input; 24 27 25 - const monitorIds = monitors?.map((item) => item.monitorId) || []; 28 + const monitorIds = monitors?.map((item) => item.monitorId) || []; 26 29 27 - const pageNumbers = ( 28 - await opts.ctx.db.query.page.findMany({ 29 - where: eq(page.workspaceId, opts.ctx.workspace.id), 30 - }) 31 - ).length; 30 + const pageNumbers = ( 31 + await opts.ctx.db.query.page.findMany({ 32 + where: eq(page.workspaceId, opts.ctx.workspace.id), 33 + }) 34 + ).length; 32 35 33 - const limit = opts.ctx.workspace.limits; 36 + const limit = opts.ctx.workspace.limits; 34 37 35 - // the user has reached the status page number limits 36 - if (pageNumbers >= limit["status-pages"]) { 37 - throw new TRPCError({ 38 - code: "FORBIDDEN", 39 - message: "You reached your status-page limits.", 40 - }); 41 - } 38 + // the user has reached the status page number limits 39 + if (pageNumbers >= limit["status-pages"]) { 40 + throw new TRPCError({ 41 + code: "FORBIDDEN", 42 + message: "You reached your status-page limits.", 43 + }); 44 + } 42 45 43 - // the user is not eligible for password protection 44 - if ( 45 - limit["password-protection"] === false && 46 - opts.input.passwordProtected === true 47 - ) { 48 - throw new TRPCError({ 49 - code: "FORBIDDEN", 50 - message: "Password protection is not available for your current plan.", 51 - }); 52 - } 53 - 54 - const newPage = await opts.ctx.db 55 - .insert(page) 56 - .values({ workspaceId: opts.ctx.workspace.id, ...pageProps }) 57 - .returning() 58 - .get(); 59 - 60 - if (monitorIds.length) { 61 - // We should make sure the user has access to the monitors 62 - const allMonitors = await opts.ctx.db.query.monitor.findMany({ 63 - where: and( 64 - inArray(monitor.id, monitorIds), 65 - eq(monitor.workspaceId, opts.ctx.workspace.id), 66 - isNull(monitor.deletedAt), 67 - ), 68 - }); 69 - 70 - if (allMonitors.length !== monitorIds.length) { 46 + // the user is not eligible for password protection 47 + if ( 48 + limit["password-protection"] === false && 49 + opts.input.passwordProtected === true 50 + ) { 71 51 throw new TRPCError({ 72 52 code: "FORBIDDEN", 73 - message: "You don't have access to all the monitors.", 53 + message: 54 + "Password protection is not available for your current plan.", 74 55 }); 75 56 } 76 57 77 - const values = monitors.map(({ monitorId }, index) => ({ 78 - pageId: newPage.id, 79 - order: index, 80 - monitorId, 81 - })); 58 + const newPage = await opts.ctx.db 59 + .insert(page) 60 + .values({ workspaceId: opts.ctx.workspace.id, ...pageProps }) 61 + .returning() 62 + .get(); 82 63 83 - await opts.ctx.db.insert(monitorsToPages).values(values).run(); 84 - } 64 + if (monitorIds.length) { 65 + // We should make sure the user has access to the monitors 66 + const allMonitors = await opts.ctx.db.query.monitor.findMany({ 67 + where: and( 68 + inArray(monitor.id, monitorIds), 69 + eq(monitor.workspaceId, opts.ctx.workspace.id), 70 + isNull(monitor.deletedAt), 71 + ), 72 + }); 73 + 74 + if (allMonitors.length !== monitorIds.length) { 75 + throw new TRPCError({ 76 + code: "FORBIDDEN", 77 + message: "You don't have access to all the monitors.", 78 + }); 79 + } 80 + 81 + const values = monitors.map(({ monitorId }, index) => ({ 82 + pageId: newPage.id, 83 + order: index, 84 + monitorId, 85 + })); 85 86 86 - await trackNewPage(opts.ctx.user, { slug: newPage.slug }); 87 + await opts.ctx.db.insert(monitorsToPages).values(values).run(); 88 + } 87 89 88 - return newPage; 89 - }), 90 + return newPage; 91 + }), 90 92 getPageById: protectedProcedure 91 93 .input(z.object({ id: z.number() })) 92 94 .query(async (opts) => { ··· 105 107 return selectPageSchemaWithMonitorsRelation.parse(firstPage); 106 108 }), 107 109 108 - update: protectedProcedure.input(insertPageSchema).mutation(async (opts) => { 109 - const { monitors, ...pageInput } = opts.input; 110 - if (!pageInput.id) return; 110 + update: protectedProcedure 111 + .meta({ track: Events.UpdatePage }) 112 + .input(insertPageSchema) 113 + .mutation(async (opts) => { 114 + const { monitors, ...pageInput } = opts.input; 115 + if (!pageInput.id) return; 111 116 112 - const monitorIds = monitors?.map((item) => item.monitorId) || []; 117 + const monitorIds = monitors?.map((item) => item.monitorId) || []; 113 118 114 - const limit = opts.ctx.workspace.limits; 119 + const limit = opts.ctx.workspace.limits; 115 120 116 - // the user is not eligible for password protection 117 - if ( 118 - limit["password-protection"] === false && 119 - opts.input.passwordProtected === true 120 - ) { 121 - throw new TRPCError({ 122 - code: "FORBIDDEN", 123 - message: "Password protection is not available for your current plan.", 124 - }); 125 - } 126 - 127 - const currentPage = await opts.ctx.db 128 - .update(page) 129 - .set({ ...pageInput, updatedAt: new Date() }) 130 - .where( 131 - and( 132 - eq(page.id, pageInput.id), 133 - eq(page.workspaceId, opts.ctx.workspace.id), 134 - ), 135 - ) 136 - .returning() 137 - .get(); 138 - 139 - if (monitorIds.length) { 140 - // We should make sure the user has access to the monitors 141 - const allMonitors = await opts.ctx.db.query.monitor.findMany({ 142 - where: and( 143 - inArray(monitor.id, monitorIds), 144 - eq(monitor.workspaceId, opts.ctx.workspace.id), 145 - isNull(monitor.deletedAt), 146 - ), 147 - }); 148 - 149 - if (allMonitors.length !== monitorIds.length) { 121 + // the user is not eligible for password protection 122 + if ( 123 + limit["password-protection"] === false && 124 + opts.input.passwordProtected === true 125 + ) { 150 126 throw new TRPCError({ 151 127 code: "FORBIDDEN", 152 - message: "You don't have access to all the monitors.", 128 + message: 129 + "Password protection is not available for your current plan.", 153 130 }); 154 131 } 155 - } 156 132 157 - // TODO: check for monitor order! 158 - const currentMonitorsToPages = await opts.ctx.db 159 - .select() 160 - .from(monitorsToPages) 161 - .where(eq(monitorsToPages.pageId, currentPage.id)) 162 - .all(); 163 - 164 - const removedMonitors = currentMonitorsToPages 165 - .map(({ monitorId }) => monitorId) 166 - .filter((x) => !monitorIds?.includes(x)); 167 - 168 - if (removedMonitors.length) { 169 - await opts.ctx.db 170 - .delete(monitorsToPages) 133 + const currentPage = await opts.ctx.db 134 + .update(page) 135 + .set({ ...pageInput, updatedAt: new Date() }) 171 136 .where( 172 137 and( 173 - inArray(monitorsToPages.monitorId, removedMonitors), 174 - eq(monitorsToPages.pageId, currentPage.id), 138 + eq(page.id, pageInput.id), 139 + eq(page.workspaceId, opts.ctx.workspace.id), 140 + ), 141 + ) 142 + .returning() 143 + .get(); 144 + 145 + if (monitorIds.length) { 146 + // We should make sure the user has access to the monitors 147 + const allMonitors = await opts.ctx.db.query.monitor.findMany({ 148 + where: and( 149 + inArray(monitor.id, monitorIds), 150 + eq(monitor.workspaceId, opts.ctx.workspace.id), 151 + isNull(monitor.deletedAt), 175 152 ), 176 - ); 177 - } 153 + }); 178 154 179 - const values = monitors.map(({ monitorId }, index) => ({ 180 - pageId: currentPage.id, 181 - order: index, 182 - monitorId, 183 - })); 155 + if (allMonitors.length !== monitorIds.length) { 156 + throw new TRPCError({ 157 + code: "FORBIDDEN", 158 + message: "You don't have access to all the monitors.", 159 + }); 160 + } 161 + } 184 162 185 - if (values.length) { 186 - await opts.ctx.db 187 - .insert(monitorsToPages) 188 - .values(values) 189 - .onConflictDoUpdate({ 190 - target: [monitorsToPages.monitorId, monitorsToPages.pageId], 191 - set: { order: sql.raw("excluded.`order`") }, 192 - }); 193 - } 194 - }), 163 + // TODO: check for monitor order! 164 + const currentMonitorsToPages = await opts.ctx.db 165 + .select() 166 + .from(monitorsToPages) 167 + .where(eq(monitorsToPages.pageId, currentPage.id)) 168 + .all(); 169 + 170 + const removedMonitors = currentMonitorsToPages 171 + .map(({ monitorId }) => monitorId) 172 + .filter((x) => !monitorIds?.includes(x)); 173 + 174 + if (removedMonitors.length) { 175 + await opts.ctx.db 176 + .delete(monitorsToPages) 177 + .where( 178 + and( 179 + inArray(monitorsToPages.monitorId, removedMonitors), 180 + eq(monitorsToPages.pageId, currentPage.id), 181 + ), 182 + ); 183 + } 184 + 185 + const values = monitors.map(({ monitorId }, index) => ({ 186 + pageId: currentPage.id, 187 + order: index, 188 + monitorId, 189 + })); 190 + 191 + if (values.length) { 192 + await opts.ctx.db 193 + .insert(monitorsToPages) 194 + .values(values) 195 + .onConflictDoUpdate({ 196 + target: [monitorsToPages.monitorId, monitorsToPages.pageId], 197 + set: { order: sql.raw("excluded.`order`") }, 198 + }); 199 + } 200 + }), 195 201 delete: protectedProcedure 202 + .meta({ track: Events.DeletePage }) 196 203 .input(z.object({ id: z.number() })) 197 204 .mutation(async (opts) => { 198 205 await opts.ctx.db
-33
packages/api/src/router/rum/index.ts
··· 1 - import { z } from "zod"; 2 - 3 - import { createTRPCRouter, protectedProcedure } from "../../trpc"; 4 - 5 - const event = z.enum(["CLS", "FCP", "FID", "INP", "LCP", "TTFB"]); 6 - 7 - const _RouteData = z.object({ 8 - href: z.string(), 9 - total_event: z.coerce.number(), 10 - clsValue: z.number().optional(), 11 - fcpValue: z.number().optional(), 12 - inpValue: z.number().optional(), 13 - lcpValue: z.number().optional(), 14 - ttfbValue: z.number().optional(), 15 - }); 16 - 17 - export const rumRouter = createTRPCRouter({ 18 - GetEventMetricsForWorkspace: protectedProcedure 19 - .input( 20 - z.object({ 21 - event: event, 22 - }), 23 - ) 24 - .query((_opts) => { 25 - // FIXME: Use tb pipe instead 26 - return null; 27 - }), 28 - 29 - GetAggregatedPerPage: protectedProcedure.query((_opts) => { 30 - // FIXME: Use tb pipe instead 31 - return null; 32 - }), 33 - });
+35 -10
packages/api/src/router/stripe/webhook.ts
··· 2 2 import type Stripe from "stripe"; 3 3 import { z } from "zod"; 4 4 5 - import { analytics, trackAnalytics } from "@openstatus/analytics"; 5 + import { Events, setupAnalytics } from "@openstatus/analytics"; 6 6 import { eq } from "@openstatus/db"; 7 7 import { user, workspace } from "@openstatus/db/src/schema"; 8 8 ··· 81 81 .get(); 82 82 if (!userResult) return; 83 83 84 - await analytics.identify(String(userResult.id), { 85 - email: customer.email, 86 - userId: userResult.id, 87 - }); 88 - await trackAnalytics({ 89 - event: "User Upgraded", 90 - email: customer.email, 84 + const analytics = await setupAnalytics({ 85 + userId: `usr_${userResult.id}`, 86 + email: userResult.email || undefined, 87 + workspaceId: String(result.id), 88 + plan: plan.plan, 91 89 }); 90 + await analytics.track(Events.UpgradeWorkspace); 92 91 } 93 92 }), 94 93 customerSubscriptionDeleted: webhookProcedure.mutation(async (opts) => { ··· 98 97 ? subscription.customer 99 98 : subscription.customer.id; 100 99 101 - await opts.ctx.db 100 + const _workspace = await opts.ctx.db 102 101 .update(workspace) 103 102 .set({ 104 103 subscriptionId: null, ··· 106 105 paidUntil: null, 107 106 }) 108 107 .where(eq(workspace.stripeId, customerId)) 109 - .run(); 108 + .returning(); 109 + 110 + const customer = await stripe.customers.retrieve(customerId); 111 + 112 + if (!_workspace) { 113 + throw new TRPCError({ 114 + code: "BAD_REQUEST", 115 + message: "Workspace not found", 116 + }); 117 + } 118 + 119 + if (!customer.deleted && customer.email) { 120 + const userResult = await opts.ctx.db 121 + .select() 122 + .from(user) 123 + .where(eq(user.email, customer.email)) 124 + .get(); 125 + if (!userResult) return; 126 + 127 + const analytics = await setupAnalytics({ 128 + userId: `usr_${userResult.id}`, 129 + email: customer.email || undefined, 130 + workspaceId: String(_workspace[0].id), 131 + plan: "free", 132 + }); 133 + await analytics.track(Events.DowngradeWorkspace); 134 + } 110 135 }), 111 136 });
-2
packages/api/src/router/user.ts
··· 1 - import { z } from "zod"; 2 - 3 1 import { eq } from "@openstatus/db"; 4 2 import { user } from "@openstatus/db/src/schema"; 5 3
+1 -2
packages/api/src/router/workspace.ts
··· 1 1 import { TRPCError } from "@trpc/server"; 2 - import { generateSlug } from "random-word-slugs"; 3 2 import * as randomWordSlugs from "random-word-slugs"; 4 3 import { z } from "zod"; 5 4 6 - import { and, eq, isNotNull, isNull, sql } from "@openstatus/db"; 5 + import { and, eq, isNull, sql } from "@openstatus/db"; 7 6 import { 8 7 application, 9 8 monitor,
+66 -21
packages/api/src/trpc.ts
··· 1 1 import { TRPCError, initTRPC } from "@trpc/server"; 2 - import type { NextRequest } from "next/server"; 2 + import { type NextRequest, after } from "next/server"; 3 3 import superjson from "superjson"; 4 4 import { ZodError } from "zod"; 5 5 6 + import { 7 + type EventProps, 8 + type IdentifyProps, 9 + parseInputToProps, 10 + setupAnalytics, 11 + } from "@openstatus/analytics"; 6 12 import { db, eq, schema } from "@openstatus/db"; 7 13 import type { User, Workspace } from "@openstatus/db/src/schema"; 8 14 ··· 26 32 workspace?: Workspace | null; 27 33 user?: User | null; 28 34 req?: NextRequest; 35 + }; 36 + 37 + type Meta = { 38 + track?: EventProps; 39 + trackProps?: string[]; 29 40 }; 30 41 31 42 /** ··· 73 84 * This is where the trpc api is initialized, connecting the context and 74 85 * transformer 75 86 */ 76 - export const t = initTRPC.context<typeof createTRPCContext>().create({ 77 - transformer: superjson, 78 - errorFormatter({ shape, error }) { 79 - return { 80 - ...shape, 81 - data: { 82 - ...shape.data, 83 - zodError: 84 - error.cause instanceof ZodError ? error.cause.flatten() : null, 85 - }, 86 - }; 87 - }, 88 - }); 87 + export const t = initTRPC 88 + .context<Context>() 89 + .meta<Meta>() 90 + .create({ 91 + transformer: superjson, 92 + errorFormatter({ shape, error }) { 93 + return { 94 + ...shape, 95 + data: { 96 + ...shape.data, 97 + zodError: 98 + error.cause instanceof ZodError ? error.cause.flatten() : null, 99 + }, 100 + }; 101 + }, 102 + }); 89 103 90 104 /** 91 105 * 3. ROUTER & PROCEDURE (THE IMPORTANT BIT) ··· 114 128 * Reusable middleware that enforces users are logged in before running the 115 129 * procedure 116 130 */ 117 - const enforceUserIsAuthed = t.middleware(async ({ ctx, next }) => { 131 + const enforceUserIsAuthed = t.middleware(async (opts) => { 132 + const { ctx } = opts; 118 133 if (!ctx.session?.user?.id) { 119 134 throw new TRPCError({ code: "UNAUTHORIZED" }); 120 135 } ··· 173 188 const user = schema.selectUserSchema.parse(userProps); 174 189 const workspace = schema.selectWorkspaceSchema.parse(activeWorkspace); 175 190 176 - return next({ 177 - ctx: { 178 - ...ctx, 179 - user, 180 - workspace, 181 - }, 191 + const result = await opts.next({ ctx: { ...ctx, user, workspace } }); 192 + 193 + if (process.env.NODE_ENV === "test") { 194 + return result; 195 + } 196 + 197 + // REMINDER: We only track the event if the request was successful 198 + if (!result.ok) { 199 + return result; 200 + } 201 + 202 + // REMINDER: We only track the event if the request was successful 203 + // REMINDER: We are not blocking the request 204 + after(async () => { 205 + const { ctx, meta, getRawInput } = opts; 206 + if (meta?.track) { 207 + let identify: IdentifyProps = {}; 208 + 209 + if (ctx.user && ctx.workspace) { 210 + identify = { 211 + userId: `usr_${ctx.user.id}`, 212 + email: ctx.user.email || undefined, 213 + workspaceId: String(ctx.workspace.id), 214 + plan: ctx.workspace.plan, 215 + }; 216 + } 217 + 218 + const analytics = await setupAnalytics(identify); 219 + const rawInput = await getRawInput(); 220 + const additionalProps = parseInputToProps(rawInput, meta.trackProps); 221 + 222 + await analytics.track({ ...meta.track, ...additionalProps }); 223 + } 182 224 }); 225 + 226 + return result; 183 227 }); 184 228 185 229 /** ··· 193 237 input: formData, 194 238 }); 195 239 }); 240 + 196 241 /** 197 242 * Protected (authed) procedure 198 243 *
+5
packages/db/src/schema/plan/config.ts
··· 6 6 WorkspacePlan, 7 7 { 8 8 title: "Hobby" | "Starter" | "Growth" | "Pro"; 9 + id: WorkspacePlan; 9 10 description: string; 10 11 price: number; 11 12 limits: Limits; ··· 13 14 > = { 14 15 free: { 15 16 title: "Hobby", 17 + id: "free", 16 18 description: "For personal projects", 17 19 price: 0, 18 20 limits: { ··· 42 44 }, 43 45 starter: { 44 46 title: "Starter", 47 + id: "starter", 45 48 description: "For small projects", 46 49 price: 30, 47 50 limits: { ··· 107 110 }, 108 111 team: { 109 112 title: "Growth", 113 + id: "team", 110 114 description: "For small teams", 111 115 price: 100, 112 116 limits: { ··· 172 176 }, 173 177 pro: { 174 178 title: "Pro", 179 + id: "pro", 175 180 description: "For bigger teams", 176 181 price: 300, 177 182 limits: {
+14 -234
pnpm-lock.yaml
··· 359 359 nuqs: 360 360 specifier: 2.2.3 361 361 version: 2.2.3(next@15.1.1(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0) 362 - posthog-js: 363 - specifier: 1.136.1 364 - version: 1.136.1 365 - posthog-node: 366 - specifier: 4.0.1 367 - version: 4.0.1 368 362 random-word-slugs: 369 363 specifier: 0.1.7 370 364 version: 0.1.7 ··· 465 459 rehype-slug: 466 460 specifier: 5.1.0 467 461 version: 5.1.0 468 - remark-gfm: 469 - specifier: 3.0.1 470 - version: 3.0.1 471 462 tailwindcss: 472 463 specifier: 3.4.3 473 464 version: 3.4.3(ts-node@10.9.2(@types/node@20.14.8)(typescript@5.6.2)) ··· 511 502 '@jitsu/js': 512 503 specifier: 1.9.2 513 504 version: 1.9.2(@types/dlv@1.1.5) 505 + '@openpanel/sdk': 506 + specifier: ^1.0.0 507 + version: 1.0.0 514 508 '@t3-oss/env-core': 515 509 specifier: 0.7.0 516 510 version: 0.7.0(typescript@5.6.2)(zod@3.23.8) ··· 3028 3022 3029 3023 '@one-ini/wasm@0.1.1': 3030 3024 resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==} 3025 + 3026 + '@openpanel/sdk@1.0.0': 3027 + resolution: {integrity: sha512-FNmmfjdXoC/VHEjA+WkrQ4lyM5lxEmV7xDd57uj4E+lIS0sU3DLG2mV/dpS8AscnZbUvuMn3kPhiLCqYzuv/gg==} 3031 3028 3032 3029 '@opentelemetry/api-logs@0.52.1': 3033 3030 resolution: {integrity: sha512-qnSqB2DQ9TPP96dl8cDubDvrUyWc0/sK81xHTK8eSUspzDM3bsewX903qclQFvVhgStjRWdC5bLb3kQqMkfV5A==} ··· 6616 6613 resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} 6617 6614 engines: {node: ^12.20 || >= 14.13} 6618 6615 6619 - fflate@0.4.8: 6620 - resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==} 6621 - 6622 6616 figures@3.2.0: 6623 6617 resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} 6624 6618 engines: {node: '>=8'} ··· 7639 7633 mdast-util-directive@3.0.0: 7640 7634 resolution: {integrity: sha512-JUpYOqKI4mM3sZcNxmF/ox04XYFFkNwr0CFlrQIkCwbvH0xzMCqkMqAde9wRd80VAhaUrwFwKm2nxretdT1h7Q==} 7641 7635 7642 - mdast-util-find-and-replace@2.2.2: 7643 - resolution: {integrity: sha512-MTtdFRz/eMDHXzeK6W3dO7mXUlF82Gom4y0oOgvHhh/HXZAGvIQDUvQ0SuUx+j2tv44b8xTHOm8K/9OoRFnXKw==} 7644 - 7645 7636 mdast-util-find-and-replace@3.0.1: 7646 7637 resolution: {integrity: sha512-SG21kZHGC3XRTSUhtofZkBzZTJNM5ecCi0SK2IMKmSXR8vO3peL+kb1O0z7Zl83jKtutG4k5Wv/W7V3/YHvzPA==} 7647 7638 ··· 7654 7645 mdast-util-frontmatter@2.0.1: 7655 7646 resolution: {integrity: sha512-LRqI9+wdgC25P0URIJY9vwocIzCcksduHQ9OF2joxQoyTNVduwLAFUzjoopuRJbJAReaKrNQKAZKL3uCMugWJA==} 7656 7647 7657 - mdast-util-gfm-autolink-literal@1.0.3: 7658 - resolution: {integrity: sha512-My8KJ57FYEy2W2LyNom4n3E7hKTuQk/0SES0u16tjA9Z3oFkF4RrC/hPAPgjlSpezsOvI8ObcXcElo92wn5IGA==} 7659 - 7660 7648 mdast-util-gfm-autolink-literal@2.0.0: 7661 7649 resolution: {integrity: sha512-FyzMsduZZHSc3i0Px3PQcBT4WJY/X/RCtEJKuybiC6sjPqLv7h1yqAkmILZtuxMSsUyaLUWNp71+vQH2zqp5cg==} 7662 7650 7663 - mdast-util-gfm-footnote@1.0.2: 7664 - resolution: {integrity: sha512-56D19KOGbE00uKVj3sgIykpwKL179QsVFwx/DCW0u/0+URsryacI4MAdNJl0dh+u2PSsD9FtxPFbHCzJ78qJFQ==} 7665 - 7666 7651 mdast-util-gfm-footnote@2.0.0: 7667 7652 resolution: {integrity: sha512-5jOT2boTSVkMnQ7LTrd6n/18kqwjmuYqo7JUPe+tRCY6O7dAuTFMtTPauYYrMPpox9hlN0uOx/FL8XvEfG9/mQ==} 7668 7653 7669 - mdast-util-gfm-strikethrough@1.0.3: 7670 - resolution: {integrity: sha512-DAPhYzTYrRcXdMjUtUjKvW9z/FNAMTdU0ORyMcbmkwYNbKocDpdk+PX1L1dQgOID/+vVs1uBQ7ElrBQfZ0cuiQ==} 7671 - 7672 7654 mdast-util-gfm-strikethrough@2.0.0: 7673 7655 resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==} 7674 7656 7675 - mdast-util-gfm-table@1.0.7: 7676 - resolution: {integrity: sha512-jjcpmNnQvrmN5Vx7y7lEc2iIOEytYv7rTvu+MeyAsSHTASGCCRA79Igg2uKssgOs1i1po8s3plW0sTu1wkkLGg==} 7677 - 7678 7657 mdast-util-gfm-table@2.0.0: 7679 7658 resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} 7680 7659 7681 - mdast-util-gfm-task-list-item@1.0.2: 7682 - resolution: {integrity: sha512-PFTA1gzfp1B1UaiJVyhJZA1rm0+Tzn690frc/L8vNX1Jop4STZgOE6bxUhnzdVSB+vm2GU1tIsuQcA9bxTQpMQ==} 7683 - 7684 7660 mdast-util-gfm-task-list-item@2.0.0: 7685 7661 resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} 7686 - 7687 - mdast-util-gfm@2.0.2: 7688 - resolution: {integrity: sha512-qvZ608nBppZ4icQlhQQIAdc6S3Ffj9RGmzwUKUWuEICFnd1LVkN3EktF7ZHAgfcEdvZB5owU9tQgt99e2TlLjg==} 7689 7662 7690 7663 mdast-util-gfm@3.0.0: 7691 7664 resolution: {integrity: sha512-dgQEX5Amaq+DuUqf26jJqSK9qgixgd6rYDHAv4aTBuA92cTknZlKpPfa86Z/s8Dj8xsAQpFfBmPUHWJBWqS4Bw==} ··· 7702 7675 mdast-util-mdxjs-esm@2.0.1: 7703 7676 resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} 7704 7677 7705 - mdast-util-phrasing@3.0.1: 7706 - resolution: {integrity: sha512-WmI1gTXUBJo4/ZmSk79Wcb2HcjPJBzM1nlI/OUWA8yk2X9ik3ffNbBGsU+09BFmXaL1IBb9fiuvq6/KMiNycSg==} 7707 - 7708 7678 mdast-util-phrasing@4.1.0: 7709 7679 resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} 7710 7680 ··· 7713 7683 7714 7684 mdast-util-to-hast@13.2.0: 7715 7685 resolution: {integrity: sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==} 7716 - 7717 - mdast-util-to-markdown@1.5.0: 7718 - resolution: {integrity: sha512-bbv7TPv/WC49thZPg3jXuqzuvI45IL2EVAr/KxF0BSdHsU0ceFHOmwQn6evxAh1GaoK/6GQ1wp4R4oW2+LFL/A==} 7719 7686 7720 7687 mdast-util-to-markdown@2.1.0: 7721 7688 resolution: {integrity: sha512-SR2VnIEdVNCJbP6y7kVTJgPLifdr8WEU440fQec7qHoHOUz/oJ2jmNRqdDQ3rbiStOXb2mCDGTuwsK5OPUgYlQ==} ··· 7751 7718 micromark-extension-frontmatter@2.0.0: 7752 7719 resolution: {integrity: sha512-C4AkuM3dA58cgZha7zVnuVxBhDsbttIMiytjgsM2XbHAB2faRVaHRle40558FBN+DJcrLNCoqG5mlrpdU4cRtg==} 7753 7720 7754 - micromark-extension-gfm-autolink-literal@1.0.5: 7755 - resolution: {integrity: sha512-z3wJSLrDf8kRDOh2qBtoTRD53vJ+CWIyo7uyZuxf/JAbNJjiHsOpG1y5wxk8drtv3ETAHutCu6N3thkOOgueWg==} 7756 - 7757 7721 micromark-extension-gfm-autolink-literal@2.1.0: 7758 7722 resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} 7759 - 7760 - micromark-extension-gfm-footnote@1.1.2: 7761 - resolution: {integrity: sha512-Yxn7z7SxgyGWRNa4wzf8AhYYWNrwl5q1Z8ii+CSTTIqVkmGZF1CElX2JI8g5yGoM3GAman9/PVCUFUSJ0kB/8Q==} 7762 7723 7763 7724 micromark-extension-gfm-footnote@2.1.0: 7764 7725 resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==} 7765 7726 7766 - micromark-extension-gfm-strikethrough@1.0.7: 7767 - resolution: {integrity: sha512-sX0FawVE1o3abGk3vRjOH50L5TTLr3b5XMqnP9YDRb34M0v5OoZhG+OHFz1OffZ9dlwgpTBKaT4XW/AsUVnSDw==} 7768 - 7769 7727 micromark-extension-gfm-strikethrough@2.1.0: 7770 7728 resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} 7771 7729 7772 - micromark-extension-gfm-table@1.0.7: 7773 - resolution: {integrity: sha512-3ZORTHtcSnMQEKtAOsBQ9/oHp9096pI/UvdPtN7ehKvrmZZ2+bbWhi0ln+I9drmwXMt5boocn6OlwQzNXeVeqw==} 7774 - 7775 7730 micromark-extension-gfm-table@2.1.0: 7776 7731 resolution: {integrity: sha512-Ub2ncQv+fwD70/l4ou27b4YzfNaCJOvyX4HxXU15m7mpYY+rjuWzsLIPZHJL253Z643RpbcP1oeIJlQ/SKW67g==} 7777 7732 7778 - micromark-extension-gfm-tagfilter@1.0.2: 7779 - resolution: {integrity: sha512-5XWB9GbAUSHTn8VPU8/1DBXMuKYT5uOgEjJb8gN3mW0PNW5OPHpSdojoqf+iq1xo7vWzw/P8bAHY0n6ijpXF7g==} 7780 - 7781 7733 micromark-extension-gfm-tagfilter@2.0.0: 7782 7734 resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} 7783 7735 7784 - micromark-extension-gfm-task-list-item@1.0.5: 7785 - resolution: {integrity: sha512-RMFXl2uQ0pNQy6Lun2YBYT9g9INXtWJULgbt01D/x8/6yJ2qpKyzdZD3pi6UIkzF++Da49xAelVKUeUMqd5eIQ==} 7786 - 7787 7736 micromark-extension-gfm-task-list-item@2.1.0: 7788 7737 resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==} 7789 - 7790 - micromark-extension-gfm@2.0.3: 7791 - resolution: {integrity: sha512-vb9OoHqrhCmbRidQv/2+Bc6pkP0FrtlhurxZofvOEy5o8RtuuvTq+RQ1Vw5ZDNrVraQZu3HixESqbG+0iKk/MQ==} 7792 7738 7793 7739 micromark-extension-gfm@3.0.0: 7794 7740 resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} ··· 8609 8555 postgres-range@1.1.4: 8610 8556 resolution: {integrity: sha512-i/hbxIE9803Alj/6ytL7UHQxRvZkI9O4Sy+J3HGc4F4oo/2eQAjTSNJ0bfxyse3bH0nuVesCk+3IRLaMtG3H6w==} 8611 8557 8612 - posthog-js@1.136.1: 8613 - resolution: {integrity: sha512-hM3PCDtPdyzO52l0FXEFAw1sI6PJm1U9U3MVanAcrOY3QgeJ+z241OnYm5XMrTyDF5ImCTWzq4p23moLQSZvDA==} 8614 - 8615 - posthog-node@4.0.1: 8616 - resolution: {integrity: sha512-rtqm2h22QxLGBrW2bLYzbRhliIrqgZ0k+gF0LkQ1SNdeD06YE5eilV0MxZppFSxC8TfH0+B0cWCuebEnreIDgQ==} 8617 - engines: {node: '>=15.0.0'} 8618 - 8619 8558 preact-render-to-string@5.2.3: 8620 8559 resolution: {integrity: sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA==} 8621 8560 peerDependencies: ··· 8623 8562 8624 8563 preact@10.11.3: 8625 8564 resolution: {integrity: sha512-eY93IVpod/zG3uMF22Unl8h9KkrcKIRs2EGar8hwLZZDU1lkjph303V9HZBwufh2s736U6VXuhD109LYqPoffg==} 8626 - 8627 - preact@10.22.0: 8628 - resolution: {integrity: sha512-RRurnSjJPj4rp5K6XoP45Ui33ncb7e4H7WiOHVpjbkvqvA3U+N8Z6Qbo0AE6leGYBV66n8EhEaFixvIu3SkxFw==} 8629 8565 8630 8566 prebuild-install@7.1.1: 8631 8567 resolution: {integrity: sha512-jAXscXWMcCK8GgCoHOfIr0ODh5ai8mj63L2nWrjuAgXE6tDyYGnx4/8o/rCgU+B4JSyZBKbeZqzhtwtC3ovxjw==} ··· 8967 8903 remark-frontmatter@5.0.0: 8968 8904 resolution: {integrity: sha512-XTFYvNASMe5iPN0719nPrdItC9aU0ssC4v14mH1BCi1u0n1gAocqcujWUrByftZTbLhRtiKRyjYTSIOcr69UVQ==} 8969 8905 8970 - remark-gfm@3.0.1: 8971 - resolution: {integrity: sha512-lEFDoi2PICJyNrACFOfDD3JlLkuSbOa5Wd8EPt06HUdptv8Gn0bxYTdbU/XXQ3swAPkEaGxxPN9cbnMHvVu1Ig==} 8972 - 8973 8906 remark-gfm@4.0.0: 8974 8907 resolution: {integrity: sha512-U92vJgBPkbw4Zfu/IiW2oTZLSL3Zpv+uI7My2eq8JxKgqraFdU8YUGicEJCEgSbeaG+QDFqIcwwfMTOEelPxuA==} 8975 8908 ··· 9104 9037 9105 9038 run-parallel@1.2.0: 9106 9039 resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} 9107 - 9108 - rusha@0.8.14: 9109 - resolution: {integrity: sha512-cLgakCUf6PedEu15t8kbsjnwIFFR2D4RfL+W3iWFJ4iac7z4B0ZI8fxy4R3J956kAI68HclCFGL8MPoUVC3qVA==} 9110 9040 9111 9041 rxjs@6.6.7: 9112 9042 resolution: {integrity: sha512-hTdwr+7yYNIT5n4AMYp85KA6yw2Va0FLa3Rguvbpa4W3I5xynaBZo41cM3XM+4Q6fRMj3sBYIR1VAmZMXYJvRQ==} ··· 9261 9191 react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc 9262 9192 react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc 9263 9193 9264 - sort-keys@5.0.0: 9265 - resolution: {integrity: sha512-Pdz01AvCAottHTPQGzndktFNdbRA75BgOfeT1hH+AMnJFv8lynkPi42rfeEhpx1saTEI3YNMWxfqu0sFD1G8pw==} 9194 + sort-keys@5.1.0: 9195 + resolution: {integrity: sha512-aSbHV0DaBcr7u0PVHXzM6NbZNAtrr9sF6+Qfs9UUVG7Ll3jQ6hHi8F/xqIIcn2rvIVbr0v/2zyjSdwSV47AgLQ==} 9266 9196 engines: {node: '>=12'} 9267 9197 9268 9198 source-map-js@1.2.0: ··· 12570 12500 12571 12501 '@one-ini/wasm@0.1.1': {} 12572 12502 12503 + '@openpanel/sdk@1.0.0': {} 12504 + 12573 12505 '@opentelemetry/api-logs@0.52.1': 12574 12506 dependencies: 12575 12507 '@opentelemetry/api': 1.9.0 ··· 13868 13800 nanoid: 5.0.7 13869 13801 postcss-nested: 6.0.1(postcss@8.4.49) 13870 13802 unhead: 1.9.15 13871 - unified: 11.0.4 13803 + unified: 11.0.5 13872 13804 vue: 3.4.31(typescript@5.7.2) 13873 13805 transitivePeerDependencies: 13874 13806 - '@jest/globals' ··· 16836 16768 node-domexception: 1.0.0 16837 16769 web-streams-polyfill: 3.2.1 16838 16770 16839 - fflate@0.4.8: {} 16840 - 16841 16771 figures@3.2.0: 16842 16772 dependencies: 16843 16773 escape-string-regexp: 1.0.5 ··· 17168 17098 hash-obj@4.0.0: 17169 17099 dependencies: 17170 17100 is-obj: 3.0.0 17171 - sort-keys: 5.0.0 17101 + sort-keys: 5.1.0 17172 17102 type-fest: 1.4.0 17173 17103 17174 17104 hasown@2.0.2: ··· 18029 17959 transitivePeerDependencies: 18030 17960 - supports-color 18031 17961 18032 - mdast-util-find-and-replace@2.2.2: 18033 - dependencies: 18034 - '@types/mdast': 3.0.14 18035 - escape-string-regexp: 5.0.0 18036 - unist-util-is: 5.2.1 18037 - unist-util-visit-parents: 5.1.3 18038 - 18039 17962 mdast-util-find-and-replace@3.0.1: 18040 17963 dependencies: 18041 17964 '@types/mdast': 4.0.4 ··· 18088 18011 transitivePeerDependencies: 18089 18012 - supports-color 18090 18013 18091 - mdast-util-gfm-autolink-literal@1.0.3: 18092 - dependencies: 18093 - '@types/mdast': 3.0.14 18094 - ccount: 2.0.1 18095 - mdast-util-find-and-replace: 2.2.2 18096 - micromark-util-character: 1.2.0 18097 - 18098 18014 mdast-util-gfm-autolink-literal@2.0.0: 18099 18015 dependencies: 18100 18016 '@types/mdast': 4.0.4 ··· 18103 18019 mdast-util-find-and-replace: 3.0.1 18104 18020 micromark-util-character: 2.1.0 18105 18021 18106 - mdast-util-gfm-footnote@1.0.2: 18107 - dependencies: 18108 - '@types/mdast': 3.0.14 18109 - mdast-util-to-markdown: 1.5.0 18110 - micromark-util-normalize-identifier: 1.1.0 18111 - 18112 18022 mdast-util-gfm-footnote@2.0.0: 18113 18023 dependencies: 18114 18024 '@types/mdast': 4.0.4 ··· 18119 18029 transitivePeerDependencies: 18120 18030 - supports-color 18121 18031 18122 - mdast-util-gfm-strikethrough@1.0.3: 18123 - dependencies: 18124 - '@types/mdast': 3.0.14 18125 - mdast-util-to-markdown: 1.5.0 18126 - 18127 18032 mdast-util-gfm-strikethrough@2.0.0: 18128 18033 dependencies: 18129 18034 '@types/mdast': 4.0.4 ··· 18132 18037 transitivePeerDependencies: 18133 18038 - supports-color 18134 18039 18135 - mdast-util-gfm-table@1.0.7: 18136 - dependencies: 18137 - '@types/mdast': 3.0.14 18138 - markdown-table: 3.0.3 18139 - mdast-util-from-markdown: 1.3.1 18140 - mdast-util-to-markdown: 1.5.0 18141 - transitivePeerDependencies: 18142 - - supports-color 18143 - 18144 18040 mdast-util-gfm-table@2.0.0: 18145 18041 dependencies: 18146 18042 '@types/mdast': 4.0.4 ··· 18151 18047 transitivePeerDependencies: 18152 18048 - supports-color 18153 18049 18154 - mdast-util-gfm-task-list-item@1.0.2: 18155 - dependencies: 18156 - '@types/mdast': 3.0.14 18157 - mdast-util-to-markdown: 1.5.0 18158 - 18159 18050 mdast-util-gfm-task-list-item@2.0.0: 18160 18051 dependencies: 18161 18052 '@types/mdast': 4.0.4 ··· 18165 18056 transitivePeerDependencies: 18166 18057 - supports-color 18167 18058 18168 - mdast-util-gfm@2.0.2: 18169 - dependencies: 18170 - mdast-util-from-markdown: 1.3.1 18171 - mdast-util-gfm-autolink-literal: 1.0.3 18172 - mdast-util-gfm-footnote: 1.0.2 18173 - mdast-util-gfm-strikethrough: 1.0.3 18174 - mdast-util-gfm-table: 1.0.7 18175 - mdast-util-gfm-task-list-item: 1.0.2 18176 - mdast-util-to-markdown: 1.5.0 18177 - transitivePeerDependencies: 18178 - - supports-color 18179 - 18180 18059 mdast-util-gfm@3.0.0: 18181 18060 dependencies: 18182 18061 mdast-util-from-markdown: 2.0.1 ··· 18238 18117 transitivePeerDependencies: 18239 18118 - supports-color 18240 18119 18241 - mdast-util-phrasing@3.0.1: 18242 - dependencies: 18243 - '@types/mdast': 3.0.14 18244 - unist-util-is: 5.2.1 18245 - 18246 18120 mdast-util-phrasing@4.1.0: 18247 18121 dependencies: 18248 18122 '@types/mdast': 4.0.4 ··· 18271 18145 unist-util-visit: 5.0.0 18272 18146 vfile: 6.0.3 18273 18147 18274 - mdast-util-to-markdown@1.5.0: 18275 - dependencies: 18276 - '@types/mdast': 3.0.14 18277 - '@types/unist': 2.0.9 18278 - longest-streak: 3.1.0 18279 - mdast-util-phrasing: 3.0.1 18280 - mdast-util-to-string: 3.2.0 18281 - micromark-util-decode-string: 1.1.0 18282 - unist-util-visit: 4.1.2 18283 - zwitch: 2.0.4 18284 - 18285 18148 mdast-util-to-markdown@2.1.0: 18286 18149 dependencies: 18287 18150 '@types/mdast': 4.0.4 ··· 18376 18239 micromark-util-symbol: 2.0.0 18377 18240 micromark-util-types: 2.0.0 18378 18241 18379 - micromark-extension-gfm-autolink-literal@1.0.5: 18380 - dependencies: 18381 - micromark-util-character: 1.2.0 18382 - micromark-util-sanitize-uri: 1.2.0 18383 - micromark-util-symbol: 1.1.0 18384 - micromark-util-types: 1.1.0 18385 - 18386 18242 micromark-extension-gfm-autolink-literal@2.1.0: 18387 18243 dependencies: 18388 18244 micromark-util-character: 2.1.0 ··· 18390 18246 micromark-util-symbol: 2.0.0 18391 18247 micromark-util-types: 2.0.0 18392 18248 18393 - micromark-extension-gfm-footnote@1.1.2: 18394 - dependencies: 18395 - micromark-core-commonmark: 1.1.0 18396 - micromark-factory-space: 1.1.0 18397 - micromark-util-character: 1.2.0 18398 - micromark-util-normalize-identifier: 1.1.0 18399 - micromark-util-sanitize-uri: 1.2.0 18400 - micromark-util-symbol: 1.1.0 18401 - micromark-util-types: 1.1.0 18402 - uvu: 0.5.6 18403 - 18404 18249 micromark-extension-gfm-footnote@2.1.0: 18405 18250 dependencies: 18406 18251 devlop: 1.1.0 ··· 18412 18257 micromark-util-symbol: 2.0.0 18413 18258 micromark-util-types: 2.0.0 18414 18259 18415 - micromark-extension-gfm-strikethrough@1.0.7: 18416 - dependencies: 18417 - micromark-util-chunked: 1.1.0 18418 - micromark-util-classify-character: 1.1.0 18419 - micromark-util-resolve-all: 1.1.0 18420 - micromark-util-symbol: 1.1.0 18421 - micromark-util-types: 1.1.0 18422 - uvu: 0.5.6 18423 - 18424 18260 micromark-extension-gfm-strikethrough@2.1.0: 18425 18261 dependencies: 18426 18262 devlop: 1.1.0 ··· 18430 18266 micromark-util-symbol: 2.0.0 18431 18267 micromark-util-types: 2.0.0 18432 18268 18433 - micromark-extension-gfm-table@1.0.7: 18434 - dependencies: 18435 - micromark-factory-space: 1.1.0 18436 - micromark-util-character: 1.2.0 18437 - micromark-util-symbol: 1.1.0 18438 - micromark-util-types: 1.1.0 18439 - uvu: 0.5.6 18440 - 18441 18269 micromark-extension-gfm-table@2.1.0: 18442 18270 dependencies: 18443 18271 devlop: 1.1.0 ··· 18446 18274 micromark-util-symbol: 2.0.0 18447 18275 micromark-util-types: 2.0.0 18448 18276 18449 - micromark-extension-gfm-tagfilter@1.0.2: 18450 - dependencies: 18451 - micromark-util-types: 1.1.0 18452 - 18453 18277 micromark-extension-gfm-tagfilter@2.0.0: 18454 18278 dependencies: 18455 18279 micromark-util-types: 2.0.0 18456 18280 18457 - micromark-extension-gfm-task-list-item@1.0.5: 18458 - dependencies: 18459 - micromark-factory-space: 1.1.0 18460 - micromark-util-character: 1.2.0 18461 - micromark-util-symbol: 1.1.0 18462 - micromark-util-types: 1.1.0 18463 - uvu: 0.5.6 18464 - 18465 18281 micromark-extension-gfm-task-list-item@2.1.0: 18466 18282 dependencies: 18467 18283 devlop: 1.1.0 ··· 18470 18286 micromark-util-symbol: 2.0.0 18471 18287 micromark-util-types: 2.0.0 18472 18288 18473 - micromark-extension-gfm@2.0.3: 18474 - dependencies: 18475 - micromark-extension-gfm-autolink-literal: 1.0.5 18476 - micromark-extension-gfm-footnote: 1.1.2 18477 - micromark-extension-gfm-strikethrough: 1.0.7 18478 - micromark-extension-gfm-table: 1.0.7 18479 - micromark-extension-gfm-tagfilter: 1.0.2 18480 - micromark-extension-gfm-task-list-item: 1.0.5 18481 - micromark-util-combine-extensions: 1.1.0 18482 - micromark-util-types: 1.1.0 18483 - 18484 18289 micromark-extension-gfm@3.0.0: 18485 18290 dependencies: 18486 18291 micromark-extension-gfm-autolink-literal: 2.1.0 ··· 19501 19306 postgres-range@1.1.4: 19502 19307 optional: true 19503 19308 19504 - posthog-js@1.136.1: 19505 - dependencies: 19506 - fflate: 0.4.8 19507 - preact: 10.22.0 19508 - 19509 - posthog-node@4.0.1: 19510 - dependencies: 19511 - axios: 1.7.2 19512 - rusha: 0.8.14 19513 - transitivePeerDependencies: 19514 - - debug 19515 - 19516 19309 preact-render-to-string@5.2.3(preact@10.11.3): 19517 19310 dependencies: 19518 19311 preact: 10.11.3 19519 19312 pretty-format: 3.8.0 19520 19313 19521 19314 preact@10.11.3: {} 19522 - 19523 - preact@10.22.0: {} 19524 19315 19525 19316 prebuild-install@7.1.1: 19526 19317 dependencies: ··· 19950 19741 rehype-parse@9.0.0: 19951 19742 dependencies: 19952 19743 '@types/hast': 3.0.4 19953 - hast-util-from-html: 2.0.1 19744 + hast-util-from-html: 2.0.3 19954 19745 unified: 11.0.5 19955 19746 19956 19747 rehype-pretty-code@0.10.0(shiki@0.14.4): ··· 20035 19826 transitivePeerDependencies: 20036 19827 - supports-color 20037 19828 20038 - remark-gfm@3.0.1: 20039 - dependencies: 20040 - '@types/mdast': 3.0.14 20041 - mdast-util-gfm: 2.0.2 20042 - micromark-extension-gfm: 2.0.3 20043 - unified: 10.1.2 20044 - transitivePeerDependencies: 20045 - - supports-color 20046 - 20047 19829 remark-gfm@4.0.0: 20048 19830 dependencies: 20049 19831 '@types/mdast': 4.0.4 ··· 20279 20061 dependencies: 20280 20062 queue-microtask: 1.2.3 20281 20063 20282 - rusha@0.8.14: {} 20283 - 20284 20064 rxjs@6.6.7: 20285 20065 dependencies: 20286 20066 tslib: 1.14.1 ··· 20488 20268 react: 19.0.0 20489 20269 react-dom: 19.0.0(react@19.0.0) 20490 20270 20491 - sort-keys@5.0.0: 20271 + sort-keys@5.1.0: 20492 20272 dependencies: 20493 20273 is-plain-obj: 4.1.0 20494 20274