Amblem
Furkan Baytekin

How I Know You Read This

Lets measure blog read counts of your blogs

How I Know You Read This
112
6 minutes

In this blog post, we’ll explore how the Observer class works to track blog post views in a Next.js middleware. We’ll examine each code segment step-by-step, explaining what it does and why it’s used. By the end, you’ll have the full picture and complete code.

Let’s dive in!


1. Setting the Stage with a Singleton

The first part of the code sets up the Observer as a singleton.

typescript
class Observer { static instance: Observer private constructor() { Observer.observationMap = {} Observer.lastMemoryClean = new Date() } public static getInstance(): Observer { if (!this.instance) this.instance = new Observer() return this.instance } }

Here’s what’s happening:


2. Setting Up the Observer Options

Next, we define options that control the Observer’s behavior:

typescript
private static observationMap: ObserverMap private static lastMemoryClean: Date private static readonly CLEAR_OLDER_THAN = 1000 * 60 * 10 // 10 minutes private static readonly MAX_CLEAN_INTERVAL = 1000 * 60 * 10 // 10 minutes
typescript
type ObserverMap = Record< ObserverIPHash, // Hashed IP address as string Record< BlogCardData["slug"], // Blog slug Date // Last visit time > >

3. Observing Requests

The heart of the Observer lies in the observe method.

typescript
public async observe(request: NextRequest) { const pathname = request.nextUrl.pathname if (!Observer.shouldTrackThisPage(pathname)) return const slug = this.getSlugToAdd(pathname) const ipHash = await this.getIpHash(request) if (!slug || !ipHash) return if (!this.hasRecentView(ipHash, slug)) { this.addView(ipHash, slug, new Date()) this.incrementBlogView(slug) } if (this.doNeedMemoryClean()) this.clearOldObservations() }

Here’s the breakdown:


3. Memory Management

Let’s look at how observations are cleared:

typescript
private clearOldObservations(): void { const now = new Date() for (const ipHash in Observer.observationMap) { const record = Observer.observationMap[ipHash] for (const slug in record) { if (now.getTime() - record[slug].getTime() > Observer.CLEAR_OLDER_THAN ) delete record[slug] } if (Object.keys(record).length === 0) delete Observer.observationMap[ipHash] } Observer.lastMemoryClean = now }

Key points:


4. Handling Blog Views

When a blog is visited, the view count is incremented, but only if it hasn’t been visited recently:

typescript
private hasRecentView(ipHash: ObserverIPHash, slug: BlogCardData["slug"]): boolean { const record = Observer.observationMap[ipHash] if (!record || !record[slug]) return false const lastView = record[slug] return new Date().getTime() - lastView.getTime() <= Observer.CLEAR_OLDER_THAN } private addView(ipHash: ObserverIPHash, slug: BlogCardData["slug"], date: Date): void { if (!Observer.observationMap[ipHash]) Observer.observationMap[ipHash] = {} Observer.observationMap[ipHash][slug] = date } private incrementBlogView(slug: BlogCardData["slug"]): void { Drawer.increseBlogView(slug) }

Drawer is a repository class that handles blog data. If you aren’t familiar with repository classes, go check out my blog post on them! Why Use Repository Classes?

What’s Happening?


5. Working with IP Addresses

To ensure privacy, IP addresses are hashed before being stored:

typescript
private async getIpHash(request: NextRequest): Promise<ObserverIPHash | null> { const ip = this.getIpAddress(request) if (!ip) return null const data = new TextEncoder().encode(ip) const hashBuffer = await crypto.subtle.digest("SHA-256", data) const hashArray = Array.from(new Uint8Array(hashBuffer)) return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("") } private getIpAddress(request: NextRequest): string | null { return request.headers.get("x-forwarded-for") }

Full Code

Here’s the complete code we’ve walked through:

typescript
import { NextRequest } from "next/server" import Drawer from "../Drawer" class Observer { static instance: Observer private static observationMap: ObserverMap private static lastMemoryClean: Date private static readonly CLEAR_OLDER_THAN = 1000 * 60 * 10 // 10 minutes private static readonly MAX_CLEAN_INTERVAL = 1000 * 60 * 10 // 10 minutes private constructor() { Observer.observationMap = {} Observer.lastMemoryClean = new Date() } public static getInstance(): Observer { if (!this.instance) this.instance = new Observer() return this.instance } public async observe(request: NextRequest) { const pathname = request.nextUrl.pathname if (!Observer.shouldTrackThisPage(pathname)) return const slug = this.getSlugToAdd(pathname) const ipHash = await this.getIpHash(request) if (!slug || !ipHash) return if (!this.hasRecentView(ipHash, slug)) { this.addView(ipHash, slug, new Date()) this.incrementBlogView(slug) } if (this.doNeedMemoryClean()) this.clearOldObservations() } private clearOldObservations(): void { const now = new Date() for (const ipHash in Observer.observationMap) { const record = Observer.observationMap[ipHash] for (const slug in record) { if (now.getTime() - record[slug].getTime() > Observer.CLEAR_OLDER_THAN ) delete record[slug] } if (Object.keys(record).length === 0) delete Observer.observationMap[ipHash] } Observer.lastMemoryClean = now } private hasRecentView( ipHash: ObserverIPHash, slug: BlogCardData["slug"] ): boolean { const record = Observer.observationMap[ipHash] if (!record || !record[slug]) return false const lastView = record[slug] return new Date().getTime() - lastView.getTime() <= Observer.CLEAR_OLDER_THAN } private addView( ipHash: ObserverIPHash, slug: BlogCardData["slug"], date: Date ): void { if (!Observer.observationMap[ipHash]) Observer.observationMap[ipHash] = {} Observer.observationMap[ipHash][slug] = date } private doNeedMemoryClean(): boolean { const now = new Date() return now.getTime() - Observer.lastMemoryClean.getTime() >= Observer.MAX_CLEAN_INTERVAL } private getSlugToAdd(pathname: string): BlogCardData["slug"] | null { const match = pathname.match(/\/blogs\/[^/]+\/([^/]+)/) return match ? match[1] : null } private static shouldTrackThisPage(pathname: string): boolean { return /^\/blogs\/[^/]+\/[^/]+$/.test(pathname) } private incrementBlogView(slug: BlogCardData["slug"]): void { Drawer.increseBlogView(slug) } private async getIpHash( request: NextRequest ): Promise<ObserverIPHash | null> { const ip = this.getIpAddress(request) if (!ip) return null const data = new TextEncoder().encode(ip) const hashBuffer = await crypto.subtle.digest("SHA-256", data) const hashArray = Array.from(new Uint8Array(hashBuffer)) return hashArray.map((b) => b.toString(16).padStart(2, "0")).join("") } private getIpAddress(request: NextRequest): string | null { return request.headers.get("x-forwarded-for") } } export default Observer

This middleware ensures that we accurately track blog views while maintaining user privacy. By using techniques like IP hashing, memory management, and a singleton structure, it’s both efficient and scalable.

Suggested Blog Posts