How I Know You Read This

How I Know You Read This

How blog sites knows read counts? Can I manipulate it by spaming the refreshing the page button? How the mechanism work behind the scenes?

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.

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:

  • Singleton Pattern: The Observer class ensures that there’s only one instance of itself through the getInstance method. This is perfect for scenarios where you want shared state—like tracking observations—without creating multiple redundant instances.
  • Observation Map: A static object observationMap is used to store which IPs have accessed which blogs. This is the backbone of our tracking system.
  • Memory Cleaning Settings: Two constants determine when old data is removed to keep memory usage efficient.

2. Setting Up the Observer Options

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

  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
  • Observation Map: This object stores the IP address hashes (for privacy) and the last visit time for each blog. ObserverMap type is defined as:
type ObserverMap = Record<
  ObserverIPHash, // Hashed IP address as string
  Record<
    BlogCardData["slug"], // Blog slug
    Date // Last visit time
  >
>
  • lastMemoryClean stores the time of the last memory cleanup. This is used to determine when to clean up old observations. Clearing old observations at every request can be inefficient, so we do it periodically.

  • Memory Cleaning Settings: These constants define when old observations are removed. The CLEAR_OLDER_THAN constant determines how long observations are kept, while MAX_CLEAN_INTERVAL sets the frequency of memory cleanup.

3. Observing Requests

The heart of the Observer lies in the observe method.

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:

  • Check If Tracking Is Needed: Not all routes need to be tracked, so the method first checks if the page is a blog using shouldTrackThisPage.
  • Extract Slug and Hash: The blog's slug is extracted from the URL, and the visitor’s IP is hashed for anonymity.
  • Check Recent Views: If the user hasn’t recently viewed the blog, their visit is recorded, and the view count is incremented.
  • Memory Cleaning: Periodically, old observations are removed to ensure optimal performance.

3. Memory Management

Let’s look at how observations are cleared:

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:

  • Efficient Cleanup: This method iterates through the observation map, removing records older than the defined threshold.
  • Maintaining State: Empty IP records are also cleaned up to avoid unnecessary memory usage.

4. Handling Blog Views

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

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?

  • Avoiding Spamming: The hasRecentView method ensures that views aren’t counted repeatedly within a short timeframe.
  • Tracking Visits: Each unique visit is logged in observationMap.
  • Incrementing Counts: The Drawer.increaseBlogView method updates the blog’s view count.

5. Working with IP Addresses

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

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")
}
  • Hashing for Privacy: IPs are hashed with SHA-256, ensuring anonymity while maintaining uniqueness.
  • Getting IPs: The x-forwarded-for header is used to extract the client’s IP address.

Full Code

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

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.