Openstatus www.openstatus.dev
at 20d0eeac16db94063a196dbfa8cb02fb172cac98 197 lines 7.2 kB view raw
1--- 2title: How We Implemented Event Analytics with OpenPanel 3description: 4 Leveraging Hono OpenAPI middleware and tRPC metadata + middleware to implement event analytics easily. 5author: 6 name: Maximilian Kaske 7 url: https://x.com/mxkaske 8 avatar: /assets/authors/max.png 9publishedAt: 2024-12-27 10image: /assets/posts/event-analytics-implementation/event-analytics-implementation.png 11tag: engineering 12--- 13 14We 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 16After 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 18This 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 22First, 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 26export type EventProps = { 27 name: string; 28 channel: string; 29}; 30 31export 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 44Next, 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 48import { 49 OpenPanel, 50 type PostEventPayload, 51 type IdentifyPayload, 52} from "@openpanel/sdk"; 53import { type EventProps } from "@openstatus/analytics"; 54 55const op = new OpenPanel({ 56 clientId: process.env.NEXT_PUBLIC_OPENPANEL_CLIENT_ID, 57 clientSecret: process.env.OPENPANEL_CLIENT_SECRET, 58}); 59 60export 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 74Now 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 78import { after } from "next/server"; 79import { initTRPC } from "@trpc/server"; 80import { setupAnalytics, type EventProps } from "@openstatus/analytics"; 81import type { User } from "@openstatus/auth"; 82 83type Context = { user?: User }; 84type Meta = { track?: EventProps }; 85 86export const t = initTRPC 87 .context<Context>() 88 .meta<Meta>() 89 .create({ /* ... */ }); 90 91 92const 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 107const enforceUserSession = t.middleware(async opts => { 108 // ... set user to ctx 109}); 110 111export const protectedProcedure = t.procedure 112 .use(enforceUserSession) 113 .use(trackEvent); 114``` 115 116The `after` function (similar to `waitUntil`) will execute the tracking after the procedure has been executed and won't block the response. 117 118The `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 120How 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 124import { Events } from '@openstatus/analytics'; 125import { insertPageSchema } from "@openstatus/db"; 126import { createTRPCRouter, protectedProcedure } from "../trpc"; 127 128export const pageRouter = createTRPCRouter({ 129 create: protectedProcedure 130 .meta({ track: Events.CreatePage }) 131 .input(insertPageSchema) 132 .mutation(async (opts) => { /* ... */ }) 133}); 134``` 135 136Voilà! 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 140Now, 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 144import { setupAnalytics, type EventProps } from "@openstatus/analytics"; 145import type { Context, Next } from "hono"; 146import type { User } from "@openstatus/auth"; 147 148export function trackMiddleware(event: EventProps) { 149 return async (c: Context<{ Variables: { user?: User } }, "/*">, next: Next) => { 150 await next(); 151 152 const isValid = c.res.status.toString().startsWith("2") && !c.error; 153 154 if (isValid) { 155 setTimeout(async () => { 156 const analytics = await setupAnalytics({ 157 profileId: c.get("user")?.id, 158 }); 159 await analytics.track(event); 160 }, 0); 161 } 162 }; 163} 164``` 165 166Depending 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. 167 168And again, we check if there was an `error` before tracking the event. We don't want to track unsuccessful events. 169 170The [`@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. 171 172```ts {11} 173// apps/web/src/pages/post.ts 174import { createRoute } from "@hono/zod-openapi"; 175import { Events } from "@openstatus/analytics"; 176import { trackMiddleware } from "../middleware"; 177 178const postRoute = createRoute({ 179 method: "post", 180 tags: ["page"], 181 description: "Create a new Page", 182 path: "/", 183 middleware: [trackMiddleware(Events.CreatePage)], 184 request: { /* ... */ }, 185 responses: { /* ... */}, 186}); 187 188// ... 189``` 190 191--- 192 193And 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! 194 195By the way, this approach can be used for audit log tracking for example as well. 196 197Check 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.