@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 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,2 @@
1
+ export { GoogleAnalyticsProvider, gtagEvent, gtagPageView, gtagSetUserId } from './gtag'
2
+ export { PostHogProvider } from './posthog'
@@ -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
+ }
@@ -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
+ }