@djangocfg/analytics 2.1.489 → 2.1.491

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/README.md ADDED
@@ -0,0 +1,46 @@
1
+ # @djangocfg/analytics
2
+
3
+ First-party product analytics for DjangoCFG. One client fans out pageviews and
4
+ custom events to django-cfg and optional GA4 transports.
5
+
6
+ ## Product events
7
+
8
+ Mount `AnalyticsProvider` once (Layouts `BaseApp` does this automatically),
9
+ then use the typed React API:
10
+
11
+ ```tsx
12
+ import { useAnalytics } from '@djangocfg/analytics'
13
+
14
+ const { event } = useAnalytics()
15
+ event('layouts.auth.step_viewed', { step: 'otp' })
16
+ event('billing.checkout_started', { plan: 'pro' })
17
+ ```
18
+
19
+ For browser integration code outside React, use the optional facade installed
20
+ by the provider. It is the same client — not a second transport:
21
+
22
+ ```ts
23
+ window.analytics?.event('billing.checkout_started', { plan: 'pro' })
24
+ ```
25
+
26
+ Unknown product event names are accepted. Extend `AnalyticsEventMap` to make
27
+ your product's names and properties type-safe everywhere:
28
+
29
+ ```ts
30
+ declare module '@djangocfg/analytics' {
31
+ interface AnalyticsEventMap {
32
+ 'billing.checkout_started': { plan: 'pro' | 'team' }
33
+ }
34
+ }
35
+ ```
36
+
37
+ Use stable event names as your analytics contract. Django goals and funnels are
38
+ configured against those names; do not encode dynamic identifiers in names —
39
+ put bounded, non-sensitive dimensions in `props` instead.
40
+
41
+ ## Identity and funnels
42
+
43
+ Browser journeys are scoped to a host. A property can aggregate hosts, but it
44
+ does not merge anonymous visitor identities across them. Server-confirmed auth
45
+ events use the request-derived identity for their originating host, so an auth
46
+ funnel on that host can join its UI and successful-login events safely.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@djangocfg/analytics",
3
- "version": "2.1.489",
3
+ "version": "2.1.491",
4
4
  "description": "First-party analytics for django-cfg apps: one route listener, N transports (self-hosted + GA4). Pageviews, sessions, and authenticated-user attribution.",
5
5
  "keywords": [
6
6
  "django",
@@ -63,7 +63,7 @@
63
63
  }
64
64
  },
65
65
  "devDependencies": {
66
- "@djangocfg/typescript-config": "^2.1.489",
66
+ "@djangocfg/typescript-config": "^2.1.491",
67
67
  "@types/node": "^25.9.5",
68
68
  "@types/react": "19.2.15",
69
69
  "next": "^15.5.9",
@@ -14,6 +14,7 @@
14
14
  */
15
15
 
16
16
  import type { AnalyticsEventPayload, AnalyticsOptions, AnalyticsTransport } from './types'
17
+ import type { AnalyticsEventName, AnalyticsEventProps } from '../events'
17
18
 
18
19
  export class AnalyticsClient {
19
20
  private transports: AnalyticsTransport[] = []
@@ -70,7 +71,10 @@ export class AnalyticsClient {
70
71
  this.fanOut((t) => t.pageview(payload))
71
72
  }
72
73
 
73
- trackEvent(name: string, props: Record<string, unknown> = {}): void {
74
+ trackEvent<Name extends AnalyticsEventName>(
75
+ name: Name,
76
+ props: AnalyticsEventProps<Name> = {} as AnalyticsEventProps<Name>,
77
+ ): void {
74
78
  if (typeof window === 'undefined') return
75
79
 
76
80
  this.fanOut((t) =>
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Imperative access to the one AnalyticsClient mounted by AnalyticsProvider.
3
+ *
4
+ * `window.analytics` is deliberately a thin optional facade, analogous to
5
+ * `window.dialog` but not a second event pipeline. React code uses
6
+ * `useAnalytics`; integration code without a React tree can use
7
+ * `getAnalytics()?.event(...)`.
8
+ */
9
+
10
+ import type { AnalyticsEventName, AnalyticsEventProps } from '../events'
11
+ import type { AnalyticsClient } from './client'
12
+
13
+ export interface AnalyticsAPI {
14
+ event<Name extends AnalyticsEventName>(
15
+ name: Name,
16
+ props?: AnalyticsEventProps<Name>,
17
+ ): void
18
+ flush(): void
19
+ }
20
+
21
+ let activeApi: AnalyticsAPI | null = null
22
+
23
+ export function getAnalytics(): AnalyticsAPI | null {
24
+ return activeApi
25
+ }
26
+
27
+ export function installAnalytics(client: AnalyticsClient): () => void {
28
+ const api: AnalyticsAPI = {
29
+ event: (name, props) => client.trackEvent(name, props),
30
+ flush: () => client.flush(),
31
+ }
32
+ activeApi = api
33
+
34
+ if (typeof window !== 'undefined') {
35
+ window.analytics = api
36
+ }
37
+
38
+ return () => {
39
+ if (activeApi !== api) return
40
+ activeApi = null
41
+ if (typeof window !== 'undefined' && window.analytics === api) {
42
+ delete window.analytics
43
+ }
44
+ }
45
+ }
46
+
47
+ declare global {
48
+ interface Window {
49
+ /** Installed only while an AnalyticsProvider is mounted. */
50
+ analytics?: AnalyticsAPI
51
+ }
52
+ }
package/src/core/index.ts CHANGED
@@ -6,6 +6,8 @@
6
6
  */
7
7
 
8
8
  export { AnalyticsClient } from './client'
9
+ export { getAnalytics } from './global'
10
+ export type { AnalyticsAPI } from './global'
9
11
  export type { AnalyticsEventPayload, AnalyticsOptions, AnalyticsTransport } from './types'
10
12
 
11
13
  /**
package/src/events.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  /**
2
- * Analytics Events Constants
2
+ * Product-event catalog.
3
3
  *
4
- * Predefined event names and categories for consistent tracking
5
- * across the entire application.
4
+ * This is the single TypeScript source of truth for framework event names and
5
+ * their properties. Products extend `AnalyticsEventMap` by declaration merging
6
+ * instead of maintaining a second enum, transport, or browser global.
6
7
  */
7
8
 
8
9
  /**
@@ -58,3 +59,52 @@ export const AnalyticsEvent = {
58
59
 
59
60
  export type AnalyticsCategoryType = typeof AnalyticsCategory[keyof typeof AnalyticsCategory];
60
61
  export type AnalyticsEventType = typeof AnalyticsEvent[keyof typeof AnalyticsEvent];
62
+
63
+ export type AnalyticsPropertyValue = string | number | boolean | null;
64
+ export type AnalyticsEventProperties = Record<string, AnalyticsPropertyValue>;
65
+
66
+ /**
67
+ * Framework event names with their valid payloads.
68
+ *
69
+ * Apps can extend this interface:
70
+ *
71
+ * ```ts
72
+ * declare module '@djangocfg/analytics' {
73
+ * interface AnalyticsEventMap {
74
+ * 'billing.checkout_started': { plan: 'pro' | 'team' }
75
+ * }
76
+ * }
77
+ * ```
78
+ */
79
+ export interface AnalyticsEventMap {
80
+ auth_otp_request: { method?: 'email' | 'phone' };
81
+ auth_login_success: { provider?: 'email' | 'github'; is_new_user?: boolean };
82
+ auth_otp_verify_fail: { reason?: string };
83
+ auth_session_expired: Record<string, never>;
84
+ auth_token_refresh: Record<string, never>;
85
+ auth_token_refresh_fail: { reason?: string };
86
+ auth_logout: Record<string, never>;
87
+ auth_oauth_start: { provider: 'github' };
88
+ auth_oauth_fail: { provider?: 'github'; reason?: string };
89
+ error_boundary: { boundary?: string };
90
+ error_api: { status?: number; operation?: string };
91
+ error_validation: { field?: string };
92
+ error_network: { operation?: string };
93
+ nav_admin_enter: Record<string, never>;
94
+ nav_dashboard_enter: Record<string, never>;
95
+ nav_page_view: Record<string, never>;
96
+ theme_change: { theme?: string };
97
+ sidebar_toggle: { open?: boolean };
98
+ mobile_menu_open: Record<string, never>;
99
+ user_profile_view: Record<string, never>;
100
+ user_profile_update: Record<string, never>;
101
+ 'layouts.auth.step_viewed': { step: 'identifier' | 'otp' | '2fa' | '2fa-setup' | 'success' };
102
+ }
103
+
104
+ /** A catalog event is fully typed; arbitrary product events remain supported. */
105
+ export type AnalyticsEventName = keyof AnalyticsEventMap | (string & {});
106
+
107
+ export type AnalyticsEventProps<Name extends AnalyticsEventName> =
108
+ Name extends keyof AnalyticsEventMap
109
+ ? AnalyticsEventMap[Name]
110
+ : AnalyticsEventProperties;
package/src/index.ts CHANGED
@@ -16,6 +16,8 @@
16
16
  */
17
17
 
18
18
  export { AnalyticsClient } from './core/client'
19
+ export { getAnalytics } from './core/global'
20
+ export type { AnalyticsAPI } from './core/global'
19
21
  export type {
20
22
  AnalyticsEventPayload,
21
23
  AnalyticsOptions,
@@ -34,4 +36,12 @@ export { PageviewTracker } from './next/PageviewTracker'
34
36
  export type { PageviewTrackerProps } from './next/PageviewTracker'
35
37
 
36
38
  export { AnalyticsCategory, AnalyticsEvent } from './events'
37
- export type { AnalyticsCategoryType, AnalyticsEventType } from './events'
39
+ export type {
40
+ AnalyticsCategoryType,
41
+ AnalyticsEventMap,
42
+ AnalyticsEventName,
43
+ AnalyticsEventProps,
44
+ AnalyticsEventProperties,
45
+ AnalyticsEventType,
46
+ AnalyticsPropertyValue,
47
+ } from './events'
@@ -8,7 +8,9 @@
8
8
  import { createContext, useContext, useEffect, useMemo, useRef, type ReactNode } from 'react'
9
9
 
10
10
  import { AnalyticsClient } from '../core/client'
11
+ import { installAnalytics } from '../core/global'
11
12
  import type { AnalyticsOptions } from '../core/types'
13
+ import type { AnalyticsEventName, AnalyticsEventProps } from '../events'
12
14
 
13
15
  const AnalyticsContext = createContext<AnalyticsClient | null>(null)
14
16
 
@@ -41,7 +43,11 @@ export function AnalyticsProvider({
41
43
 
42
44
  useEffect(() => {
43
45
  void client.init()
44
- return () => client.destroy()
46
+ const uninstall = installAnalytics(client)
47
+ return () => {
48
+ uninstall()
49
+ client.destroy()
50
+ }
45
51
  }, [client])
46
52
 
47
53
  useEffect(() => {
@@ -63,7 +69,8 @@ export function useAnalytics() {
63
69
 
64
70
  return useMemo(
65
71
  () => ({
66
- event: (name: string, props?: Record<string, unknown>) => client?.trackEvent(name, props),
72
+ event: <Name extends AnalyticsEventName>(name: Name, props?: AnalyticsEventProps<Name>) =>
73
+ client?.trackEvent(name, props),
67
74
  identify: (userId: string | null) => client?.identify(userId),
68
75
  flush: () => client?.flush(false),
69
76
  /** False when no AnalyticsProvider is mounted above this component. */