Anveio
BlogAppsAbout

Published October 27, 2023

Saving $400/month on Vercel Analytics by using Edge Runtime and Planetscale's free tier instead

Planetscale UI showing some analytics events

A glimpse of the analytics table we'll be building

Vercel Analytics' free tier gives you 2,500 events a month, which isn't a lot. Its most efficient tier costs $20 per 500k events before you have to pick the phone and call for a better price. But you can set up an endpoint hosted on Vercel using the new Edge Runtime to get half-a-million invocations per-month for free and use that endpoint to write up to 10 million free analytics events per-month using Planetscale (I'm not affiliated in any way). The next 40 million events will cost you $29.

You can of course use any database you like but Planetscale is the cheapest managed solution. We're also going to be using Drizzle ORM in this tutorial because it makes the code simpler, safer, compiles down to regular SQL and if you want to bring your own database all you' have to do is delete a line of code. We'll also be using Vercel KV to do rate limiting.

The geolocation data Vercel provides is impressively precise, accurate to within 50 feet in some cases. I had to fake the geolocation data for this tutorial to avoid giving out the exact building I live in. Quite scary!

Just give me the code

Here's the code for a Next.js Route Handler that will read the IP addres and geolocation of a request and save it to a database. It handles rate limiting IP's per unique event type using a 5 second sliding window and validating the request body against a list of known events.

file_type_typescript
src/app/api/record-events/route.ts

if you use this code in your project don't forget to modify your next.config.js to protect this route with CORS so it doesn't get spammed from non-visitors. You also have the option to wall off recording events to authenticated-users-only by reading, e.g. a session token from the request using the cookie function exported from

next/server
and mapping the session token to a user. The route handler above does not do that and treats events as anonymous.

If you want to know more about how to set up the full tech stack used in that example, including Drizzle ORM and Planetscale to get 10 million free events a month, read on.

If you're adding analytics to a project using this exact tech stack already I expect it'll take just a few minutes to get this set up. If you're starting a project fresh, this entire tutorial will likely take just 30 minutes.

The high-level components

  1. A table for our analytics events
  2. An edge function deployed on Vercel that collects the user's IP and Geolocation data and writes the data to the table
  3. A lil' frontend app that pings this edge function from the user's browser.

Each of these components can be deployed independently but the example code will use a single Next.js app using the new App Directory to manage the database, the API, and the frontend.

To get this production ready wee'll also need to rate limit events per user and per event type and batch events on the client so that we minimize the total number of edge function invocations.

1. Install bun

If you'd rather use npm or yarn you can skip this. Vercel supports Bun now so may as well to speed up deployments and local development. You can use it alongside npm when Bun has some gap in feature parity.

Follow the instructions at https://bun.sh/docs/installation to install it.

It also helps to have the Vercel CLI to manage environment variables automatically, and you can install it with

file_type_shell
install-vercel-cli.sh

But you can choose to copy paste environment variables manually if you prefer.

2. Create the Next.js app

Run the below command from your command line to create a Next.js app using the App Router, Bun, and the

file_type_shell
create-next-app.sh

Now let's install the dependencies we'll need for the first iteration of our analytics.

@vercel/edge includes the utilities to pull the ip and geolocation from requests.

drizzle-kit wil let us perform migrations and push migrations to the connected database

drizzle-orm will allow us to write typesafe queries and take some boilerpalte out of the picture. It compiles down to SQL so there's no runtime cost to using it.

Zod will allow us to get some type safety on the server and discard invalid requests.

@planetscale/database exports a function that allows drizzle to create a connection to the Planetscale database, it's a set and forget config thing.

file_type_shell
install-initial-dependencies.sh

3. Set up the DB

Ok this section is a lot of boring hooking stuff up and copying around credentials but the good thing is you only have to do it once. Managing environment variables is the most tedious part of programming.

To get started create an account on Planetscale and create a table with a branch name of "dev". We'll connect to the dev branch for local development and the main branch for production.

Planetscale UI: Branch name = dev, base branch = main, region = us-east-2 (Ohio)

Then, from the overview tab, click connect on the top right and then "new password" on the top right of the modal that pops up. This will give you a connection string that includes the username, password, branch, and URL of the database. It's the only credential we need to connect to the DB from our app. Do this once for the "main" branch and once for the "dev" branch and make sure to copy the DATABASE_URL string for both as you won't be able to see it after creation. Next step is to copy these into Vercel

Planetscale UI: Connect button

(Skipping past setting up a project in Vercel, using git, and pushing to Github...) Navigate to the Environment Variables section in your Vercel project's settings, Uncheck "Preview" and "Development" and paste in the `DATABASE_URL="..."`environment variable using the credentials for the "main" branch of your Planetscale Database into the text fields and hit save. Do the same for the "dev" branch but uncheck "Production" and "Preview" before hitting save.

Now from a terminal somewhere in your project run the following commands to pull in the development environment variables into your local filesystem.

file_type_shell
link-vercel-and-pull.sh

Next, we'll start writing some code. First make files we'll put our core DB code in.

file_type_shell
create-db-folder.sh

In src/lib/db/db.ts we'll put the core code to initialize our DB and export the db connection to the rest of the codebase.

file_type_typescript
src/lib/db/db.ts

In the above codeblock we make sure we have DATABASE_URL set, and ensure it throws at build time if it's not. We also set up logging while in development mode but you can disable that entirely or even enable it in production. We export default a config that's read by Drizzle Kit so that it knows where to find our schemas to generate migrations and push DB changes.

Next, let's set up the schema for our analytics table:

file_type_typescript
src/lib/@/lib/db/schemas.ts

To be honest, I haven't found "flagEmoji" to be a particularly useful column as it seems redundant with the country column, but I include it for exhaustiveness. Feel free to remove it for your project.

The API for our analytics event is that events have an "event_type" and "metadata". We can enforce the presence or lack of metadata for some events as the API layer and get some type-safety at build time with TypeScript.

Feel free to play around with the character lengths of these columns. I'm using 50 for the event_type column as I plan to stuff as much information into a structured event_type string as possible, but if you prefer a different approach you can get away with a smaller character allocation.

    bun install --global vercel
    bunx create-next-app@latest --ts --app --src-dir --use-bun
    bun i @vercel/edge drizzle-kit drizzle-orm zod @planetscale/database
    vercel link
    vercel env pull
    mkdir -p src/lib/db
    touch src/lib/db/db.ts src/lib/@/lib/db/schema.ts

    1import { connect } from "@planetscale/database";
    2import { drizzle } from "drizzle-orm/planetscale-serverless";
    3import { Config } from "drizzle-kit";
    4
    5import { z } from "zod";
    6
    7export const DATABASE_URL = z
    8 .string({
    9 required_error: "DATABASE_URL missing",
    10 })
    11 .parse(process.env.DATABASE_URL);
    12
    13const connection = connect({
    14 url: DATABASE_URL,
    15});
    16
    17export const db = drizzle(
    18 connection,
    19 process.env.NODE_ENV === "development"
    20 ? {
    21 logger: {
    22 logQuery: console.log,
    23 },
    24 }
    25 : undefined
    26);
    27
    28export default {
    29 schema: "./src/lib/@/lib/db/schema.ts",
    30 driver: "mysql2",
    31 dbCredentials: {
    32 connectionString: DATABASE_URL,
    33 },
    34 out: "./src/lib/db/__generated__/migrations",
    35} satisfies Config;
    1
    2export const events = mysqlTable("blog_events", {
    3 /**
    4 * autoincrement() is a helper function that adds the `AUTO_INCREMENT` keyword
    5 */
    6 id: serial("id").primaryKey().autoincrement(),
    7 event_type: varchar("event_type", { length: 50 }).notNull(),
    8 /**
    9 * Start of properties provided by Vercel's Edge Runtime on the request object
    10 */
    11 ipAddress: varchar("ip_address", { length: 39 }),
    12 city: varchar("city", { length: 30 }),
    13 country: varchar("country", { length: 30 }),
    14 flagEmoji: varchar("flag", { length: 4 }),
    15 region: varchar("region", { length: 30 }),
    16 countryRegion: varchar("country_region", { length: 30 }),
    17 latitude: varchar("latitude", { length: 30 }),
    18 longitude: varchar("longitude", { length: 30 }),
    19 browser_name: varchar("browser_name", { length: 50 }),
    20 browser_version: varchar("browser_version", { length: 30 }),
    21 rendering_engine_name: varchar("rendering_engine_name", { length: 30 }),
    22 device_type: varchar("device_type", { length: 15 }),
    23 device_vendor: varchar("device_vendor", { length: 50 }),
    24 device_model: varchar("device_model", { length: 50 }),
    25 /**
    26 * End of the properties provided by Vercel's Edge Runtime
    27 */
    28 /**
    29 * There could be weeks between the event happening and it hitting our analytics
    30 * endpoint and subsequently being written into our database, so the auto-generated
    31 * created_at timestamp is not sufficient for our needs. We need the client to tell
    32 * us when the event happened.
    33 */
    34 client_recorded_at: timestamp("client_recorded_at").notNull(),
    35 metadata: json("metadata"),
    36 created_at: timestamp("created_at").notNull().defaultNow(),
    37});
    38
    1import { NextRequest, userAgentFromString } from "next/server";
    2import { geolocation, ipAddress } from "@vercel/edge";
    3import { db } from "@/lib/db/db";
    4import { events } from "@/lib/db/schema";
    5import { z } from "zod";
    6
    7import { Ratelimit } from "@upstash/ratelimit";
    8import { kv } from "@vercel/kv";
    9
    10export const runtime = "edge";
    11
    12/**
    13 * Set up the expected request JSON schema
    14 */
    15const requestBodySchema = z.array(
    16 z.object({
    17 eventType: z.enum(
    18 [
    19 "view:home",
    20 "view:blog:vercel_edge_analytics",
    21 "click:vercel_edge_analytics",
    22 ],
    23 {
    24 invalid_type_error: "Invalid event type",
    25 required_error: "Event type not provided",
    26 }
    27 ),
    28 clientRecordedAtUtcMillis: z.number(),
    29 metadata: z.record(z.any()).optional(),
    30 })
    31);
    32
    33const ratelimit = new Ratelimit({
    34 redis: kv,
    35 limiter: Ratelimit.slidingWindow(1, "5 s"),
    36});
    37
    38export const POST = async (request: NextRequest) => {
    39 if (process.env.NODE_ENV === "development") {
    40 return new Response(undefined, { status: 200 });
    41 }
    42
    43 /**
    44 * Pull the user agent out from the request headers
    45 */
    46 const userAgent = request.headers.get("user-agent");
    47
    48 /**
    49 * Parse the request JSON
    50 */
    51 const json = await request.json();
    52
    53 const parseResult = requestBodySchema.safeParse(json);
    54
    55 if (!parseResult.success) {
    56 /**
    57 * Return a 200 because the client doesn't necessarily need to know this failed
    58 * but make sure we log it for our own purposes.
    59 */
    60 console.error(`Failed to record event`, parseResult.error);
    61 return new Response(undefined, { status: 200 });
    62 }
    63
    64 const ua = userAgentFromString(userAgent || undefined);
    65 const geo = geolocation(request);
    66 const ip = ipAddress(request);
    67
    68 /**
    69 * We're making this a batch API so that callers can minimize the amount of
    70 * times they need to call this and we save ourselves some bandwidth.
    71 */
    72 const eventsUnderRateLimit = await Promise.all(
    73 parseResult.data.filter(async (event) => {
    74 const redisResponse = ratelimit.limit(`${ip}-${event.eventType}}`);
    75
    76 /**
    77 * Ensure that emitting none of these events exceeds the rate limit
    78 */
    79 if (!(await redisResponse).success) {
    80 console.error(
    81 `Rate limit exceeded for ${ipAddress} for eventL ${event.eventType}. Ignoring`
    82 );
    83 return true;
    84 } else {
    85 return false;
    86 }
    87 })
    88 );
    89
    90 if (eventsUnderRateLimit.length === 0) {
    91 console.warn(`Rate limit exceeded for all events. Ignoring`);
    92 return new Response(undefined, { status: 200 });
    93 } else {
    94 console.log(`Logging ${eventsUnderRateLimit.length} events`);
    95 }
    96
    97 await db.transaction(async (tx) => {
    98 for (let event of eventsUnderRateLimit) {
    99 /**
    100 * We can't do concurrent writes so do these writes serially.
    101 */
    102 await tx
    103 .insert(events)
    104 .values({
    105 event_type: event.eventType,
    106 ipAddress: ip,
    107 city: geo && geo.city ? decodeURIComponent(geo.city) : undefined,
    108 country: geo?.country,
    109 latitude: geo?.latitude,
    110 longitude: geo?.longitude,
    111 region: geo?.region,
    112 countryRegion: geo?.countryRegion,
    113 flagEmoji: geo?.flag,
    114 browser_version: ua.browser.version,
    115 browser_name: ua.browser.name,
    116 rendering_engine_name: ua.engine.name,
    117 device_type: ua.device.type,
    118 device_vendor: ua.device.vendor,
    119 device_model: ua.device.model,
    120 metadata: event.metadata,
    121 client_recorded_at: new Date(event.clientRecordedAtUtcMillis),
    122 })
    123 .execute();
    124 }
    125 });
    126
    127 return new Response(undefined, { status: 200 });
    128};