@startsimpli/analytics 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +46 -0
- package/src/components/GoogleAnalyticsScript.tsx +76 -0
- package/src/components/index.ts +1 -0
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useAnalytics.ts +52 -0
- package/src/index.ts +59 -0
- package/src/providers/gtag.ts +149 -0
- package/src/providers/index.ts +2 -0
- package/src/providers/posthog.ts +221 -0
- package/src/telemetry.ts +281 -0
- package/src/types.ts +82 -0
- package/src/vitals.ts +100 -0
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@startsimpli/analytics",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Shared analytics and telemetry package for StartSimpli Next.js apps",
|
|
5
|
+
"main": "./src/index.ts",
|
|
6
|
+
"types": "./src/index.ts",
|
|
7
|
+
"files": ["src"],
|
|
8
|
+
"publishConfig": {
|
|
9
|
+
"access": "public"
|
|
10
|
+
},
|
|
11
|
+
"exports": {
|
|
12
|
+
".": "./src/index.ts",
|
|
13
|
+
"./providers": "./src/providers/index.ts",
|
|
14
|
+
"./components": "./src/components/index.ts",
|
|
15
|
+
"./hooks": "./src/hooks/index.ts",
|
|
16
|
+
"./vitals": "./src/vitals.ts"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"type-check": "tsc --noEmit"
|
|
20
|
+
},
|
|
21
|
+
"peerDependencies": {
|
|
22
|
+
"react": "^18.0.0 || ^19.0.0",
|
|
23
|
+
"next": "^14.0.0 || ^15.0.0"
|
|
24
|
+
},
|
|
25
|
+
"peerDependenciesMeta": {
|
|
26
|
+
"posthog-js": {
|
|
27
|
+
"optional": true
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"optionalDependencies": {
|
|
31
|
+
"posthog-js": "^1.0.0"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/react": "^18.3.18",
|
|
35
|
+
"@types/node": "^20.17.14",
|
|
36
|
+
"typescript": "^5.7.3"
|
|
37
|
+
},
|
|
38
|
+
"keywords": [
|
|
39
|
+
"analytics",
|
|
40
|
+
"telemetry",
|
|
41
|
+
"posthog",
|
|
42
|
+
"google-analytics",
|
|
43
|
+
"web-vitals",
|
|
44
|
+
"startsimpli"
|
|
45
|
+
]
|
|
46
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* GoogleAnalyticsScript
|
|
5
|
+
* Injects the Google Analytics gtag.js script into the page and tracks route changes.
|
|
6
|
+
* Renders nothing — place once in the root layout.
|
|
7
|
+
*
|
|
8
|
+
* Requires Next.js (uses next/script and next/navigation hooks).
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* // In app/layout.tsx:
|
|
12
|
+
* import { GoogleAnalyticsScript } from '@startsimpli/analytics/components'
|
|
13
|
+
*
|
|
14
|
+
* export default function RootLayout({ children }) {
|
|
15
|
+
* return (
|
|
16
|
+
* <html>
|
|
17
|
+
* <body>
|
|
18
|
+
* <GoogleAnalyticsScript measurementId="G-XXXXXXXXXX" />
|
|
19
|
+
* {children}
|
|
20
|
+
* </body>
|
|
21
|
+
* </html>
|
|
22
|
+
* )
|
|
23
|
+
* }
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { useEffect } from 'react'
|
|
27
|
+
import { usePathname, useSearchParams } from 'next/navigation'
|
|
28
|
+
import Script from 'next/script'
|
|
29
|
+
|
|
30
|
+
// Local window augmentation — the canonical declaration lives in providers/gtag.ts
|
|
31
|
+
// but we avoid importing that module here to keep the component dependency-free.
|
|
32
|
+
declare global {
|
|
33
|
+
interface Window {
|
|
34
|
+
gtag?: (
|
|
35
|
+
command: 'config' | 'event' | 'set' | 'js',
|
|
36
|
+
targetId: string | Date,
|
|
37
|
+
config?: Record<string, unknown>
|
|
38
|
+
) => void
|
|
39
|
+
dataLayer?: unknown[]
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface GoogleAnalyticsScriptProps {
|
|
44
|
+
measurementId: string
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function GoogleAnalyticsScript({ measurementId }: GoogleAnalyticsScriptProps) {
|
|
48
|
+
const pathname = usePathname()
|
|
49
|
+
const searchParams = useSearchParams()
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
if (!measurementId || typeof window === 'undefined' || !window.gtag) return
|
|
53
|
+
|
|
54
|
+
const url = pathname + (searchParams?.toString() ? `?${searchParams.toString()}` : '')
|
|
55
|
+
window.gtag('config', measurementId, { page_path: url })
|
|
56
|
+
}, [pathname, searchParams, measurementId])
|
|
57
|
+
|
|
58
|
+
if (!measurementId) return null
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<>
|
|
62
|
+
<Script
|
|
63
|
+
src={`https://www.googletagmanager.com/gtag/js?id=${measurementId}`}
|
|
64
|
+
strategy="afterInteractive"
|
|
65
|
+
/>
|
|
66
|
+
<Script id="google-analytics" strategy="afterInteractive">
|
|
67
|
+
{`
|
|
68
|
+
window.dataLayer = window.dataLayer || [];
|
|
69
|
+
function gtag(){dataLayer.push(arguments);}
|
|
70
|
+
gtag('js', new Date());
|
|
71
|
+
gtag('config', '${measurementId}', { page_path: window.location.pathname });
|
|
72
|
+
`}
|
|
73
|
+
</Script>
|
|
74
|
+
</>
|
|
75
|
+
)
|
|
76
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { GoogleAnalyticsScript } from './GoogleAnalyticsScript'
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { useAnalytics } from './useAnalytics'
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* useAnalytics
|
|
5
|
+
* React hook that returns bound convenience methods from the telemetry singleton.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* const { track, pageView } = useAnalytics()
|
|
9
|
+
* track('button_clicked', 'user', { button: 'signup' })
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { useCallback } from 'react'
|
|
13
|
+
import { telemetry } from '../telemetry'
|
|
14
|
+
import type { EventCategory, UserIdentity } from '../types'
|
|
15
|
+
|
|
16
|
+
export function useAnalytics() {
|
|
17
|
+
const track = useCallback(
|
|
18
|
+
(
|
|
19
|
+
eventName: string,
|
|
20
|
+
category: EventCategory,
|
|
21
|
+
properties?: Record<string, string | number | boolean | null | undefined>
|
|
22
|
+
) => {
|
|
23
|
+
telemetry.track(eventName, category, properties)
|
|
24
|
+
},
|
|
25
|
+
[]
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
const pageView = useCallback(
|
|
29
|
+
(
|
|
30
|
+
path: string,
|
|
31
|
+
title?: string,
|
|
32
|
+
properties?: Record<string, string | number | boolean | null | undefined>
|
|
33
|
+
) => {
|
|
34
|
+
telemetry.pageView(path, title, properties)
|
|
35
|
+
},
|
|
36
|
+
[]
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
const identify = useCallback((identity: UserIdentity) => {
|
|
40
|
+
telemetry.identify(identity)
|
|
41
|
+
}, [])
|
|
42
|
+
|
|
43
|
+
const reset = useCallback(() => {
|
|
44
|
+
telemetry.reset()
|
|
45
|
+
}, [])
|
|
46
|
+
|
|
47
|
+
const isFeatureEnabled = useCallback((key: string) => {
|
|
48
|
+
return telemetry.isFeatureEnabled(key)
|
|
49
|
+
}, [])
|
|
50
|
+
|
|
51
|
+
return { track, pageView, identify, reset, isFeatureEnabled }
|
|
52
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @startsimpli/analytics
|
|
3
|
+
* Shared analytics and telemetry for StartSimpli Next.js apps.
|
|
4
|
+
*
|
|
5
|
+
* Provides:
|
|
6
|
+
* - TelemetryService: multi-provider event/page-view/user tracking
|
|
7
|
+
* - GoogleAnalyticsProvider: GA4 gtag wrapper
|
|
8
|
+
* - PostHogProvider: PostHog product analytics + feature flags
|
|
9
|
+
* - GoogleAnalyticsScript: Next.js component to inject GA script
|
|
10
|
+
* - useAnalytics: React hook for telemetry in components
|
|
11
|
+
* - reportWebVital: handler for Next.js useReportWebVitals
|
|
12
|
+
*
|
|
13
|
+
* Quick start:
|
|
14
|
+
* // app/layout.tsx
|
|
15
|
+
* import { GoogleAnalyticsScript } from '@startsimpli/analytics/components'
|
|
16
|
+
*
|
|
17
|
+
* // app/providers.tsx (client component)
|
|
18
|
+
* import { telemetry } from '@startsimpli/analytics'
|
|
19
|
+
* telemetry.initialize() // picks up NEXT_PUBLIC_GA_MEASUREMENT_ID / NEXT_PUBLIC_POSTHOG_KEY
|
|
20
|
+
*
|
|
21
|
+
* // Any component
|
|
22
|
+
* import { useAnalytics } from '@startsimpli/analytics/hooks'
|
|
23
|
+
* const { track } = useAnalytics()
|
|
24
|
+
* track('button_clicked', 'user', { button: 'signup' })
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
// Telemetry service (singleton + convenience functions)
|
|
28
|
+
export {
|
|
29
|
+
telemetry,
|
|
30
|
+
trackEvent,
|
|
31
|
+
trackPageView,
|
|
32
|
+
identifyUser,
|
|
33
|
+
resetUser,
|
|
34
|
+
TelemetryService,
|
|
35
|
+
} from './telemetry'
|
|
36
|
+
|
|
37
|
+
// Providers (direct access for apps that need custom wiring)
|
|
38
|
+
export {
|
|
39
|
+
GoogleAnalyticsProvider,
|
|
40
|
+
PostHogProvider,
|
|
41
|
+
gtagEvent,
|
|
42
|
+
gtagPageView,
|
|
43
|
+
gtagSetUserId,
|
|
44
|
+
} from './providers/index'
|
|
45
|
+
|
|
46
|
+
// Types
|
|
47
|
+
export type {
|
|
48
|
+
AnalyticsEvent,
|
|
49
|
+
AnalyticsProvider,
|
|
50
|
+
EventCategory,
|
|
51
|
+
FeatureFlagResult,
|
|
52
|
+
PageViewEvent,
|
|
53
|
+
TelemetryConfig,
|
|
54
|
+
UserIdentity,
|
|
55
|
+
} from './types'
|
|
56
|
+
|
|
57
|
+
// Web vitals
|
|
58
|
+
export type { WebVital, WebVitalName, ReportWebVitalOptions } from './vitals'
|
|
59
|
+
export { reportWebVital } from './vitals'
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Google Analytics (gtag) Wrapper
|
|
3
|
+
* Provides type-safe access to Google Analytics tracking
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type {
|
|
7
|
+
AnalyticsProvider,
|
|
8
|
+
AnalyticsEvent,
|
|
9
|
+
PageViewEvent,
|
|
10
|
+
UserIdentity,
|
|
11
|
+
} from '../types'
|
|
12
|
+
|
|
13
|
+
// Extend window type for gtag
|
|
14
|
+
declare global {
|
|
15
|
+
interface Window {
|
|
16
|
+
gtag?: (
|
|
17
|
+
command: 'config' | 'event' | 'set' | 'js',
|
|
18
|
+
targetId: string | Date,
|
|
19
|
+
config?: Record<string, unknown>
|
|
20
|
+
) => void
|
|
21
|
+
dataLayer?: unknown[]
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class GoogleAnalyticsProvider implements AnalyticsProvider {
|
|
26
|
+
name = 'google-analytics'
|
|
27
|
+
initialized = false
|
|
28
|
+
private measurementId: string
|
|
29
|
+
private debug: boolean
|
|
30
|
+
|
|
31
|
+
constructor(measurementId: string, debug = false) {
|
|
32
|
+
this.measurementId = measurementId
|
|
33
|
+
this.debug = debug
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
initialize(): void {
|
|
37
|
+
if (typeof window === 'undefined') return
|
|
38
|
+
if (this.initialized) return
|
|
39
|
+
|
|
40
|
+
if (window.gtag) {
|
|
41
|
+
this.initialized = true
|
|
42
|
+
if (this.debug) {
|
|
43
|
+
console.log('[GA] Initialized with measurement ID:', this.measurementId)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
identify(identity: UserIdentity): void {
|
|
49
|
+
if (!this.isReady()) return
|
|
50
|
+
|
|
51
|
+
window.gtag?.('config', this.measurementId, {
|
|
52
|
+
user_id: identity.userId,
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
if (identity.properties) {
|
|
56
|
+
window.gtag?.('set', 'user_properties', identity.properties)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (this.debug) {
|
|
60
|
+
console.log('[GA] User identified:', identity.userId)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
reset(): void {
|
|
65
|
+
if (!this.isReady()) return
|
|
66
|
+
|
|
67
|
+
window.gtag?.('config', this.measurementId, {
|
|
68
|
+
user_id: undefined,
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
if (this.debug) {
|
|
72
|
+
console.log('[GA] User reset')
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
trackEvent(event: AnalyticsEvent): void {
|
|
77
|
+
if (!this.isReady()) return
|
|
78
|
+
|
|
79
|
+
window.gtag?.('event', event.name, {
|
|
80
|
+
event_category: event.category,
|
|
81
|
+
...event.properties,
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
if (this.debug) {
|
|
85
|
+
console.log('[GA] Event tracked:', event.name, event.properties)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
trackPageView(pageView: PageViewEvent): void {
|
|
90
|
+
if (!this.isReady()) return
|
|
91
|
+
|
|
92
|
+
window.gtag?.('config', this.measurementId, {
|
|
93
|
+
page_path: pageView.path,
|
|
94
|
+
page_title: pageView.title,
|
|
95
|
+
page_referrer: pageView.referrer,
|
|
96
|
+
...pageView.properties,
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
if (this.debug) {
|
|
100
|
+
console.log('[GA] Page view tracked:', pageView.path)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
setUserProperties(properties: Record<string, unknown>): void {
|
|
105
|
+
if (!this.isReady()) return
|
|
106
|
+
|
|
107
|
+
window.gtag?.('set', 'user_properties', properties)
|
|
108
|
+
|
|
109
|
+
if (this.debug) {
|
|
110
|
+
console.log('[GA] User properties set:', properties)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private isReady(): boolean {
|
|
115
|
+
if (typeof window === 'undefined') return false
|
|
116
|
+
if (!window.gtag) {
|
|
117
|
+
if (this.debug) {
|
|
118
|
+
console.warn('[GA] gtag not loaded')
|
|
119
|
+
}
|
|
120
|
+
return false
|
|
121
|
+
}
|
|
122
|
+
return true
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Direct utility functions for convenience
|
|
127
|
+
export function gtagEvent(
|
|
128
|
+
eventName: string,
|
|
129
|
+
measurementId: string,
|
|
130
|
+
params?: Record<string, unknown>
|
|
131
|
+
): void {
|
|
132
|
+
if (typeof window === 'undefined' || !window.gtag) return
|
|
133
|
+
window.gtag('event', eventName, params)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function gtagPageView(measurementId: string, path: string, title?: string): void {
|
|
137
|
+
if (typeof window === 'undefined' || !window.gtag) return
|
|
138
|
+
window.gtag('config', measurementId, {
|
|
139
|
+
page_path: path,
|
|
140
|
+
page_title: title,
|
|
141
|
+
})
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function gtagSetUserId(measurementId: string, userId: string): void {
|
|
145
|
+
if (typeof window === 'undefined' || !window.gtag) return
|
|
146
|
+
window.gtag('config', measurementId, {
|
|
147
|
+
user_id: userId,
|
|
148
|
+
})
|
|
149
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PostHog Analytics Wrapper
|
|
3
|
+
* Provides type-safe access to PostHog for product analytics and feature flags.
|
|
4
|
+
* posthog-js is an optional peer dependency — only import this file if you have it installed.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
AnalyticsProvider,
|
|
9
|
+
AnalyticsEvent,
|
|
10
|
+
PageViewEvent,
|
|
11
|
+
UserIdentity,
|
|
12
|
+
FeatureFlagResult,
|
|
13
|
+
} from '../types'
|
|
14
|
+
|
|
15
|
+
// posthog-js is optional — dynamic import to avoid hard dependency
|
|
16
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
17
|
+
type PostHogInstance = any
|
|
18
|
+
|
|
19
|
+
let posthog: PostHogInstance = null
|
|
20
|
+
|
|
21
|
+
async function loadPostHog(): Promise<PostHogInstance> {
|
|
22
|
+
if (posthog) return posthog
|
|
23
|
+
try {
|
|
24
|
+
const mod = await import('posthog-js')
|
|
25
|
+
posthog = mod.default
|
|
26
|
+
return posthog
|
|
27
|
+
} catch {
|
|
28
|
+
return null
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export class PostHogProvider implements AnalyticsProvider {
|
|
33
|
+
name = 'posthog'
|
|
34
|
+
initialized = false
|
|
35
|
+
private apiKey: string
|
|
36
|
+
private apiHost: string
|
|
37
|
+
private debug: boolean
|
|
38
|
+
private ph: PostHogInstance = null
|
|
39
|
+
|
|
40
|
+
constructor(apiKey: string, apiHost = 'https://us.i.posthog.com', debug = false) {
|
|
41
|
+
this.apiKey = apiKey
|
|
42
|
+
this.apiHost = apiHost
|
|
43
|
+
this.debug = debug
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
initialize(): void {
|
|
47
|
+
if (typeof window === 'undefined') return
|
|
48
|
+
if (this.initialized) return
|
|
49
|
+
if (!this.apiKey) {
|
|
50
|
+
if (this.debug) {
|
|
51
|
+
console.warn('[PostHog] No API key provided, skipping initialization')
|
|
52
|
+
}
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
loadPostHog().then((ph) => {
|
|
57
|
+
if (!ph) return
|
|
58
|
+
|
|
59
|
+
ph.init(this.apiKey, {
|
|
60
|
+
api_host: this.apiHost,
|
|
61
|
+
person_profiles: 'identified_only',
|
|
62
|
+
capture_pageview: false,
|
|
63
|
+
capture_pageleave: true,
|
|
64
|
+
autocapture: true,
|
|
65
|
+
persistence: 'localStorage+cookie',
|
|
66
|
+
loaded: (instance: PostHogInstance) => {
|
|
67
|
+
if (this.debug) {
|
|
68
|
+
console.log('[PostHog] Loaded successfully')
|
|
69
|
+
instance.debug()
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
this.ph = ph
|
|
75
|
+
this.initialized = true
|
|
76
|
+
|
|
77
|
+
if (this.debug) {
|
|
78
|
+
console.log('[PostHog] Initialized with host:', this.apiHost)
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
identify(identity: UserIdentity): void {
|
|
84
|
+
if (!this.isReady()) return
|
|
85
|
+
|
|
86
|
+
const userProperties: Record<string, unknown> = {}
|
|
87
|
+
if (identity.email) userProperties.email = identity.email
|
|
88
|
+
if (identity.name) userProperties.name = identity.name
|
|
89
|
+
if (identity.properties) Object.assign(userProperties, identity.properties)
|
|
90
|
+
|
|
91
|
+
this.ph.identify(identity.userId, userProperties)
|
|
92
|
+
|
|
93
|
+
if (this.debug) {
|
|
94
|
+
console.log('[PostHog] User identified:', identity.userId)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
reset(): void {
|
|
99
|
+
if (!this.isReady()) return
|
|
100
|
+
this.ph.reset()
|
|
101
|
+
if (this.debug) console.log('[PostHog] User reset')
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
trackEvent(event: AnalyticsEvent): void {
|
|
105
|
+
if (!this.isReady()) return
|
|
106
|
+
|
|
107
|
+
this.ph.capture(event.name, {
|
|
108
|
+
category: event.category,
|
|
109
|
+
timestamp: event.timestamp || Date.now(),
|
|
110
|
+
...event.properties,
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
if (this.debug) {
|
|
114
|
+
console.log('[PostHog] Event tracked:', event.name, event.properties)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
trackPageView(pageView: PageViewEvent): void {
|
|
119
|
+
if (!this.isReady()) return
|
|
120
|
+
|
|
121
|
+
this.ph.capture('$pageview', {
|
|
122
|
+
$current_url: pageView.path,
|
|
123
|
+
$title: pageView.title,
|
|
124
|
+
$referrer: pageView.referrer,
|
|
125
|
+
...pageView.properties,
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
if (this.debug) {
|
|
129
|
+
console.log('[PostHog] Page view tracked:', pageView.path)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
setUserProperties(properties: Record<string, unknown>): void {
|
|
134
|
+
if (!this.isReady()) return
|
|
135
|
+
this.ph.setPersonProperties(properties)
|
|
136
|
+
if (this.debug) console.log('[PostHog] User properties set:', properties)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
getFeatureFlag(
|
|
140
|
+
key: string,
|
|
141
|
+
defaultValue: boolean | string | number = false
|
|
142
|
+
): FeatureFlagResult {
|
|
143
|
+
if (!this.isReady()) {
|
|
144
|
+
return { key, value: defaultValue, source: 'default' }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const value = this.ph.getFeatureFlag(key)
|
|
148
|
+
|
|
149
|
+
if (value === undefined || value === null) {
|
|
150
|
+
return { key, value: defaultValue, source: 'default' }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (this.debug) console.log('[PostHog] Feature flag:', key, '=', value)
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
key,
|
|
157
|
+
value: value as boolean | string | number,
|
|
158
|
+
source: 'posthog',
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
isFeatureEnabled(key: string): boolean {
|
|
163
|
+
if (!this.isReady()) return false
|
|
164
|
+
return this.ph.isFeatureEnabled(key) ?? false
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
reloadFeatureFlags(): void {
|
|
168
|
+
if (!this.isReady()) return
|
|
169
|
+
this.ph.reloadFeatureFlags()
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
onFeatureFlags(callback: (flags: string[]) => void): void {
|
|
173
|
+
if (!this.isReady()) return
|
|
174
|
+
this.ph.onFeatureFlags(callback)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
group(groupType: string, groupKey: string, properties?: Record<string, unknown>): void {
|
|
178
|
+
if (!this.isReady()) return
|
|
179
|
+
this.ph.group(groupType, groupKey, properties)
|
|
180
|
+
if (this.debug) console.log('[PostHog] Group set:', groupType, groupKey)
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
startSessionRecording(): void {
|
|
184
|
+
if (!this.isReady()) return
|
|
185
|
+
this.ph.startSessionRecording()
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
stopSessionRecording(): void {
|
|
189
|
+
if (!this.isReady()) return
|
|
190
|
+
this.ph.stopSessionRecording()
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
getSessionId(): string | undefined {
|
|
194
|
+
if (!this.isReady()) return undefined
|
|
195
|
+
return this.ph.get_session_id()
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
optIn(): void {
|
|
199
|
+
if (!this.isReady()) return
|
|
200
|
+
this.ph.opt_in_capturing()
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
optOut(): void {
|
|
204
|
+
if (!this.isReady()) return
|
|
205
|
+
this.ph.opt_out_capturing()
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
hasOptedOut(): boolean {
|
|
209
|
+
if (!this.isReady()) return false
|
|
210
|
+
return this.ph.has_opted_out_capturing()
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
getInstance(): PostHogInstance {
|
|
214
|
+
return this.isReady() ? this.ph : null
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
private isReady(): boolean {
|
|
218
|
+
if (typeof window === 'undefined') return false
|
|
219
|
+
return this.initialized && !!this.apiKey && !!this.ph
|
|
220
|
+
}
|
|
221
|
+
}
|
package/src/telemetry.ts
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telemetry Service
|
|
3
|
+
* Unified abstraction layer that routes events to all configured analytics providers.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* import { telemetry } from '@startsimpli/analytics'
|
|
7
|
+
*
|
|
8
|
+
* // Track an event
|
|
9
|
+
* telemetry.track('fundraise_created', 'fundraise', { name: 'Series A' })
|
|
10
|
+
*
|
|
11
|
+
* // Track page view
|
|
12
|
+
* telemetry.pageView('/dashboard')
|
|
13
|
+
*
|
|
14
|
+
* // Identify user
|
|
15
|
+
* telemetry.identify({ userId: '123', email: 'user@example.com' })
|
|
16
|
+
*
|
|
17
|
+
* // Check feature flags (PostHog)
|
|
18
|
+
* if (telemetry.isFeatureEnabled('new-dashboard')) { ... }
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { GoogleAnalyticsProvider } from './providers/gtag'
|
|
22
|
+
import { PostHogProvider } from './providers/posthog'
|
|
23
|
+
import type {
|
|
24
|
+
AnalyticsEvent,
|
|
25
|
+
AnalyticsProvider,
|
|
26
|
+
EventCategory,
|
|
27
|
+
FeatureFlagResult,
|
|
28
|
+
PageViewEvent,
|
|
29
|
+
TelemetryConfig,
|
|
30
|
+
UserIdentity,
|
|
31
|
+
} from './types'
|
|
32
|
+
|
|
33
|
+
export class TelemetryService {
|
|
34
|
+
private providers: AnalyticsProvider[] = []
|
|
35
|
+
private posthog: PostHogProvider | null = null
|
|
36
|
+
private googleAnalytics: GoogleAnalyticsProvider | null = null
|
|
37
|
+
private initialized = false
|
|
38
|
+
private debug: boolean
|
|
39
|
+
private enabled: boolean
|
|
40
|
+
|
|
41
|
+
constructor() {
|
|
42
|
+
this.debug = process.env.NODE_ENV === 'development'
|
|
43
|
+
this.enabled =
|
|
44
|
+
process.env.NODE_ENV === 'production' ||
|
|
45
|
+
process.env.NEXT_PUBLIC_ANALYTICS_ENABLED === 'true'
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Initialize all analytics providers.
|
|
50
|
+
* Call once when the app loads (in a client component).
|
|
51
|
+
*
|
|
52
|
+
* @param config - Optional override for provider configuration.
|
|
53
|
+
* If not provided, providers are configured from environment variables:
|
|
54
|
+
* - NEXT_PUBLIC_GA_MEASUREMENT_ID
|
|
55
|
+
* - NEXT_PUBLIC_POSTHOG_KEY / NEXT_PUBLIC_POSTHOG_HOST
|
|
56
|
+
*/
|
|
57
|
+
initialize(config?: Partial<TelemetryConfig>): void {
|
|
58
|
+
if (typeof window === 'undefined') return
|
|
59
|
+
if (this.initialized) return
|
|
60
|
+
|
|
61
|
+
const debug = config?.debug ?? this.debug
|
|
62
|
+
|
|
63
|
+
// Initialize Google Analytics
|
|
64
|
+
const gaMeasurementId =
|
|
65
|
+
config?.googleAnalytics?.measurementId ||
|
|
66
|
+
process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID ||
|
|
67
|
+
''
|
|
68
|
+
|
|
69
|
+
if (config?.googleAnalytics?.enabled !== false && gaMeasurementId) {
|
|
70
|
+
this.googleAnalytics = new GoogleAnalyticsProvider(gaMeasurementId, debug)
|
|
71
|
+
this.googleAnalytics.initialize()
|
|
72
|
+
if (this.googleAnalytics.initialized) {
|
|
73
|
+
this.providers.push(this.googleAnalytics)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Initialize PostHog
|
|
78
|
+
const posthogKey =
|
|
79
|
+
config?.posthog?.apiKey || process.env.NEXT_PUBLIC_POSTHOG_KEY || ''
|
|
80
|
+
const posthogHost =
|
|
81
|
+
config?.posthog?.apiHost ||
|
|
82
|
+
process.env.NEXT_PUBLIC_POSTHOG_HOST ||
|
|
83
|
+
'https://us.i.posthog.com'
|
|
84
|
+
|
|
85
|
+
if (config?.posthog?.enabled !== false && posthogKey) {
|
|
86
|
+
this.posthog = new PostHogProvider(posthogKey, posthogHost, debug)
|
|
87
|
+
this.posthog.initialize()
|
|
88
|
+
if (this.posthog.initialized) {
|
|
89
|
+
this.providers.push(this.posthog)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
this.initialized = true
|
|
94
|
+
|
|
95
|
+
if (debug) {
|
|
96
|
+
console.log(
|
|
97
|
+
'[Telemetry] Initialized with providers:',
|
|
98
|
+
this.providers.map((p) => p.name).join(', ') || 'none'
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
identify(identity: UserIdentity): void {
|
|
104
|
+
if (!this.shouldTrack()) return
|
|
105
|
+
|
|
106
|
+
this.providers.forEach((provider) => {
|
|
107
|
+
try {
|
|
108
|
+
provider.identify(identity)
|
|
109
|
+
} catch (error) {
|
|
110
|
+
console.error(`[Telemetry] Error identifying user in ${provider.name}:`, error)
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
if (this.debug) console.log('[Telemetry] User identified:', identity.userId)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
reset(): void {
|
|
118
|
+
if (!this.shouldTrack()) return
|
|
119
|
+
|
|
120
|
+
this.providers.forEach((provider) => {
|
|
121
|
+
try {
|
|
122
|
+
provider.reset()
|
|
123
|
+
} catch (error) {
|
|
124
|
+
console.error(`[Telemetry] Error resetting in ${provider.name}:`, error)
|
|
125
|
+
}
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
if (this.debug) console.log('[Telemetry] User reset')
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
track(
|
|
132
|
+
eventName: string,
|
|
133
|
+
category: EventCategory,
|
|
134
|
+
properties?: Record<string, string | number | boolean | null | undefined>
|
|
135
|
+
): void {
|
|
136
|
+
if (!this.shouldTrack()) return
|
|
137
|
+
|
|
138
|
+
const event: AnalyticsEvent = {
|
|
139
|
+
name: eventName,
|
|
140
|
+
category,
|
|
141
|
+
properties,
|
|
142
|
+
timestamp: Date.now(),
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
this.providers.forEach((provider) => {
|
|
146
|
+
try {
|
|
147
|
+
provider.trackEvent(event)
|
|
148
|
+
} catch (error) {
|
|
149
|
+
console.error(`[Telemetry] Error tracking event in ${provider.name}:`, error)
|
|
150
|
+
}
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
if (this.debug) console.log('[Telemetry] Event tracked:', eventName, properties)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
pageView(
|
|
157
|
+
path: string,
|
|
158
|
+
title?: string,
|
|
159
|
+
properties?: Record<string, string | number | boolean | null | undefined>
|
|
160
|
+
): void {
|
|
161
|
+
if (!this.shouldTrack()) return
|
|
162
|
+
|
|
163
|
+
const pageView: PageViewEvent = {
|
|
164
|
+
path,
|
|
165
|
+
title,
|
|
166
|
+
referrer: typeof document !== 'undefined' ? document.referrer : undefined,
|
|
167
|
+
properties,
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
this.providers.forEach((provider) => {
|
|
171
|
+
try {
|
|
172
|
+
provider.trackPageView(pageView)
|
|
173
|
+
} catch (error) {
|
|
174
|
+
console.error(`[Telemetry] Error tracking page view in ${provider.name}:`, error)
|
|
175
|
+
}
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
if (this.debug) console.log('[Telemetry] Page view:', path)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
setUserProperties(properties: Record<string, unknown>): void {
|
|
182
|
+
if (!this.shouldTrack()) return
|
|
183
|
+
|
|
184
|
+
this.providers.forEach((provider) => {
|
|
185
|
+
try {
|
|
186
|
+
provider.setUserProperties?.(properties)
|
|
187
|
+
} catch (error) {
|
|
188
|
+
console.error(`[Telemetry] Error setting properties in ${provider.name}:`, error)
|
|
189
|
+
}
|
|
190
|
+
})
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
getFeatureFlag(
|
|
194
|
+
key: string,
|
|
195
|
+
defaultValue: boolean | string | number = false
|
|
196
|
+
): FeatureFlagResult {
|
|
197
|
+
if (!this.posthog) {
|
|
198
|
+
return { key, value: defaultValue, source: 'default' }
|
|
199
|
+
}
|
|
200
|
+
return this.posthog.getFeatureFlag(key, defaultValue)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
isFeatureEnabled(key: string): boolean {
|
|
204
|
+
if (!this.posthog) return false
|
|
205
|
+
return this.posthog.isFeatureEnabled(key)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
reloadFeatureFlags(): void {
|
|
209
|
+
this.posthog?.reloadFeatureFlags()
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
onFeatureFlags(callback: (flags: string[]) => void): void {
|
|
213
|
+
this.posthog?.onFeatureFlags(callback)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
group(groupType: string, groupKey: string, properties?: Record<string, unknown>): void {
|
|
217
|
+
if (!this.shouldTrack()) return
|
|
218
|
+
this.posthog?.group(groupType, groupKey, properties)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
optIn(): void {
|
|
222
|
+
this.enabled = true
|
|
223
|
+
this.posthog?.optIn()
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
optOut(): void {
|
|
227
|
+
this.enabled = false
|
|
228
|
+
this.posthog?.optOut()
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
hasOptedOut(): boolean {
|
|
232
|
+
return this.posthog?.hasOptedOut() ?? false
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
getSessionId(): string | undefined {
|
|
236
|
+
return this.posthog?.getSessionId()
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
getPostHog(): PostHogProvider | null {
|
|
240
|
+
return this.posthog
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
getGoogleAnalytics(): GoogleAnalyticsProvider | null {
|
|
244
|
+
return this.googleAnalytics
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
private shouldTrack(): boolean {
|
|
248
|
+
if (typeof window === 'undefined') return false
|
|
249
|
+
if (!this.initialized) return false
|
|
250
|
+
if (!this.enabled) return false
|
|
251
|
+
return true
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Singleton instance
|
|
256
|
+
export const telemetry = new TelemetryService()
|
|
257
|
+
|
|
258
|
+
// Convenience functions
|
|
259
|
+
export function trackEvent(
|
|
260
|
+
eventName: string,
|
|
261
|
+
category: EventCategory,
|
|
262
|
+
properties?: Record<string, string | number | boolean | null | undefined>
|
|
263
|
+
): void {
|
|
264
|
+
telemetry.track(eventName, category, properties)
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function trackPageView(
|
|
268
|
+
path: string,
|
|
269
|
+
title?: string,
|
|
270
|
+
properties?: Record<string, string | number | boolean | null | undefined>
|
|
271
|
+
): void {
|
|
272
|
+
telemetry.pageView(path, title, properties)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function identifyUser(identity: UserIdentity): void {
|
|
276
|
+
telemetry.identify(identity)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function resetUser(): void {
|
|
280
|
+
telemetry.reset()
|
|
281
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telemetry and Analytics Types
|
|
3
|
+
* Shared type definitions for the analytics abstraction layer
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// Standard event categories for tracking
|
|
7
|
+
export type EventCategory =
|
|
8
|
+
| 'navigation'
|
|
9
|
+
| 'user'
|
|
10
|
+
| 'search'
|
|
11
|
+
| 'filter'
|
|
12
|
+
| 'export'
|
|
13
|
+
| 'integration'
|
|
14
|
+
| 'error'
|
|
15
|
+
| string // allow app-specific categories
|
|
16
|
+
|
|
17
|
+
// Base event interface
|
|
18
|
+
export interface AnalyticsEvent {
|
|
19
|
+
name: string
|
|
20
|
+
category: EventCategory
|
|
21
|
+
properties?: Record<string, string | number | boolean | null | undefined>
|
|
22
|
+
timestamp?: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Page view tracking
|
|
26
|
+
export interface PageViewEvent {
|
|
27
|
+
path: string
|
|
28
|
+
title?: string
|
|
29
|
+
referrer?: string
|
|
30
|
+
properties?: Record<string, string | number | boolean | null | undefined>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// User identification
|
|
34
|
+
export interface UserIdentity {
|
|
35
|
+
userId: string
|
|
36
|
+
email?: string
|
|
37
|
+
name?: string
|
|
38
|
+
properties?: Record<string, string | number | boolean | null | undefined>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Feature flag evaluation result
|
|
42
|
+
export interface FeatureFlagResult {
|
|
43
|
+
key: string
|
|
44
|
+
value: boolean | string | number
|
|
45
|
+
source: 'posthog' | 'default'
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Analytics provider interface - implemented by each provider wrapper
|
|
49
|
+
export interface AnalyticsProvider {
|
|
50
|
+
name: string
|
|
51
|
+
initialized: boolean
|
|
52
|
+
|
|
53
|
+
// Core methods
|
|
54
|
+
initialize(): void
|
|
55
|
+
identify(identity: UserIdentity): void
|
|
56
|
+
reset(): void
|
|
57
|
+
|
|
58
|
+
// Tracking methods
|
|
59
|
+
trackEvent(event: AnalyticsEvent): void
|
|
60
|
+
trackPageView(pageView: PageViewEvent): void
|
|
61
|
+
|
|
62
|
+
// Optional: Feature flags (PostHog specific)
|
|
63
|
+
getFeatureFlag?(key: string, defaultValue?: boolean | string | number): FeatureFlagResult
|
|
64
|
+
|
|
65
|
+
// Optional: User properties
|
|
66
|
+
setUserProperties?(properties: Record<string, unknown>): void
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Telemetry configuration
|
|
70
|
+
export interface TelemetryConfig {
|
|
71
|
+
enabled: boolean
|
|
72
|
+
googleAnalytics: {
|
|
73
|
+
enabled: boolean
|
|
74
|
+
measurementId: string
|
|
75
|
+
}
|
|
76
|
+
posthog: {
|
|
77
|
+
enabled: boolean
|
|
78
|
+
apiKey: string
|
|
79
|
+
apiHost?: string
|
|
80
|
+
}
|
|
81
|
+
debug?: boolean
|
|
82
|
+
}
|
package/src/vitals.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web Vitals Tracking
|
|
3
|
+
* Tracks Core Web Vitals (LCP, FID, CLS, TTFB, FCP, INP) and reports them
|
|
4
|
+
* to console in development and to a configurable endpoint in production.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type WebVitalName = 'LCP' | 'FID' | 'CLS' | 'TTFB' | 'FCP' | 'INP'
|
|
8
|
+
|
|
9
|
+
export interface WebVital {
|
|
10
|
+
id: string
|
|
11
|
+
name: WebVitalName
|
|
12
|
+
value: number
|
|
13
|
+
rating: 'good' | 'needs-improvement' | 'poor'
|
|
14
|
+
delta: number
|
|
15
|
+
navigationType: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Thresholds per Google's Core Web Vitals guidance
|
|
19
|
+
const VITAL_THRESHOLDS: Record<WebVitalName, [number, number]> = {
|
|
20
|
+
LCP: [2500, 4000],
|
|
21
|
+
FID: [100, 300],
|
|
22
|
+
CLS: [0.1, 0.25],
|
|
23
|
+
TTFB: [800, 1800],
|
|
24
|
+
FCP: [1800, 3000],
|
|
25
|
+
INP: [200, 500],
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function rateVital(name: WebVitalName, value: number): WebVital['rating'] {
|
|
29
|
+
const [good, poor] = VITAL_THRESHOLDS[name]
|
|
30
|
+
if (value <= good) return 'good'
|
|
31
|
+
if (value <= poor) return 'needs-improvement'
|
|
32
|
+
return 'poor'
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function sendVitalToEndpoint(vital: WebVital, endpoint: string): Promise<void> {
|
|
36
|
+
try {
|
|
37
|
+
await fetch(endpoint, {
|
|
38
|
+
method: 'POST',
|
|
39
|
+
headers: { 'Content-Type': 'application/json' },
|
|
40
|
+
body: JSON.stringify(vital),
|
|
41
|
+
keepalive: true,
|
|
42
|
+
})
|
|
43
|
+
} catch {
|
|
44
|
+
// Silent - never block the user for analytics failures
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface ReportWebVitalOptions {
|
|
49
|
+
/**
|
|
50
|
+
* API endpoint to POST vitals to in production.
|
|
51
|
+
* Defaults to '/api/analytics/vitals'.
|
|
52
|
+
*/
|
|
53
|
+
endpoint?: string
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Handler for Next.js useReportWebVitals hook.
|
|
58
|
+
* Call this from layout.tsx via the useReportWebVitals hook.
|
|
59
|
+
*
|
|
60
|
+
* @example
|
|
61
|
+
* // In a client component in your root layout:
|
|
62
|
+
* import { useReportWebVitals } from 'next/web-vitals'
|
|
63
|
+
* import { reportWebVital } from '@startsimpli/analytics/vitals'
|
|
64
|
+
*
|
|
65
|
+
* export function WebVitalsReporter() {
|
|
66
|
+
* useReportWebVitals(reportWebVital)
|
|
67
|
+
* return null
|
|
68
|
+
* }
|
|
69
|
+
*/
|
|
70
|
+
export function reportWebVital(
|
|
71
|
+
metric: {
|
|
72
|
+
id: string
|
|
73
|
+
name: string
|
|
74
|
+
value: number
|
|
75
|
+
delta: number
|
|
76
|
+
navigationType?: string
|
|
77
|
+
},
|
|
78
|
+
options: ReportWebVitalOptions = {}
|
|
79
|
+
): void {
|
|
80
|
+
const name = metric.name as WebVitalName
|
|
81
|
+
const vital: WebVital = {
|
|
82
|
+
id: metric.id,
|
|
83
|
+
name,
|
|
84
|
+
value: metric.value,
|
|
85
|
+
rating: rateVital(name, metric.value),
|
|
86
|
+
delta: metric.delta,
|
|
87
|
+
navigationType: metric.navigationType ?? 'navigate',
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
91
|
+
const label = vital.rating === 'good' ? '✓' : vital.rating === 'poor' ? '✗' : '~'
|
|
92
|
+
console.log(
|
|
93
|
+
`[Web Vitals] ${label} ${vital.name}: ${vital.value.toFixed(2)} (${vital.rating})`
|
|
94
|
+
)
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const endpoint = options.endpoint ?? '/api/analytics/vitals'
|
|
99
|
+
sendVitalToEndpoint(vital, endpoint)
|
|
100
|
+
}
|