diff --git a/CHANGELOG.md b/CHANGELOG.md
index eb0eafb..c711b6e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,9 +6,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
## [Unreleased]
-### Added
+### Removed
-- **Dedicated Performance tab.** Core Web Vitals (LCP, CLS, INP) have moved from the main dashboard into their own "Performance" tab. This gives you a full-page view with your overall performance score, individual metric cards, and a "Slowest pages by metric" table you can sort by LCP, CLS, or INP. The tab includes its own date range picker so you can analyze performance trends independently.
+- **Performance insights removed.** The Performance tab, Core Web Vitals tracking (LCP, CLS, INP), and the "Enable performance insights" toggle in Settings have been removed. The tracking script no longer collects Web Vitals data. Visit duration tracking continues to work as before.
+
+### Added
- **BunnyCDN integration.** Connect your BunnyCDN account in Settings > Integrations to monitor your CDN performance right alongside your analytics. A new "CDN" tab on your dashboard shows total bandwidth served, request volume, cache hit rate, origin response time, and error counts — each with percentage changes compared to the previous period. Charts show bandwidth trends (total vs cached), daily request volume, and error breakdowns over time. A geographic breakdown shows which countries consume the most bandwidth. Pulse only stores your API key encrypted and only reads statistics — it never modifies anything in your BunnyCDN account. You can disconnect and fully remove all CDN data at any time.
- **Smart pull zone matching.** When connecting BunnyCDN, Pulse automatically filters your pull zones to only show the ones that match your tracked site's domain — so you can't accidentally connect the wrong pull zone.
@@ -22,9 +24,6 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
### Fixed
-- **Performance metrics no longer show "0 0 0" when no data exists.** Previously, if no visitors had reported Web Vitals data, the Performance section showed "LCP 0 ms, CLS 0, INP 0 ms" and rated everything as "Good" — which was misleading. It now clearly says "No data" when no metrics have been collected, and shows a helpful message explaining when data will appear.
-- **Performance metrics no longer show inflated numbers from slow outliers.** A single very slow page load could skew the entire site's LCP or INP average to unrealistically high values. Pulse now uses the 75th percentile (p75) — the same methodology Google uses — so a handful of extreme outliers don't distort your scores.
-
- **BunnyCDN logo now displays correctly.** The BunnyCDN integration card in Settings previously showed a generic globe icon. It now shows the proper BunnyCDN bunny logo.
- **Your BunnyCDN API key is no longer visible in network URLs.** When loading pull zones, the API key was previously sent as a URL parameter. It's now sent securely in the request body, just like when connecting.
diff --git a/app/share/[id]/page.tsx b/app/share/[id]/page.tsx
index 53e6acb..125e030 100644
--- a/app/share/[id]/page.tsx
+++ b/app/share/[id]/page.tsx
@@ -3,7 +3,7 @@
import { useCallback, useEffect, useState } from 'react'
import Image from 'next/image'
import { useParams, useSearchParams, useRouter } from 'next/navigation'
-import { getPublicDashboard, getPublicStats, getPublicDailyStats, getPublicRealtime, getPublicPerformanceByPage, type DashboardData, type Stats, type DailyStat, type PerformanceByPageStat } from '@/lib/api/stats'
+import { getPublicDashboard, getPublicStats, getPublicDailyStats, getPublicRealtime, type DashboardData, type Stats, type DailyStat } from '@/lib/api/stats'
import { toast } from '@ciphera-net/ui'
import { getAuthErrorMessage } from '@ciphera-net/ui'
import { ApiError } from '@/lib/api/client'
@@ -13,7 +13,6 @@ import TopPages from '@/components/dashboard/ContentStats'
import TopReferrers from '@/components/dashboard/TopReferrers'
import Locations from '@/components/dashboard/Locations'
import TechSpecs from '@/components/dashboard/TechSpecs'
-import PerformanceStats from '@/components/dashboard/PerformanceStats'
import { Select, DatePicker as DatePickerModal, Captcha, DownloadIcon, ZapIcon } from '@ciphera-net/ui'
import { DashboardSkeleton, useMinimumLoading, useSkeletonFade } from '@/components/skeletons'
import ExportModal from '@/components/dashboard/ExportModal'
@@ -257,7 +256,7 @@ export default function PublicDashboardPage() {
if (!data) return null
- const { site, stats, daily_stats, top_pages, entry_pages, exit_pages, top_referrers, countries, cities, regions, browsers, os, devices, screen_resolutions, performance, performance_by_page, realtime_visitors } = data
+ const { site, stats, daily_stats, top_pages, entry_pages, exit_pages, top_referrers, countries, cities, regions, browsers, os, devices, screen_resolutions, realtime_visitors } = data
// Provide defaults for potentially undefined data
const safeDailyStats = daily_stats || []
@@ -395,29 +394,6 @@ export default function PublicDashboardPage() {
/>
- {/* Performance Stats - Only show if enabled */}
- {performance && data.site?.enable_performance_insights && (
-
-
{
- return getPublicPerformanceByPage(siteId, startDate, endDate, opts, {
- password,
- captcha: {
- captcha_id: captchaId,
- captcha_solution: captchaSolution,
- captcha_token: captchaToken
- }
- })
- }}
- />
-
- )}
-
{/* Details Grid */}
import('@/components/dashboard/PerformanceStats'))
-
-function getThisWeekRange(): { start: string; end: string } {
- const today = new Date()
- const dayOfWeek = today.getDay()
- const monday = new Date(today)
- monday.setDate(today.getDate() - (dayOfWeek === 0 ? 6 : dayOfWeek - 1))
- return { start: formatDate(monday), end: formatDate(today) }
-}
-
-function getThisMonthRange(): { start: string; end: string } {
- const today = new Date()
- const firstOfMonth = new Date(today.getFullYear(), today.getMonth(), 1)
- return { start: formatDate(firstOfMonth), end: formatDate(today) }
-}
-
-function PerformanceSkeleton() {
- return (
-
-
-
-
- {[1, 2, 3].map(i => (
-
- ))}
-
-
-
- )
-}
-
-export default function PerformancePage() {
- const params = useParams()
- const siteId = params.id as string
-
- const [period, setPeriod] = useState('30')
- const [dateRange, setDateRange] = useState(() => getDateRange(30))
- const [isDatePickerOpen, setIsDatePickerOpen] = useState(false)
-
- const { data: dashboard, isLoading: loading } = useDashboard(siteId, dateRange.start, dateRange.end)
-
- const site = dashboard?.site ?? null
- const showSkeleton = useMinimumLoading(loading && !dashboard)
- const fadeClass = useSkeletonFade(showSkeleton)
-
- useEffect(() => {
- const domain = site?.domain
- document.title = domain ? `Performance \u00b7 ${domain} | Pulse` : 'Performance | Pulse'
- }, [site?.domain])
-
- if (showSkeleton) return
-
- if (site && !site.enable_performance_insights) {
- return (
-
-
-
- Performance insights are disabled
-
-
- Enable performance insights in your site settings to start collecting Core Web Vitals data.
-
-
-
- )
- }
-
- return (
-
- {/* Header */}
-
-
-
- Performance
-
-
- Core Web Vitals from real user sessions
-
-
-
{
- if (value === 'today') {
- const today = formatDate(new Date())
- setDateRange({ start: today, end: today })
- setPeriod('today')
- } else if (value === '7') {
- setDateRange(getDateRange(7))
- setPeriod('7')
- } else if (value === 'week') {
- setDateRange(getThisWeekRange())
- setPeriod('week')
- } else if (value === '30') {
- setDateRange(getDateRange(30))
- setPeriod('30')
- } else if (value === 'month') {
- setDateRange(getThisMonthRange())
- setPeriod('month')
- } else if (value === 'custom') {
- setIsDatePickerOpen(true)
- }
- }}
- options={[
- { value: 'today', label: 'Today' },
- { value: '7', label: 'Last 7 days' },
- { value: '30', label: 'Last 30 days' },
- { value: 'divider-1', label: '', divider: true },
- { value: 'week', label: 'This week' },
- { value: 'month', label: 'This month' },
- { value: 'divider-2', label: '', divider: true },
- { value: 'custom', label: 'Custom' },
- ]}
- />
-
-
-
-
-
setIsDatePickerOpen(false)}
- onApply={(range) => {
- setDateRange(range)
- setPeriod('custom')
- setIsDatePickerOpen(false)
- }}
- initialRange={dateRange}
- />
-
- )
-}
diff --git a/app/sites/[id]/settings/page.tsx b/app/sites/[id]/settings/page.tsx
index 7c3fb16..b5dc03c 100644
--- a/app/sites/[id]/settings/page.tsx
+++ b/app/sites/[id]/settings/page.tsx
@@ -74,8 +74,6 @@ export default function SiteSettingsPage() {
collect_device_info: true,
collect_geo_data: 'full' as GeoDataLevel,
collect_screen_resolution: true,
- // Performance insights setting
- enable_performance_insights: false,
// Bot and noise filtering
filter_bots: true,
// Hide unknown locations
@@ -135,7 +133,6 @@ export default function SiteSettingsPage() {
collect_device_info: site.collect_device_info ?? true,
collect_geo_data: site.collect_geo_data || 'full',
collect_screen_resolution: site.collect_screen_resolution ?? true,
- enable_performance_insights: site.enable_performance_insights ?? false,
filter_bots: site.filter_bots ?? true,
hide_unknown_locations: site.hide_unknown_locations ?? false,
data_retention_months: site.data_retention_months ?? 6
@@ -150,7 +147,6 @@ export default function SiteSettingsPage() {
collect_device_info: site.collect_device_info ?? true,
collect_geo_data: site.collect_geo_data || 'full',
collect_screen_resolution: site.collect_screen_resolution ?? true,
- enable_performance_insights: site.enable_performance_insights ?? false,
filter_bots: site.filter_bots ?? true,
hide_unknown_locations: site.hide_unknown_locations ?? false,
data_retention_months: site.data_retention_months ?? 6
@@ -423,8 +419,6 @@ export default function SiteSettingsPage() {
collect_device_info: formData.collect_device_info,
collect_geo_data: formData.collect_geo_data,
collect_screen_resolution: formData.collect_screen_resolution,
- // Performance insights setting
- enable_performance_insights: formData.enable_performance_insights,
// Bot and noise filtering
filter_bots: formData.filter_bots,
// Hide unknown locations
@@ -443,7 +437,6 @@ export default function SiteSettingsPage() {
collect_device_info: formData.collect_device_info,
collect_geo_data: formData.collect_geo_data,
collect_screen_resolution: formData.collect_screen_resolution,
- enable_performance_insights: formData.enable_performance_insights,
filter_bots: formData.filter_bots,
hide_unknown_locations: formData.hide_unknown_locations,
data_retention_months: formData.data_retention_months
@@ -511,7 +504,6 @@ export default function SiteSettingsPage() {
collect_device_info: formData.collect_device_info,
collect_geo_data: formData.collect_geo_data,
collect_screen_resolution: formData.collect_screen_resolution,
- enable_performance_insights: formData.enable_performance_insights,
filter_bots: formData.filter_bots,
hide_unknown_locations: formData.hide_unknown_locations,
data_retention_months: formData.data_retention_months
@@ -1113,30 +1105,6 @@ export default function SiteSettingsPage() {
- {/* Performance Insights Toggle */}
-
-
Performance Insights
-
-
-
-
Performance Insights (Add-on)
-
- Track Core Web Vitals (LCP, CLS, INP) to monitor site performance
-
-
-
- setFormData({ ...formData, enable_performance_insights: e.target.checked })}
- className="sr-only peer"
- />
-
-
-
-
-
-
{/* Data Retention */}
Data Retention
diff --git a/components/dashboard/PerformanceStats.tsx b/components/dashboard/PerformanceStats.tsx
deleted file mode 100644
index bf9ce8a..0000000
--- a/components/dashboard/PerformanceStats.tsx
+++ /dev/null
@@ -1,243 +0,0 @@
-'use client'
-
-import { useState, useEffect } from 'react'
-import { PerformanceStats as Stats, PerformanceByPageStat, getPerformanceByPage } from '@/lib/api/stats'
-import { Select } from '@ciphera-net/ui'
-import { TableSkeleton } from '@/components/skeletons'
-
-interface Props {
- stats: Stats | null
- performanceByPage?: PerformanceByPageStat[] | null
- siteId?: string
- startDate?: string
- endDate?: string
- getPerformanceByPage?: typeof getPerformanceByPage
-}
-
-type Score = 'good' | 'needs-improvement' | 'poor'
-
-const getScore = (metric: 'lcp' | 'cls' | 'inp', value: number): Score => {
- if (metric === 'lcp') return value <= 2500 ? 'good' : value <= 4000 ? 'needs-improvement' : 'poor'
- if (metric === 'cls') return value <= 0.1 ? 'good' : value <= 0.25 ? 'needs-improvement' : 'poor'
- if (metric === 'inp') return value <= 200 ? 'good' : value <= 500 ? 'needs-improvement' : 'poor'
- return 'good'
-}
-
-const scoreColors = {
- good: 'text-green-600 bg-green-50 dark:bg-green-900/20 dark:text-green-400 border-green-200 dark:border-green-800',
- 'needs-improvement': 'text-yellow-600 bg-yellow-50 dark:bg-yellow-900/20 dark:text-yellow-400 border-yellow-200 dark:border-yellow-800',
- poor: 'text-red-600 bg-red-50 dark:bg-red-900/20 dark:text-red-400 border-red-200 dark:border-red-800',
-}
-
-const badgeColors = {
- good: 'text-green-700 dark:text-green-400 bg-green-100 dark:bg-green-900/30 border-green-200 dark:border-green-800',
- 'needs-improvement': 'text-yellow-700 dark:text-yellow-400 bg-yellow-100 dark:bg-yellow-900/30 border-yellow-200 dark:border-yellow-800',
- poor: 'text-red-700 dark:text-red-400 bg-red-100 dark:bg-red-900/30 border-red-200 dark:border-red-800',
-}
-
-function MetricCard({ label, value, unit, score }: { label: string, value: string, unit: string, score: Score | null }) {
- const noData = score === null
- const colorClass = noData
- ? 'text-neutral-500 bg-neutral-50 dark:bg-neutral-800/50 dark:text-neutral-400 border-neutral-200 dark:border-neutral-700'
- : scoreColors[score]
-
- return (
-
-
{label}
-
- {value}
- {unit && {unit} }
-
-
- )
-}
-
-function formatMetricValue(metric: 'lcp' | 'cls' | 'inp', val: number | null): string {
- if (val == null) return 'No data'
- if (metric === 'cls') return val.toFixed(3)
- return `${Math.round(val)}`
-}
-
-function formatMetricCell(metric: 'lcp' | 'cls' | 'inp', val: number | null): string {
- if (val == null) return '\u2014'
- if (metric === 'cls') return val.toFixed(3)
- return `${Math.round(val)} ms`
-}
-
-export default function PerformanceStats({ stats, performanceByPage, siteId, startDate, endDate, getPerformanceByPage }: Props) {
- const [sortBy, setSortBy] = useState<'lcp' | 'cls' | 'inp'>('lcp')
- const [overrideRows, setOverrideRows] = useState
(null)
- const [loadingTable, setLoadingTable] = useState(false)
-
- useEffect(() => {
- setOverrideRows(null)
- }, [performanceByPage])
-
- const rows = overrideRows ?? performanceByPage ?? []
- const canRefetch = Boolean(getPerformanceByPage && siteId && startDate && endDate)
-
- const handleSortChange = (value: string) => {
- const v = value as 'lcp' | 'cls' | 'inp'
- setSortBy(v)
- if (!getPerformanceByPage || !siteId || !startDate || !endDate) return
- setLoadingTable(true)
- getPerformanceByPage(siteId, startDate, endDate, { sort: v, limit: 20 })
- .then(setOverrideRows)
- .finally(() => setLoadingTable(false))
- }
-
- const hasData = stats && stats.samples > 0
- const lcp = stats?.lcp ?? null
- const cls = stats?.cls ?? null
- const inp = stats?.inp ?? null
-
- const lcpScore = lcp != null ? getScore('lcp', lcp) : null
- const clsScore = cls != null ? getScore('cls', cls) : null
- const inpScore = inp != null ? getScore('inp', inp) : null
-
- // Overall score: worst of available metrics
- let overallScore: Score | null = null
- if (hasData) {
- const scores = [lcpScore, clsScore, inpScore].filter((s): s is Score => s !== null)
- if (scores.length > 0) {
- if (scores.includes('poor')) overallScore = 'poor'
- else if (scores.includes('needs-improvement')) overallScore = 'needs-improvement'
- else overallScore = 'good'
- }
- }
-
- const overallLabel = overallScore
- ? { good: 'Good', 'needs-improvement': 'Needs improvement', poor: 'Poor' }[overallScore]
- : 'No data'
-
- const overallBadgeClass = overallScore
- ? badgeColors[overallScore]
- : 'text-neutral-500 dark:text-neutral-400 bg-neutral-100 dark:bg-neutral-800 border-neutral-200 dark:border-neutral-700'
-
- const getCellScoreClass = (score: Score) => {
- const m: Record = {
- good: 'text-green-600 dark:text-green-400',
- 'needs-improvement': 'text-yellow-600 dark:text-yellow-400',
- poor: 'text-red-600 dark:text-red-400',
- }
- return m[score] ?? ''
- }
-
- const getCellClass = (metric: 'lcp' | 'cls' | 'inp', val: number | null) => {
- if (val == null) return 'text-neutral-400 dark:text-neutral-500'
- return getCellScoreClass(getScore(metric, val))
- }
-
- return (
-
- {/* Overall badge + summary */}
-
-
- {overallLabel}
-
- {hasData && (
-
- Based on {stats.samples.toLocaleString()} session{stats.samples !== 1 ? 's' : ''} (p75 values)
-
- )}
-
-
- {/* Metric cards */}
-
-
-
-
-
-
- {!hasData && (
-
- No performance data collected yet. Core Web Vitals data will appear here once visitors browse your site with performance insights enabled.
-
- )}
-
- {hasData && (
-
- * 75th percentile (p75) calculated from real user sessions. Lower is better.
-
- )}
-
- {/* Worst pages by metric */}
-
-
-
- Slowest pages by metric
-
- {canRefetch && (
-
- )}
-
- {loadingTable ? (
-
- ) : rows.length === 0 ? (
-
- No per-page metrics yet. Data appears as visitors are tracked with performance insights enabled.
-
- ) : (
-
-
-
-
- Path
- Samples
- LCP
- CLS
- INP
-
-
-
- {rows.map((r) => (
-
-
- {r.path || '/'}
-
- {r.samples}
-
- {formatMetricCell('lcp', r.lcp)}
-
-
- {formatMetricCell('cls', r.cls)}
-
-
- {formatMetricCell('inp', r.inp)}
-
-
- ))}
-
-
-
- )}
-
-
- )
-}
diff --git a/components/dashboard/SiteNav.tsx b/components/dashboard/SiteNav.tsx
index 82d5fcc..fd7c185 100644
--- a/components/dashboard/SiteNav.tsx
+++ b/components/dashboard/SiteNav.tsx
@@ -18,7 +18,6 @@ export default function SiteNav({ siteId }: SiteNavProps) {
const tabs = [
{ label: 'Dashboard', href: `/sites/${siteId}` },
- { label: 'Performance', href: `/sites/${siteId}/performance` },
{ label: 'Journeys', href: `/sites/${siteId}/journeys` },
{ label: 'Funnels', href: `/sites/${siteId}/funnels` },
{ label: 'Behavior', href: `/sites/${siteId}/behavior` },
diff --git a/lib/api/sites.ts b/lib/api/sites.ts
index 34f5f99..60d2f4b 100644
--- a/lib/api/sites.ts
+++ b/lib/api/sites.ts
@@ -17,8 +17,6 @@ export interface Site {
collect_device_info?: boolean
collect_geo_data?: GeoDataLevel
collect_screen_resolution?: boolean
- // Performance insights setting
- enable_performance_insights?: boolean
// Bot and noise filtering
filter_bots?: boolean
// Hide unknown locations from stats
@@ -48,8 +46,6 @@ export interface UpdateSiteRequest {
collect_device_info?: boolean
collect_geo_data?: GeoDataLevel
collect_screen_resolution?: boolean
- // Performance insights setting
- enable_performance_insights?: boolean
// Bot and noise filtering
filter_bots?: boolean
// Hide unknown locations from stats
diff --git a/lib/api/stats.ts b/lib/api/stats.ts
index 157224e..a26fa04 100644
--- a/lib/api/stats.ts
+++ b/lib/api/stats.ts
@@ -21,21 +21,6 @@ export interface ScreenResolutionStat {
pageviews: number
}
-export interface PerformanceStats {
- lcp: number | null
- cls: number | null
- inp: number | null
- samples: number
-}
-
-export interface PerformanceByPageStat {
- path: string
- samples: number
- lcp: number | null
- cls: number | null
- inp: number | null
-}
-
export interface GoalCountStat {
event_name: string
count: number
@@ -226,31 +211,6 @@ export function getPublicCampaigns(siteId: string, startDate?: string, endDate?:
.then(r => r?.campaigns || [])
}
-// ─── Performance By Page ────────────────────────────────────────────
-
-export function getPerformanceByPage(
- siteId: string,
- startDate?: string,
- endDate?: string,
- opts?: { limit?: number; sort?: 'lcp' | 'cls' | 'inp' }
-): Promise {
- return apiRequest<{ performance_by_page: PerformanceByPageStat[] }>(
- `/sites/${siteId}/performance-by-page${buildQuery({ startDate, endDate, limit: opts?.limit, sort: opts?.sort })}`
- ).then(r => r?.performance_by_page ?? [])
-}
-
-export function getPublicPerformanceByPage(
- siteId: string,
- startDate?: string,
- endDate?: string,
- opts?: { limit?: number; sort?: 'lcp' | 'cls' | 'inp' },
- auth?: AuthParams
-): Promise {
- return apiRequest<{ performance_by_page: PerformanceByPageStat[] }>(
- `/public/sites/${siteId}/performance-by-page${buildQuery({ startDate, endDate, limit: opts?.limit, sort: opts?.sort }, auth)}`
- ).then(r => r?.performance_by_page ?? [])
-}
-
// ─── Full Dashboard ─────────────────────────────────────────────────
export interface DashboardData {
@@ -269,8 +229,6 @@ export interface DashboardData {
os: OSStat[]
devices: DeviceStat[]
screen_resolutions: ScreenResolutionStat[]
- performance?: PerformanceStats
- performance_by_page?: PerformanceByPageStat[]
goal_counts?: GoalCountStat[]
}
@@ -324,11 +282,6 @@ export interface DashboardReferrersData {
top_referrers: TopReferrer[]
}
-export interface DashboardPerformanceData {
- performance?: PerformanceStats
- performance_by_page?: PerformanceByPageStat[]
-}
-
export interface DashboardGoalsData {
goal_counts: GoalCountStat[]
}
@@ -388,17 +341,6 @@ export function getPublicDashboardReferrers(
return apiRequest(`/public/sites/${siteId}/dashboard/referrers${buildQuery({ startDate, endDate, limit }, { password, captcha })}`)
}
-export function getDashboardPerformance(siteId: string, startDate?: string, endDate?: string, filters?: string): Promise {
- return apiRequest(`/sites/${siteId}/dashboard/performance${buildQuery({ startDate, endDate, filters })}`)
-}
-
-export function getPublicDashboardPerformance(
- siteId: string, startDate?: string, endDate?: string,
- password?: string, captcha?: { captcha_id?: string, captcha_solution?: string, captcha_token?: string }
-): Promise {
- return apiRequest(`/public/sites/${siteId}/dashboard/performance${buildQuery({ startDate, endDate }, { password, captcha })}`)
-}
-
export function getDashboardGoals(siteId: string, startDate?: string, endDate?: string, limit = 10, filters?: string): Promise {
return apiRequest(`/sites/${siteId}/dashboard/goals${buildQuery({ startDate, endDate, limit, filters })}`)
}
diff --git a/lib/swr/dashboard.ts b/lib/swr/dashboard.ts
index 2a6b7ac..9dc62e1 100644
--- a/lib/swr/dashboard.ts
+++ b/lib/swr/dashboard.ts
@@ -9,7 +9,6 @@ import {
getDashboardLocations,
getDashboardDevices,
getDashboardReferrers,
- getDashboardPerformance,
getDashboardGoals,
getCampaigns,
getRealtime,
@@ -48,7 +47,6 @@ import type {
DashboardLocationsData,
DashboardDevicesData,
DashboardReferrersData,
- DashboardPerformanceData,
DashboardGoalsData,
BehaviorData,
} from '@/lib/api/stats'
@@ -62,7 +60,6 @@ const fetchers = {
dashboardLocations: (siteId: string, start: string, end: string, filters?: string) => getDashboardLocations(siteId, start, end, undefined, undefined, filters),
dashboardDevices: (siteId: string, start: string, end: string, filters?: string) => getDashboardDevices(siteId, start, end, undefined, filters),
dashboardReferrers: (siteId: string, start: string, end: string, filters?: string) => getDashboardReferrers(siteId, start, end, undefined, filters),
- dashboardPerformance: (siteId: string, start: string, end: string, filters?: string) => getDashboardPerformance(siteId, start, end, filters),
dashboardGoals: (siteId: string, start: string, end: string, filters?: string) => getDashboardGoals(siteId, start, end, undefined, filters),
stats: (siteId: string, start: string, end: string, filters?: string) => getStats(siteId, start, end, filters),
dailyStats: (siteId: string, start: string, end: string, interval: 'hour' | 'day' | 'minute') =>
@@ -260,19 +257,6 @@ export function useDashboardReferrers(siteId: string, start: string, end: string
)
}
-// * Hook for focused dashboard performance data
-export function useDashboardPerformance(siteId: string, start: string, end: string, filters?: string) {
- return useSWR(
- siteId && start && end ? ['dashboardPerformance', siteId, start, end, filters] : null,
- () => fetchers.dashboardPerformance(siteId, start, end, filters),
- {
- ...dashboardSWRConfig,
- refreshInterval: 60 * 1000,
- dedupingInterval: 10 * 1000,
- }
- )
-}
-
// * Hook for focused dashboard goals data
export function useDashboardGoals(siteId: string, start: string, end: string, filters?: string) {
return useSWR(
diff --git a/lib/utils/privacySnippet.ts b/lib/utils/privacySnippet.ts
index 36dad18..d20c33a 100644
--- a/lib/utils/privacySnippet.ts
+++ b/lib/utils/privacySnippet.ts
@@ -21,7 +21,6 @@ export function generatePrivacySnippet(site: Site): string {
const device = site.collect_device_info ?? true
const geo = site.collect_geo_data || 'full'
const screen = site.collect_screen_resolution ?? true
- const perf = site.enable_performance_insights ?? false
const filterBots = site.filter_bots ?? true
const retentionMonths = site.data_retention_months ?? 6
@@ -32,7 +31,6 @@ export function generatePrivacySnippet(site: Site): string {
if (geo === 'full') parts.push('country, region, and city')
else if (geo === 'country') parts.push('country')
if (screen) parts.push('screen resolution')
- if (perf) parts.push('Core Web Vitals (e.g. page load performance)')
const list =
parts.length > 0
diff --git a/public/script.js b/public/script.js
index fc2739d..a2a596b 100644
--- a/public/script.js
+++ b/public/script.js
@@ -32,59 +32,15 @@
const ttlHours = storageMode === 'local' ? parseFloat(script.getAttribute('data-storage-ttl') || '24') : 0;
const ttlMs = ttlHours > 0 ? ttlHours * 60 * 60 * 1000 : 0;
- // * Performance Monitoring (Core Web Vitals) State
let currentEventId = null;
- let metrics = { lcp: 0, cls: 0, inp: 0 };
- let lcpObserved = false;
- let clsObserved = false;
- let performanceInsightsEnabled = false;
// * Time-on-page tracking: records when the current pageview started
var pageStartTime = 0;
- // * Minimal Web Vitals Observer
- function observeMetrics() {
- try {
- if (typeof PerformanceObserver === 'undefined') return;
-
- // * LCP (Largest Contentful Paint) - fires when the browser has determined the LCP element (often 2–4s+ after load)
- new PerformanceObserver((entryList) => {
- const entries = entryList.getEntries();
- const lastEntry = entries[entries.length - 1];
- if (lastEntry) {
- metrics.lcp = lastEntry.startTime;
- lcpObserved = true;
- }
- }).observe({ type: 'largest-contentful-paint', buffered: true });
-
- // * CLS (Cumulative Layout Shift) - accumulates when elements shift after load
- new PerformanceObserver((entryList) => {
- for (const entry of entryList.getEntries()) {
- if (!entry.hadRecentInput) {
- metrics.cls += entry.value;
- clsObserved = true;
- }
- }
- }).observe({ type: 'layout-shift', buffered: true });
-
- // * INP (Interaction to Next Paint) - Simplified (track max duration)
- new PerformanceObserver((entryList) => {
- const entries = entryList.getEntries();
- for (const entry of entries) {
- // * Track longest interaction
- if (entry.duration > metrics.inp) metrics.inp = entry.duration;
- }
- }).observe({ type: 'event', buffered: true, durationThreshold: 16 });
-
- } catch (e) {
- // * Browser doesn't support PerformanceObserver or specific entry types
- }
- }
-
function sendMetrics() {
if (!currentEventId) return;
- // * Calculate time-on-page in seconds (always sent, even without performance insights)
+ // * Calculate time-on-page in seconds
var durationSec = pageStartTime > 0 ? Math.round((Date.now() - pageStartTime) / 1000) : 0;
var payload = { event_id: currentEventId };
@@ -92,17 +48,8 @@
// * Always include duration if we have a valid measurement
if (durationSec > 0) payload.duration = durationSec;
- // * Only include Web Vitals when performance insights are enabled
- if (performanceInsightsEnabled) {
- // * Only include metrics the browser actually reported. Sending 0 would either be
- // * rejected by the backend (LCP/INP must be > 0) or skew averages.
- if (lcpObserved && metrics.lcp > 0) payload.lcp = metrics.lcp;
- if (clsObserved) payload.cls = metrics.cls;
- if (metrics.inp > 0) payload.inp = metrics.inp;
- }
-
- // * Skip if nothing to send (no duration and no vitals)
- if (!payload.duration && !performanceInsightsEnabled) return;
+ // * Skip if nothing to send (no duration)
+ if (!payload.duration) return;
var data = JSON.stringify(payload);
@@ -118,14 +65,10 @@
}
}
- // * Start observing metrics immediately (buffered observers will capture early metrics)
- // * Metrics will only be sent if performance insights are enabled (checked in sendMetrics)
- observeMetrics();
-
// * Send metrics when user leaves or hides the page
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
- // * Delay metrics slightly so in-flight LCP/CLS callbacks can run before we send
+ // * Delay slightly so duration measurement captures final moment
setTimeout(sendMetrics, 150);
}
});
@@ -306,9 +249,6 @@
// * Track pageview
function trackPageview() {
- var routeChangeTime = performance.now();
- var isSpaNav = !!currentEventId;
-
const path = cleanPath();
// * Skip if same path was just tracked (refresh dedup)
@@ -321,9 +261,6 @@
sendMetrics();
}
- metrics = { lcp: 0, cls: 0, inp: 0 };
- lcpObserved = false;
- clsObserved = false;
currentEventId = null;
pageStartTime = 0;
// * Only send external referrer on the first pageview (landing page).
@@ -375,21 +312,6 @@
if (data && data.id) {
currentEventId = data.id;
pageStartTime = Date.now();
- // * For SPA navigations the browser never emits a new largest-contentful-paint
- // * (LCP is only for full document loads). After the new view has had time to
- // * paint, we record time-from-route-change as an LCP proxy so /products etc.
- // * get a value. If the user navigates away before the delay, we leave LCP unset.
- if (isSpaNav) {
- var thatId = data.id;
- // * Run soon so we set lcpObserved before the user leaves; 500ms was too long
- // * and we often sent metrics (next nav or visibilitychange+150ms) before it ran.
- setTimeout(function() {
- if (!lcpObserved && currentEventId === thatId) {
- metrics.lcp = Math.round(performance.now() - routeChangeTime);
- lcpObserved = true;
- }
- }, 100);
- }
}
}).catch(() => {
// * Silently fail - don't interrupt user experience