Openstatus
www.openstatus.dev
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.