إذا حاولت يوماً إلصاق تحليلات على قاعدة بيانات Postgres قائمة، فأنت تعرف الألم جيداً. الاستعلامات التي تشغل لوحات المعلومات تختلف كلياً عن استعلامات منتجك، وفي اللحظة التي تتجاوز فيها جداول الأحداث بضعة ملايين صف، تتحول كل عملية تجميع إلى أزمة قلبية صغيرة. Tinybird يقلب هذه المعادلة رأساً على عقب. إنها منصة تحليلات فورية مُدارة مبنية على ClickHouse، مصممة لمهندسي المنتج الذين يريدون استقبال أحداث متدفقة وعرضها عبر نقاط نهاية SQL مُصدَّرة خلال دقائق، لا أسابيع.
في هذا الدرس، ستبني لوحة تحليلات SaaS فورية لمنتج افتراضي. ستضخ مشاهدات الصفحات وأحداث الميزات من تطبيق Next.js 15، وستحوّلها عبر أنابيب Tinybird، وستعرض النتائج كنقاط نهاية API موثّقة، وسترسم رسوماً بيانية حية باستخدام Recharts. بنهاية الدرس سيكون لديك نظام يتعامل بسهولة مع مئات الملايين من الأحداث مع استعلامات لوحة معلومات بزمن استجابة دون الثانية.
المتطلبات الأساسية
قبل البدء، تأكد من توفر ما يلي:
- إصدار Node.js رقم 20 أو أحدث مثبت
- أساسيات Next.js 15 (App Router و Server Components)
- معرفة أساسية بـ SQL (SELECT و GROUP BY و JOIN)
- حساب Tinybird (خطة Build المجانية كافية)
- محرر شيفرة (يُنصح بـ VS Code)
ينبغي أن تكون مرتاحاً مع قراءة TypeScript. الإلمام بـ ClickHouse مفيد لكنه ليس شرطاً لأن Tinybird يُخفي معظم التعقيد التشغيلي.
ما الذي ستبنيه
وحدة تحليلات عاملة تتكوّن من:
- جامع أحداث يلتقط مشاهدات الصفحات والأحداث المخصصة من تطبيق Next.js
- مساحة عمل Tinybird فيها مصادر بيانات وعروض ماديّة وأنابيب
- ثلاث نقاط نهاية SQL: أكثر الصفحات زيارة، والمستخدمون النشطون يومياً، وتدفق أحداث فوري
- لوحة Next.js ترسم الرسوم البيانية وتُحدّث نفسها كل بضع ثوانٍ
- طبقة تفويض قائمة على رموز توثيق تحصر البيانات لكل مساحة عمل
تبدو البنية المعمارية كما يلي. يرسل تطبيق Next.js الأحداث إلى Tinybird Events API. يخزنها Tinybird في مصدر بيانات Landing، ثم تجمعها العروض الماديّة في إحصائيات مجمّعة. تكشف الأنابيب هذه الإحصائيات كنقاط نهاية JSON. تجلبها لوحة Next.js من server components وتعيد التحقق على فاصل زمني قصير.
الخطوة 1: إعداد المشروع
أنشئ مشروع Next.js جديداً وثبّت التبعيات التي ستحتاجها.
npx create-next-app@latest saas-analytics --typescript --tailwind --app
cd saas-analytics
npm install @tinybirdco/mockingbird recharts zod date-fns
npm install -D @types/nodeأضف إعدادات Tinybird إلى متغيرات البيئة. أنشئ ملف .env.local في جذر المشروع.
# .env.local
NEXT_PUBLIC_TINYBIRD_HOST=https://api.tinybird.co
TINYBIRD_INGEST_TOKEN=your_ingest_token_here
TINYBIRD_READ_TOKEN=your_read_token_here
TINYBIRD_ADMIN_TOKEN=your_admin_token_hereستملأ الرموز الفعلية بعد قليل. الآن، أنشئ مساعداً مُكتَب الأنواع للعميل سنعيد استخدامه عبر التطبيق.
// lib/tinybird.ts
const host = process.env.NEXT_PUBLIC_TINYBIRD_HOST!;
export async function tbQuery<T>(
pipe: string,
params: Record<string, string | number> = {},
token = process.env.TINYBIRD_READ_TOKEN!
): Promise<{ data: T[]; meta: unknown[] }> {
const search = new URLSearchParams(
Object.entries(params).reduce<Record<string, string>>((acc, [k, v]) => {
acc[k] = String(v);
return acc;
}, {})
);
const url = `${host}/v0/pipes/${pipe}.json?${search.toString()}`;
const res = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
next: { revalidate: 5 },
});
if (!res.ok) throw new Error(`Tinybird ${pipe} failed: ${res.status}`);
return res.json();
}
export async function tbIngest(event: Record<string, unknown>) {
const url = `${host}/v0/events?name=events_landing`;
const res = await fetch(url, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.TINYBIRD_INGEST_TOKEN}`,
"Content-Type": "application/json",
},
body: JSON.stringify(event),
});
if (!res.ok) throw new Error(`Tinybird ingest failed: ${res.status}`);
}تخبر تلميحة revalidate: 5 إطار Next.js بأن يُخزّن الاستجابات لمدة خمس ثوانٍ، وهي النقطة المثلى للوحة فورية لا تحتاج إلى تحديث بالملي ثانية.
الخطوة 2: إنشاء مساحة عمل Tinybird ومصدر البيانات
سجل الدخول إلى حسابك في Tinybird، وأنشئ مساحة عمل باسم saas-analytics، ثم ثبّت Tinybird CLI لتُدير المخطط كشيفرة.
pip install tinybird-cli
tb auth --token YOUR_ADMIN_TOKEN
tb workspace lsالآن عرّف مصدر بيانات Landing. هنا تحطّ كل حدث خام. أنشئ ملفاً في tinybird/datasources/events_landing.datasource.
SCHEMA >
`timestamp` DateTime `json:$.timestamp`,
`workspace_id` String `json:$.workspace_id`,
`user_id` String `json:$.user_id`,
`session_id` String `json:$.session_id`,
`event` String `json:$.event`,
`path` String `json:$.path`,
`referrer` String `json:$.referrer`,
`country` String `json:$.country`,
`properties` String `json:$.properties`
ENGINE "MergeTree"
ENGINE_PARTITION_KEY "toYYYYMM(timestamp)"
ENGINE_SORTING_KEY "workspace_id, timestamp, event"
ENGINE_TTL "timestamp + INTERVAL 180 DAY"بعض الخيارات تستحق التوضيح. مفتاح الفرز يبدأ بـ workspace_id لأن كل استعلام لوحة معلومات يُحصر بمستأجر واحد، ويستطيع ClickHouse تخطّي أقسام كاملة عندما تتطابق بادئة الفرز. مدة بقاء TTL البالغة 180 يوماً تُنهي صلاحية الأحداث الخام تلقائياً مع الإبقاء على الإحصائيات الماديّة سليمة، وهكذا تبقى على خطة رخيصة دون فقدان الاتجاهات طويلة الأمد.
ادفع مصدر البيانات إلى Tinybird.
tb push tinybird/datasources/events_landing.datasourceسيمنحك Tinybird عنوان URL للاستقبال وينسخ رمزاً إلى الحافظة. الصق الرمز في .env.local تحت TINYBIRD_INGEST_TOKEN.
الخطوة 3: إرسال الأحداث من Next.js
أضف مسار استقبال من جانب الخادم في app/api/track/route.ts.
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { tbIngest } from "@/lib/tinybird";
const eventSchema = z.object({
workspace_id: z.string().min(1),
user_id: z.string().min(1),
session_id: z.string().min(1),
event: z.string().min(1).max(64),
path: z.string().default("/"),
referrer: z.string().default(""),
country: z.string().default(""),
properties: z.record(z.unknown()).default({}),
});
export async function POST(req: NextRequest) {
const body = await req.json();
const parsed = eventSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json({ error: parsed.error.format() }, { status: 400 });
}
const event = {
...parsed.data,
timestamp: new Date().toISOString(),
properties: JSON.stringify(parsed.data.properties),
};
await tbIngest(event);
return NextResponse.json({ ok: true });
}التحقق من الصحة ليس خياراً. ClickHouse متسامح حيال انحراف المخطط، لكن إن تركت JSON عشوائياً يمر، فستدفع الثمن لاحقاً حين يخلق خطأ مطبعي ملايين الصفوف من القمامة.
الآن اعرض خطاف عميل صغير ليُتيح لمكونات React إطلاق الأحداث.
// lib/use-track.ts
"use client";
import { useCallback } from "react";
export function useTrack(workspaceId: string, userId: string) {
return useCallback(
async (event: string, properties: Record<string, unknown> = {}) => {
const sessionId =
sessionStorage.getItem("sid") ??
crypto.randomUUID().replace(/-/g, "").slice(0, 16);
sessionStorage.setItem("sid", sessionId);
await fetch("/api/track", {
method: "POST",
body: JSON.stringify({
workspace_id: workspaceId,
user_id: userId,
session_id: sessionId,
event,
path: window.location.pathname,
referrer: document.referrer,
properties,
}),
});
},
[workspaceId, userId]
);
}استخدمه من أي مكون عميل:
"use client";
import { useTrack } from "@/lib/use-track";
export function UpgradeButton({ workspaceId, userId }: Props) {
const track = useTrack(workspaceId, userId);
return (
<button
onClick={() => {
track("upgrade_clicked", { plan: "pro" });
}}
>
Upgrade
</button>
);
}أرسل حفنة من الأحداث من جهاز التطوير الخاص بك وتأكد من ظهورها في واجهة Tinybird ضمن Data Sources ثم events_landing ثم Operations. إذا رأيتها تتدفق، فأنت جاهز للتجميع.
الخطوة 4: بناء عروض ماديّة للتجميعات
لوحة المعلومات الساذجة تمسح جدول landing بأكمله مع كل طلب. ظريفة عند ألف صف، مؤلمة عند مئة مليون. الحركة الصحيحة هي التجميع المسبق عند الاستقبال باستخدام العروض الماديّة. أنشئ واحدة لمشاهدات الصفحات لكل يوم.
أنشئ tinybird/datasources/page_views_daily.datasource.
SCHEMA >
`day` Date,
`workspace_id` String,
`path` String,
`views` AggregateFunction(count, UInt64),
`uniques` AggregateFunction(uniq, String)
ENGINE "AggregatingMergeTree"
ENGINE_PARTITION_KEY "toYYYYMM(day)"
ENGINE_SORTING_KEY "workspace_id, day, path"ثم أنشئ أنبوباً ماديّاً في tinybird/pipes/mv_page_views_daily.pipe.
NODE materialize_node
SQL >
SELECT
toDate(timestamp) AS day,
workspace_id,
path,
countState() AS views,
uniqState(user_id) AS uniques
FROM events_landing
WHERE event = 'page_view'
GROUP BY day, workspace_id, path
TYPE MATERIALIZED
DATASOURCE page_views_dailyادفع الاثنين إلى Tinybird.
tb push tinybird/datasources/page_views_daily.datasource
tb push tinybird/pipes/mv_page_views_daily.pipe --populateعلامة --populate تملأ العرض الماديّ بأثر رجعي من البيانات القائمة. من الآن فصاعداً، كل حدث page_view جديد يتدفق إلى الإحصائية المجمّعة تلقائياً. كرر النمط نفسه لأي تجميعة تحتاجها: مصدر بيانات daily_active_users مفتاحه اليوم ومساحة العمل، و feature_events_daily لتتبع الميزات المُستخدمة، وهكذا. الانضباط بسيط. الأحداث الخام تذهب إلى landing. أي شيء تستعلم عنه بشكل متكرر ينتمي إلى عرض ماديّ.
الخطوة 5: عرض نقاط نهاية SQL عبر الأنابيب
الأنبوب هو وحدة Tinybird لتأليف SQL. كل أنبوب فيه عقدة أو أكثر، والعقدة الأخيرة تُعرض كنقطة نهاية JSON. أنشئ tinybird/pipes/top_pages.pipe.
TOKEN read READ
NODE top_pages_node
SQL >
%
SELECT
path,
countMerge(views) AS views,
uniqMerge(uniques) AS uniques
FROM page_views_daily
WHERE workspace_id = {{ String(workspace_id, required=True) }}
AND day BETWEEN {{ Date(start_date) }} AND {{ Date(end_date) }}
GROUP BY path
ORDER BY views DESC
LIMIT {{ Int32(limit, 10) }}علامة % تُفعّل نحو القوالب في Tinybird. حارس required=True على workspace_id هو العمود الفقري للأمان. واجهة أمامية مُعدَّة بشكل خاطئ لا تستطيع طلب بيانات مستأجر آخر لأن نقطة النهاية ترفض العمل دون ذلك الوسيط.
ادفعه واحصل على رمز القراءة.
tb push tinybird/pipes/top_pages.pipe
tb token lsانسخ الرمز الموسوم بـ read إلى .env.local تحت TINYBIRD_READ_TOKEN. الآن اختبر نقطة النهاية من ساحة لعب Tinybird.
GET /v0/pipes/top_pages.json?workspace_id=demo&start_date=2026-05-01&end_date=2026-05-25إذا حصلت على JSON يحوي المسارات وعدد المشاهدات، فأنبوب البيانات يعمل من البداية إلى النهاية.
أنشئ أنبوبين إضافيين بالشكل نفسه: daily_active_users و realtime_feed. تدفق الأحداث الفوري مثير للاهتمام لأنه يستعلم عن جدول landing مباشرة، مرتباً بالطابع الزمني، محدوداً بآخر 100 صف. هذا يمنح اللوحة عرض "ذيل حي" للأحداث الواردة.
NODE realtime_feed_node
SQL >
%
SELECT timestamp, event, path, user_id, country
FROM events_landing
WHERE workspace_id = {{ String(workspace_id, required=True) }}
AND timestamp > now() - INTERVAL 5 MINUTE
ORDER BY timestamp DESC
LIMIT 100الخطوة 6: عرض اللوحة في Next.js
تتألق Server components هنا لأنها تستطيع الجلب من Tinybird دون كشف رمز القراءة للمتصفح. أنشئ app/(dashboard)/page.tsx.
import { tbQuery } from "@/lib/tinybird";
import { TopPagesChart } from "@/components/top-pages-chart";
import { LiveFeed } from "@/components/live-feed";
type TopPage = { path: string; views: number; uniques: number };
type FeedRow = { timestamp: string; event: string; path: string };
export const revalidate = 5;
export default async function Dashboard({
searchParams,
}: {
searchParams: { workspace?: string };
}) {
const workspace = searchParams.workspace ?? "demo";
const today = new Date().toISOString().slice(0, 10);
const start = new Date(Date.now() - 30 * 86_400_000)
.toISOString()
.slice(0, 10);
const [topPages, feed] = await Promise.all([
tbQuery<TopPage>("top_pages", {
workspace_id: workspace,
start_date: start,
end_date: today,
limit: 10,
}),
tbQuery<FeedRow>("realtime_feed", { workspace_id: workspace }),
]);
return (
<main className="grid gap-6 p-8 md:grid-cols-2">
<TopPagesChart rows={topPages.data} />
<LiveFeed rows={feed.data} />
</main>
);
}لاحظ Promise.all المتوازي وإعداد المقطع revalidate = 5. نقطتا نهاية، رحلة ذهاب وعودة واحدة من حيث زمن الاستجابة، والصفحة بأكملها قابلة للتخزين المؤقت بالكامل على الحافة لمدة خمس ثوانٍ. هذا أكثر من كافٍ لتقريباً كل حالة استخدام لتحليلات SaaS.
يستخدم مكون الرسم البياني Recharts:
"use client";
import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis } from "recharts";
export function TopPagesChart({ rows }: { rows: { path: string; views: number }[] }) {
return (
<section className="rounded-2xl border p-4">
<h2 className="mb-3 text-lg font-semibold">أكثر الصفحات زيارة</h2>
<ResponsiveContainer width="100%" height={320}>
<BarChart data={rows}>
<XAxis dataKey="path" tickFormatter={(p) => p.slice(0, 18)} />
<YAxis allowDecimals={false} />
<Bar dataKey="views" fill="#2563eb" radius={6} />
</BarChart>
</ResponsiveContainer>
</section>
);
}التدفق الحي عبارة عن قائمة بسيطة تُعاد رسمتها كلما تجدد المكون الأب على الخادم.
import { formatDistanceToNow } from "date-fns";
export function LiveFeed({ rows }: { rows: { timestamp: string; event: string; path: string }[] }) {
return (
<section className="rounded-2xl border p-4">
<h2 className="mb-3 text-lg font-semibold">النشاط الحي</h2>
<ul className="space-y-2 text-sm">
{rows.map((r, i) => (
<li key={i} className="flex justify-between">
<span>
<code className="text-blue-600">{r.event}</code> على {r.path}
</span>
<span className="text-gray-500">
{formatDistanceToNow(new Date(r.timestamp), { addSuffix: true })}
</span>
</li>
))}
</ul>
</section>
);
}شغّل npm run dev، أطلق بضعة أحداث من تبويب آخر، وراقب اللوحة تنبض بالحياة.
الخطوة 7: تأمين الوصول متعدد المستأجرين
تطبيق SaaS حقيقي يكشف التحليلات لعملائه. لا تريد للعميل أ أن يتمكن من جلب أحداث العميل ب. يدعم Tinybird أماناً على مستوى الصف لكل رمز عبر ميزة تُسمى JWT tokens. الفكرة الأساسية هي أنك تُصدر JWT قصير العمر على الخادم يُثبّت الطلب على workspace_id محدد.
// lib/tinybird-jwt.ts
import { SignJWT } from "jose";
const secret = new TextEncoder().encode(process.env.TINYBIRD_JWT_SECRET!);
export async function mintTinybirdToken(workspaceId: string) {
return new SignJWT({
workspace_id: workspaceId,
scopes: [{ type: "PIPES:READ", resource: "top_pages" }],
})
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("10m")
.sign(secret);
}اضبط السر على جانب Tinybird، ثم استدعِ mintTinybirdToken كلما احتجت إلى تسليم رمز إلى المتصفح. مع حارس required=True على كل أنبوب، يمنحك هذا دفاعاً متعدد الطبقات: حتى إن تسرّب رمز، فلن يستطيع قراءة أكثر من مساحة العمل التي حُصر بها، ولمدة عشر دقائق فقط.
الخطوة 8: النشر والمراقبة
انشر تطبيق Next.js على Vercel باستخدام vercel deploy --prod. أضف متغيرات البيئة الثلاثة في إعدادات مشروع Vercel. Tinybird نفسه يعمل في السحابة، لذا لا يوجد خادم تديره على هذا الجانب.
للعمليات الجارية، راقب ثلاث إشارات في واجهة Tinybird. أولاً، إنتاجية الاستقبال على مصدر بيانات events_landing. إن رأيت انخفاضات، فإن Events API يُقيّد المعدل أو عميلك يفشل. ثانياً، تأخر العرض الماديّ. يُظهر Tinybird إلى أي مدى تتأخر الإحصائيات المجمّعة الزاحفة عن الوقت الفعلي. إن نما، فتجميعك مكلف جداً ويحتاج إلى تبسيط. ثالثاً، زمن استجابة الأنبوب. كل نقطة نهاية تُبلّغ عن p50 و p99. إن تجاوز p99 الـ 200 مللي ثانية، فأنت عادة تفتقد عمود فهرس في مفتاح الفرز أو تمسح أقساماً أكثر من اللازم لأن فلتر الوقت لديك واسع جداً.
اختبار التنفيذ
راجع قائمة التحقق هذه قبل الإطلاق:
- أرسل 10000 حدث باستخدام مساعد Tinybird Mockingbird وتأكد من تحديث اللوحة خلال خمس ثوانٍ
- اضرب نقاط النهاية بمعرّف مساحة عمل خاطئ وتأكد من أن النتيجة فارغة، لا خطأ
- بدّل بين مساحتي عمل تجريبيتين في الواجهة وتحقق من تبديل البيانات بسلاسة
- انتظر 24 ساعة وتأكد من تعبئة العروض الماديّة اليومية بشكل صحيح
- شغّل
tb sql "SELECT count() FROM events_landing"لمقارنة عدد الصفوف المُستقبَلة بسجلات تطبيقك
npx @tinybirdco/mockingbird-cli generate \
--schema "./mockingbird-schema.json" \
--count 10000 \
--target tinybird \
--token $TINYBIRD_INGEST_TOKEN \
--datasource events_landingاستكشاف الأخطاء وإصلاحها
إذا اختفت الأحداث بصمت، فتأكد من أن رمز الاستقبال لديه صلاحية كتابة على events_landing. يُعيد Tinybird رمز 200 OK لجسم فارغ حتى عند عدم تطابق المخطط، لذا أضف فحصاً على failed_rows في الاستجابة عند الشك.
إذا كان العرض الماديّ فارغاً بعد --populate، فالسبب الأكثر شيوعاً هو جملة WHERE تُصفّي كل الصفوف التاريخية. أعد تشغيل الاستعلام كـ SELECT بسيط على جدول landing للتأكد من أنه يُعيد بيانات.
إذا ارتفع زمن استجابة نقطة النهاية، فالحل البديل تقريباً دائماً هو مفتاح الفرز. تأكد من أن أكثر فلتر انتقائياً، عادة workspace_id، هو العمود الأول.
إذا عرضت لوحة Next.js بيانات قديمة، فالسبب عادة هو ذاكرة Vercel المؤقتة للبيانات. خفض revalidate إلى 1 أثناء التصحيح، ثم أعده إلى 5 بعد التأكد من سلامة الأنبوب.
الخطوات التالية
لديك أنبوب تحليلات عامل. بضعة اتجاهات لتطويره أكثر:
- أضف نقطة نهاية SQL لتحليل القمع باستخدام
windowFunnelفي ClickHouse - دفّق الأحداث مباشرة من Cloudflare Workers باستخدام موصل Tinybird
- أضف اكتشاف الشذوذ بضخ الإحصائيات المجمّعة إلى سير عمل n8n متعدد الوكلاء
- أضف طبقة من Claude Agent SDK فوقها ليطرح زملاؤك أسئلة على اللوحة بلغة طبيعية
- صدّر الإحصائيات إلى Postgres باستخدام ميزة Tinybird Sinks للضم مع بياناتك التشغيلية
الخاتمة
Tinybird واحدة من تلك الأدوات التي تُغيّر بهدوء طريقة تفكيرك في تحليلات المنتج. بدلاً من رمي المزيد من الفهارس على Postgres أو الدفع لمزود تحليلات عام يفرض رسوماً لكل حدث، تحتفظ بملكية مخططاتك واستعلاماتك وميزانية زمن استجابتك. ادمجها مع server components في Next.js وتحصل على بنية لوحة معلومات ممتعة في البناء ورخيصة في التشغيل، حتى عندما يرتفع حجم أحداثك بمقدار أس عشري.
النمط في هذا الدرس يتوسع من مشروع جانبي إلى شركة ناشئة في الجولة B دون تغييرات معمارية. أضف مصادر بيانات مع نمو تصنيف الأحداث لديك، أضف عروضاً ماديّة مع استقرار أنماط استعلاماتك، واعتمد على رموز JWT لإبقاء المستأجرين معزولين. هذا هو النموذج الذهني بأكمله.