openstatus logoPricingDocsDashboard

How We Implemented Event Analytics with OpenPanel

Dec 27, 2024 | by Maximilian Kaske | [engineering]

How We Implemented Event Analytics with OpenPanel

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.

After some research, we finally settled on leveraging tRPC and Hono middlewares. Shoutout to Midday for the (tRPC) inspiration. They use a similar approach with next-safe-action for their server actions.

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 or tRPC documentation for more detailed information and our GitHub repository for the full implementation.


First, let's start with the basics. We need to define the events we want to track, like page_created, user_created, etc.

// packages/analytics/src/events.ts
export type EventProps = {
  name: string;
  channel: string;
};

export const Events = {
  CreatePage: {
    name: "page_created",
    channel: "page",
  },
  UpdatePage: {
    name: "page_upated",
    channel: "page",
  },
  // ... add more events
} as const satisfies Record<string, EventProps>;

Next, we need to initialize OpenPanel (see installation) and set up the analytics in our application.

// packages/analytics/src/index.ts
import {
  OpenPanel,
  type PostEventPayload,
  type IdentifyPayload,
} from "@openpanel/sdk";
import { type EventProps } from "@openstatus/analytics";

const op = new OpenPanel({
  clientId: process.env.NEXT_PUBLIC_OPENPANEL_CLIENT_ID,
  clientSecret: process.env.OPENPANEL_CLIENT_SECRET,
});

export async function setupAnalytics(props: Partial<IdentifyPayload>) {
  if (props.profileId) {
    await op.identify(props):
  }

  return {
    track: (opts: EventProps & PostEventPayload["properties"]) => {
      const { name, ...rest } = opts;
      return op.track(name, rest);
    },
  };
}

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.

// packages/trpc/src/index.ts
import { after } from "next/server";
import { initTRPC } from "@trpc/server";
import { setupAnalytics, type EventProps } from "@openstatus/analytics";
import type { User } from "@openstatus/auth";

type Context = { user?: User };
type Meta = { track?: EventProps };

export const t = initTRPC
  .context<Context>()
  .meta<Meta>()
  .create({ /* ... */ });


const trackEvent = t.middleware(async opts => {
  const result = await opts.next(opts.ctx);
  
  if (!result.ok) return result;

  if (opts.meta.track) {
    after(async () => {
      const identify = opts.ctx.user ? { userId: opts.ctx.user.id } : {};
      const analytics = await setupAnalytics(identify);
      await analytics.track(opts.meta.track);
    })
  }
  return result;
});

const enforceUserSession = t.middleware(async opts => { 
  // ... set user to ctx
});

export const protectedProcedure = t.procedure
  .use(enforceUserSession)
  .use(trackEvent);

The after function (similar to waitUntil) will execute the tracking after the procedure has been executed and won't block the response.

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.

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.

// packages/trpc/src/router/page.ts
import { Events } from '@openstatus/analytics';
import { insertPageSchema } from "@openstatus/db";
import { createTRPCRouter, protectedProcedure } from "../trpc";

export const pageRouter = createTRPCRouter({
  create: protectedProcedure
    .meta({ track: Events.CreatePage })
    .input(insertPageSchema)
    .mutation(async (opts) => { /* ... */ })
});

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.


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.

// app/server/src/middleware.ts
import { setupAnalytics, type EventProps } from "@openstatus/analytics";
import type { Context, Next } from "hono";
import type { User } from "@openstatus/auth";

export function trackMiddleware(event: EventProps) {
  return async (c: Context<{ Variables: { user?: User } }, "/*">, next: Next) => {
    await next();
    
    const isValid = c.res.status.toString().startsWith("2") && !c.error;

    if (isValid) {
      setTimeout(async () => {
        const analytics = await setupAnalytics({
          profileId: c.get("user")?.id,
        });
        await analytics.track(event);
      }, 0);
    }
  };
}

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.

And again, we check if there was an error before tracking the event. We don't want to track unsuccessful events.

The @hono/zod-openapi routes have a middleware property that allows you to add middleware to the route. This is where we will add the tracking middleware.

// apps/web/src/pages/post.ts
import { createRoute } from "@hono/zod-openapi";
import { Events } from "@openstatus/analytics";
import { trackMiddleware } from "../middleware";

const postRoute = createRoute({
  method: "post",
  tags: ["page"],
  description: "Create a new Page",
  path: "/",
  middleware: [trackMiddleware(Events.CreatePage)],
  request: { /* ... */ },
  responses: { /* ... */},
});

// ...

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 with any other analytics provider like PostHog, but give it a try, it's an amazing tool!

By the way, this approach can be used for audit log tracking for example as well.

Check out our GitHub repository for the full implementation and don't forget to leave a star if you found this helpful.