使用 Next.js、Tinybird 和 Tremor 构建实时分析仪表盘:综合指南 您将在本文中找到什么? Papermark - 开源 DocSend 替代方案。 设置项目 构建应用程序 结论 帮帮我!

2025-06-09

使用 Next.js、Tinybird 和 Tremor 构建实时分析仪表板:综合指南

您将在本文中发现什么?

Papermark - 开源的 DocSend 替代品。

设置项目

构建应用程序

结论

帮帮我!

您将在本文中发现什么?

实时分析已成为当今应用程序的重要组成部分,尤其是在理解用户行为并基于数据驱动的洞察改进平台方面。如果您的用户依赖您的产品来提供洞察,那么实时分析就显得尤为重要。

在本文中,我们将探讨如何使用 Tinybird、Tremor.so 和 Next.js 创建页面浏览量的实时分析仪表板。

我们将使用 Tinybird 进行数据提取和实时数据分析,使用 Tremor 进行数据可视化,并使用 Next.js 进行服务器端渲染。

分析 GIF

Papermark - 开源的 DocSend 替代品。

在开始之前,我先来介绍一下 Papermark。它是一个开源项目,用于安全地共享文档,内置实时逐页分析功能,由 Tinybird 提供支持,并由 Tremor 进行可视化。

如果你能给我们一颗星,我会非常高兴!别忘了在评论区分享你的想法❤️

https://github.com/mfts/papermark

Papermark 分析

设置项目

让我们设置项目环境。我们将设置一个 Next.js 应用,安装 Tinybird CLI,并配置所需的服务和工具。

准备茶

手边备有一个包管理器是个好主意,比如tea。它可以处理你的开发环境,简化你的(编程)生活!

sh <(curl https://tea.xyz)

# --- OR ---
# using brew
brew install teaxyz/pkgs/tea-cli
Enter fullscreen mode Exit fullscreen mode

tea让您可以专注于代码,因为它会负责安装pythonpipenv(我用它来运行tinybird-cli)、node以及npm您可能需要的任何其他软件包。最棒的是,它tea会将所有软件包安装在一个专用目录中(默认值~/.tea:),让您的系统文件保持整洁。

使用 TypeScript 和 Tailwindcss 设置 Next.js

我们将使用 create-next-app 生成一个新的 Next.js 项目。我们还将使用 TypeScript 和 Tailwind CSS,因此请确保在出现提示时选择这些选项。

npx create-next-app

# ---
# you'll be asked the following prompts
What is your project named?  my-app
Would you like to add TypeScript with this project?  Y/N
# select `Y` for typescript
Would you like to use ESLint with this project?  Y/N
# select `Y` for ESLint
Would you like to use Tailwind CSS with this project? Y/N
# select `Y` for Tailwind CSS
Would you like to use the `src/ directory` with this project? Y/N
# select `N` for `src/` directory
What import alias would you like configured? `@/*`
# enter `@/*` for import alias
Enter fullscreen mode Exit fullscreen mode

安装 Tinybird

Tinybird 的命令行界面 (CLI) 可以帮助我们管理数据源和管道。我正在tinybird-cli使用进行安装pipenv,它在我们的本地环境中为我的 pip 包管理一个虚拟环境。

# Navigate to your Next.js repo
cd my-app

# Create a new virtual environment and install Tinybird CLI
# if you install `tea` in the previous step, then tea will take care of installing pipenv and its dependencies
pipenv install tinybird-cli

# Activate the virtual environment
pipenv shell
Enter fullscreen mode Exit fullscreen mode

配置 Tinybird

前往tinybird.co创建一个免费账户。您需要拥有一个 Tinybird 账户和一个关联的代币。您可以从 Tinybird 的仪表盘获取代币。

图片描述

# Authenticate with tinybird-cli using your auth token when prompted
tb auth
Enter fullscreen mode Exit fullscreen mode

一个.tinyb文件将被添加到你的仓库根目录。无需修改。但请将其添加到你的.gitignore文件中,以避免暴露你的令牌。

echo ".tinyb" >> .gitignore
Enter fullscreen mode Exit fullscreen mode

构建应用程序

现在我们已经完成了设置,可以开始构建应用程序了。我们将介绍的主要功能包括:

  • Tinybird 管道和数据源
  • 页面浏览记录
  • 震颤条形图

#1 Tinybird 管道和数据源

Tinybird 能够以编程方式配置管道和数据源,这带来了显著的优势。这种灵活性使我们能够将数据基础设施视为代码,这意味着整个配置都可以提交到版本控制系统中。对于像 Papermark 这样的开源项目来说,这种功能非常有益。它促进了透明度和协作,因为贡献者可以轻松理解数据结构,而不会产生任何歧义。

我们按如下方式设置 Tinybird 管道和数据源:

mkdir -p lib/tinybird/{datasources,endpoints}
Enter fullscreen mode Exit fullscreen mode

1. 数据源

这基本上是一个版本化的架构page_views。这是我们提取、存储和读取页面浏览量分析数据所需的唯一数据源。您可以随意添加/删除字段。

# lib/tinybird/datasources/page_views.datasource
VERSION 1

DESCRIPTION >
  Page views are events when a user views a document

SCHEMA >
  `id` String `json:$.id`,
  `linkId` String `json:$.linkId`,
  `documentId` String `json:$.documentId`,
  `viewId` String `json:$.viewId`,
  # Unix timestamp
  `time` Int64 `json:$.time`,
  `duration` UInt32 `json:$.duration`,
  # The page number
  `pageNumber` LowCardinality(String) `json:$.pageNumber`,
  `country` String `json:$.country`,
  `city` String `json:$.city`,
  `region` String `json:$.region`,
  `latitude` String `json:$.latitude`,
  `longitude` String `json:$.longitude`,
  `ua` String `json:$.ua`,
  `browser` String `json:$.browser`,
  `browser_version` String `json:$.browser_version`,
  `engine` String `json:$.engine`,
  `engine_version` String `json:$.engine_version`,
  `os` String `json:$.os`,
  `os_version` String `json:$.os_version`,
  `device` String `json:$.device`,
  `device_vendor` String `json:$.device_vendor`,
  `device_model` String `json:$.device_model`,
  `cpu_architecture` String `json:$.cpu_architecture`,
  `bot` UInt8 `json:$.bot`,
  `referer` String `json:$.referer`,
  `referer_url` String `json:$.referer_url`


ENGINE "MergeTree"
ENGINE_SORTING_KEY "linkId,documentId,viewId,pageNumber,time,id"
Enter fullscreen mode Exit fullscreen mode

2.管道

在 Tinybird 中,管道是应用于数据的一系列转换操作。这些转换可能包括从简单的数据清理操作到复杂的聚合和分析操作。

我们有一个版本化的管道(有时也称为端点)来检索 page_view 数据。这个名为 的 Tinybird 管道endpoint会计算用户在特定时间范围内浏览特定文档每页的平均时长,并按页码升序显示结果。

# lib/endpoints/get_average_page_duration.pipe
VERSION 1

NODE endpoint
SQL >
    %
    SELECT
        pageNumber,
        AVG(duration) AS avg_duration
    FROM
        page_views__v1
    WHERE
        documentId = {{ String(documentId, required=True) }}
        AND time >= {{ Int64(since, required=True) }}
    GROUP BY
        pageNumber
    ORDER BY
        pageNumber ASC
Enter fullscreen mode Exit fullscreen mode

现在我们已经设置了 Tinybird 数据源和管道,您需要使用 CLI 将它们推送到您的 Tinybird 帐户。

# Navigate to the directory containing your datasource and pipe files
cd lib/tinybird

# Push your files to Tinybird
tb push datasources/*.datasource pipes/*.pipe
Enter fullscreen mode Exit fullscreen mode

3. Typescript 函数

让我们设置适当的 TypeScript 函数来实际发送和检索 Tinybird 的数据。我们使用zodchronark 的zod-bird

此函数用于从 Tinybird检索数据:

// lib/tinybird/pipes.ts
import { z } from "zod";
import { Tinybird } from "@chronark/zod-bird";

const tb = new Tinybird({ token: process.env.TINYBIRD_TOKEN! });

export const getTotalAvgPageDuration = tb.buildPipe({
  pipe: "get_total_average_page_duration__v1",
  parameters: z.object({
    documentId: z.string(),
    since: z.number(),
  }),
  data: z.object({
    pageNumber: z.string(),
    avg_duration: z.number(),
  }),
});
Enter fullscreen mode Exit fullscreen mode

此函数用于向 Tinybird发送数据:

// lib/tinybird/publish.ts
import { z } from "zod";
import { Tinybird } from "@chronark/zod-bird";

const tb = new Tinybird({ token: process.env.TINYBIRD_TOKEN! });

export const publishPageView = tb.buildIngestEndpoint({
  datasource: "page_views__v1",
  event: z.object({
    id: z.string(),
    linkId: z.string(),
    documentId: z.string(),
    viewId: z.string(),
    time: z.number().int(),
    duration: z.number().int(),
    pageNumber: z.string(),
    country: z.string().optional().default("Unknown"),
    city: z.string().optional().default("Unknown"),
    region: z.string().optional().default("Unknown"),
    latitude: z.string().optional().default("Unknown"),
    longitude: z.string().optional().default("Unknown"),
    ua: z.string().optional().default("Unknown"),
    browser: z.string().optional().default("Unknown"),
    browser_version: z.string().optional().default("Unknown"),
    engine: z.string().optional().default("Unknown"),
    engine_version: z.string().optional().default("Unknown"),
    os: z.string().optional().default("Unknown"),
    os_version: z.string().optional().default("Unknown"),
    device: z.string().optional().default("Desktop"),
    device_vendor: z.string().optional().default("Unknown"),
    device_model: z.string().optional().default("Unknown"),
    cpu_architecture: z.string().optional().default("Unknown"),
    bot: z.boolean().optional(),
    referer: z.string().optional().default("(direct)"),
    referer_url: z.string().optional().default("(direct)"),
  }),
});
Enter fullscreen mode Exit fullscreen mode

4. 配置生产环境的 Auth Token

别忘了添加TINYBIRD_TOKEN到你的.env文件中。建议你创建一个具有最小操作范围的令牌:

  • 从特定管道读取
  • 附加到特定数据源

图片描述

恭喜!🎉 您已成功配置 Tinybird,可以进入下一步了。

#2 页面浏览记录

我们将在 PDFviewer 组件中捕获页面查看事件并将其发布到 Tinybird 的数据源。

让我们构建一个 API 函数,以便在每次浏览页面时将数据发送到 Tinybird:

// pages/api/record_view.ts
import { NextApiRequest, NextApiResponse } from "next";
import { publishPageView } from "@/lib/tinybird";
import { z } from "zod";
import { v4 as uuidv4 } from 'uuid';

// Define the validation schema
const bodyValidation = z.object({
  id: z.string(),
  linkId: z.string(),
  documentId: z.string(),
  viewId: z.string(),
  time: z.number().int(),
  duration: z.number().int(),
  pageNumber: z.string(),
  ...
});

export default async function handle(
  req: NextApiRequest,
  res: NextApiResponse
) {
  // We only allow POST requests
  if (req.method !== "POST") {
    res.status(405).json({ message: "Method Not Allowed" });
    return;
  }

  const { linkId, documentId, viewId, duration, pageNumber } = req.body;
  const time = Date.now(); // in milliseconds
  const pageViewId = uuidv4();

  const pageViewObject = {
    id: pageViewId,
    linkId,
    documentId,
    viewId,
    time,
    duration,
    pageNumber: pageNumber.toString(),
    ...
  };

  const result = bodyValidation.safeParse(pageViewObject);
  if (!result.success) {
    return res.status(400).json(
      { error: `Invalid body: ${result.error.message}` }
    );
  }

  try {
    await publishPageView(result.data);
    res.status(200).json({ message: "View recorded" });
  } catch (error) {
    res.status(500).json({ message: (error as Error).message });
  }
}
Enter fullscreen mode Exit fullscreen mode

最后是发送请求的 PDFViewer 组件:

// components/PDFViewer.tsx
import { useState, useEffect } from 'react';

const PDFViewer = () => {
  const [pageNumber, setPageNumber] = useState<number>(1)

  useEffect(() => {
    startTime = Date.now(); // update the start time for the new page

    // when component unmounts, calculate duration and track page view
    return () => {
      const endTime = Date.now();
      const duration = Math.round(endTime - startTime);
      trackPageView(duration);
    };
  }, [pageNumber]); // monitor pageNumber for changes

  async function trackPageView(duration: number = 0) {
    await fetch("/api/record_view", {
      method: "POST",
      body: JSON.stringify({
        linkId: props.linkId,
        documentId: props.documentId,
        viewId: props.viewId,
        duration: duration,
        pageNumber: pageNumber,
      }),
      headers: {
        "Content-Type": "application/json",
      },
    });
  }

  return (
    // Your PDF Viewer implementation
  );
}

export default PDFViewer;
Enter fullscreen mode Exit fullscreen mode

#3 震颤条形图

现在让我们创建一个条形图来显示每个文档的页面浏览量。我们使用tremor.so来构建我们漂亮的仪表板。

# Install tremor with their CLI 
npx @tremor/cli@latest init
Enter fullscreen mode Exit fullscreen mode
// components/bar-chart.tsx
import { BarChart } from "@tremor/react";

const timeFormatter = (number) => {
  const totalSeconds = Math.floor(number / 1000);
  const minutes = Math.floor(totalSeconds / 60);
  const seconds = Math.round(totalSeconds % 60);

  // Adding zero padding if seconds less than 10
  const secondsFormatted = seconds < 10 ? `0${seconds}` : `${seconds}`;

  return `${minutes}:${secondsFormatted}`;
};

export default function BarChartComponent({data}) {
  return (
    <BarChart
      className="mt-6 rounded-tremor-small"
      data={data}
      index="pageNumber"
      categories={["Time spent per page"]}
      colors={["gray"]}
      valueFormatter={timeFormatter}
      yAxisWidth={50}
      showGridLines={false}
    />
  );
}
Enter fullscreen mode Exit fullscreen mode
// lib/swr/use-stats.ts
import { useRouter } from "next/router";
import useSWR from "swr";
import { getTotalAvgPageDuration } from "@/lib/tinybird/pipes";

export function useStats() {
  const router = useRouter();
  const { id } = router.query as { id: string };

  const { data, error } = useSWR(
    id,
    () => getTotalAvgPageDuration({ documentId: id, since: 0 
}),
    {
      dedupingInterval: 10000,
    }
  );

  return {
    durationData: data,
    isLoading: !error && !data,
    error,
  };
}
Enter fullscreen mode Exit fullscreen mode
// pages/document/[id].tsx
import { useDocument } from "@/lib/swr/use-document";
import { useStats } from "@/lib/swr/use-stats";
import BarChartComponent from "@/components/bar-chart";

export default function DocumentPage() {
  const { document, error: documentError } = useDocument();
  const { stats, error: statsError } = useStats();

  if (documentError) {
    // handle document error
  }

  if (statsError) {
    // handle stats error
  }

  return (
    <>
      <main>
        {document && (
          <header>
            <h1>{document.name}</h1>
            {stats && <BarChartComponent data={stats.durationData} />}
          </header>
        )}
      </main>
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

瞧!🎉 带有精确逐页时间的条形图!

图片描述

结论

就这样!我们使用 Tinybird、Tremor 和 Next.js 构建了一个用于页面浏览量的实时分析仪表盘。虽然这里的示例很简单,但相同的概念可以扩展,以处理您的应用可能需要执行的任何类型的分析。

感谢您的阅读。我是 Marc,一位开源倡导者。我正在开发papermark.com ——DocSend 的开源替代方案,提供毫秒级精确的页面分析功能。

帮帮我!

如果您觉得这篇文章对您有帮助,并且能够更好地理解 Tinybird 以及 Tremor 的仪表盘功能,请给我们一颗星,我将不胜感激!也别忘了在评论区分享您的想法 ❤️

https://github.com/mfts/papermark

图片描述

链接:https://dev.to/mfts/building-a-real-time-analytics-dashboard-with-nextjs-tinybird-and-tremor-a-comprehensive-guide-15k0
PREV
使用 Nx 改进 React 微前端
NEXT
使用 2 个 shadcn/ui 组件构建可扩展/可折叠数据表