diff --git a/README.md b/README.md index 770f1e2..2bd2d0d 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ A modern, type-safe analytics library for tracking user events across multiple p - [Import Structure](#import-structure) - [🚀 Quick Start](#-quick-start) - [Basic Usage](#basic-usage) + - [Consent-aware Capture](#consent-aware-capture) - [Global Instance Management](#global-instance-management) - [React Integration](#react-integration) - [SPM (Source Page Medium) Auto-Prefixing](#spm-source-page-medium-auto-prefixing) @@ -191,6 +192,46 @@ await analytics.identify('user_123', { }); ``` +### Consent-aware Capture + +Start opted out when analytics requires explicit user consent, then synchronize the user's choice: + +```typescript +const analytics = createAnalytics({ + business: 'my-app', + captureEnabled: false, + providers: { + posthog: { + enabled: true, + key: process.env.POSTHOG_KEY!, + }, + }, +}); + +await analytics.initialize(); + +// No track, identify, or page-view calls are captured before this point. +analytics.setCaptureEnabled(true); + +// Consent withdrawal stops capture immediately. Reset remains available for +// clearing identity during logout. +analytics.setCaptureEnabled(false); +await analytics.reset(); +``` + +For React, pass the current consent state to the provider. It is synchronized before provider +initialization and whenever the value changes: + +```tsx + + + +``` + +The PostHog browser provider maps this state to `opt_in_capturing` and `opt_out_capturing`, so +automatic capture and calls made through the native PostHog instance respect the same choice. GA4 +uses Google's `ga-disable-*` flag, and X Ads defers loading its pixel until capture is enabled. + ### Global Instance Management **Singleton Pattern (Recommended for simple apps):** @@ -389,8 +430,9 @@ Main class for managing analytics providers. - `identify(userId: string, properties?): Promise` - Identify user - `trackPageView(page: string, properties?): Promise` - Track page view - `reset(): Promise` - Reset user identity +- `setCaptureEnabled(enabled: boolean): this` - Synchronize analytics consent - `setGlobalContext(context: EventContext): this` - Set global context -- `getStatus(): { initialized: boolean; providersCount: number }` - Get status +- `getStatus(): { captureEnabled: boolean; initialized: boolean; providersCount: number }` - Get status ### Global Instance Management @@ -434,6 +476,7 @@ getGlobalAnalyticsNames(): string[] diff --git a/src/base.ts b/src/base.ts index ab27548..be4ebb5 100644 --- a/src/base.ts +++ b/src/base.ts @@ -8,6 +8,7 @@ export abstract class BaseAnalytics { protected readonly debug: boolean; protected readonly enabled: boolean; protected readonly business: string; + private captureEnabled: boolean | undefined; constructor(config: { business: string; debug?: boolean; enabled?: boolean }) { this.debug = config.debug ?? false; @@ -45,6 +46,17 @@ export abstract class BaseAnalytics { */ abstract getProviderName(): string; + /** + * Update whether this provider may capture analytics data. + * + * Providers with native consent APIs can override this method to synchronize + * the state with their SDK after calling super. + */ + setCaptureEnabled(enabled: boolean): void { + this.captureEnabled = enabled; + this.log(`Capture ${enabled ? 'enabled' : 'disabled'}`); + } + /** * Check if provider is enabled */ @@ -56,6 +68,27 @@ export abstract class BaseAnalytics { return true; } + /** + * Check whether analytics capture is allowed. + */ + protected isCaptureEnabled(): boolean { + if (this.captureEnabled === false) { + this.log('Capture is disabled'); + return false; + } + + return true; + } + + /** + * Get the explicitly configured capture state. + * + * Undefined means the consumer has not opted into library-managed consent. + */ + protected getCaptureEnabled(): boolean | undefined { + return this.captureEnabled; + } + /** * Validate event data */ diff --git a/src/config.ts b/src/config.ts index 4893cca..44b8886 100644 --- a/src/config.ts +++ b/src/config.ts @@ -35,7 +35,7 @@ import type { AnalyticsConfig } from './types'; * ``` */ export function createAnalytics(config: AnalyticsConfig): AnalyticsManager { - const manager = new AnalyticsManager(config.business, config.debug); + const manager = new AnalyticsManager(config.business, config.debug, config.captureEnabled); // Register PostHog if enabled if (config.providers.posthog?.enabled) { diff --git a/src/index.test.ts b/src/index.test.ts index 04ab917..bc92c71 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -296,6 +296,29 @@ describe('Lobe Analytics Integration Tests', () => { expect(mockProvider.events).toHaveLength(0); expect(mockProvider.identifiedUsers).toHaveLength(0); }); + + it('should suppress capture while disabled and resume after opt-in', async () => { + manager.setCaptureEnabled(false); + + await manager.track({ name: 'disabled_event' }); + await manager.identify(`user_${testId}`); + await manager.trackPageView('/disabled'); + + expect(manager.getStatus().captureEnabled).toBe(false); + expect(mockProvider.events).toHaveLength(0); + expect(mockProvider.identifiedUsers).toHaveLength(0); + expect(mockProvider.pageViews).toHaveLength(0); + + // Identity cleanup must remain available after consent withdrawal. + await manager.reset(); + expect(mockProvider.resetCalled).toBe(true); + + manager.setCaptureEnabled(true); + await manager.track({ name: 'enabled_event' }); + + expect(manager.getStatus().captureEnabled).toBe(true); + expect(mockProvider.events).toHaveLength(1); + }); }); describe('Global Context Management', () => { diff --git a/src/manager.ts b/src/manager.ts index d863113..9266f78 100644 --- a/src/manager.ts +++ b/src/manager.ts @@ -9,19 +9,25 @@ import type { AnalyticsEvent, EventContext, PredefinedEvents, ProviderTypeMap } export class AnalyticsManager { private readonly providers = new Map(); private readonly business: string; + private captureEnabled: boolean | undefined; private globalContext: EventContext = {}; private initialized = false; private readonly debug: boolean; - constructor(business: string, debug = false) { + constructor(business: string, debug = false, captureEnabled?: boolean) { this.business = business; this.debug = debug; + this.captureEnabled = captureEnabled; } /** * 注册分析工具提供商 */ registerProvider(name: string, provider: BaseAnalytics): this { + if (this.captureEnabled !== undefined) { + provider.setCaptureEnabled(this.captureEnabled); + } + this.providers.set(name, provider); this.log(`Registered provider: ${name}`); return this; @@ -74,7 +80,7 @@ export class AnalyticsManager { * 追踪事件到所有提供商 */ async track(event: AnalyticsEvent): Promise { - if (!this.ensureInitialized()) return; + if (!this.ensureCaptureEnabled()) return; const enrichedEvent = this.enrichEvent(event); await this.executeOnAllProviders('track', enrichedEvent); @@ -97,7 +103,7 @@ export class AnalyticsManager { * 识别用户 */ async identify(userId: string, properties?: Record): Promise { - if (!this.ensureInitialized()) return; + if (!this.ensureCaptureEnabled()) return; const mergedProperties = { ...this.globalContext, ...properties }; await this.executeOnAllProviders('identify', userId, mergedProperties); } @@ -106,7 +112,7 @@ export class AnalyticsManager { * 追踪页面浏览 */ async trackPageView(page: string, properties?: Record): Promise { - if (!this.ensureInitialized()) return; + if (!this.ensureCaptureEnabled()) return; const mergedProperties = { ...this.globalContext, ...properties }; await this.executeOnAllProviders('trackPageView', page, mergedProperties); } @@ -119,6 +125,30 @@ export class AnalyticsManager { await this.executeOnAllProviders('reset'); } + /** + * Enable or disable capture for all providers. + * + * Reset remains available while capture is disabled so applications can + * clear user identity during logout or consent withdrawal. + */ + setCaptureEnabled(enabled: boolean): this { + this.captureEnabled = enabled; + + for (const provider of this.providers.values()) { + try { + provider.setCaptureEnabled(enabled); + } catch (error) { + console.error( + `[AnalyticsManager] Failed to update capture state for ${provider.getProviderName()}:`, + error, + ); + } + } + + this.log(`Capture ${enabled ? 'enabled' : 'disabled'}`); + return this; + } + /** * 设置全局上下文 */ @@ -138,8 +168,9 @@ export class AnalyticsManager { /** * 获取管理器状态 */ - getStatus(): { initialized: boolean; providersCount: number } { + getStatus(): { captureEnabled: boolean; initialized: boolean; providersCount: number } { return { + captureEnabled: this.captureEnabled !== false, initialized: this.initialized, providersCount: this.providers.size, }; @@ -156,6 +187,22 @@ export class AnalyticsManager { return true; } + /** + * Check whether capture is initialized and allowed. + */ + private ensureCaptureEnabled(): boolean { + if (!this.ensureInitialized()) { + return false; + } + + if (this.captureEnabled === false) { + this.log('Capture is disabled'); + return false; + } + + return true; + } + /** * 在所有提供商上执行操作 */ diff --git a/src/providers/ga4.test.ts b/src/providers/ga4.test.ts new file mode 100644 index 0000000..ff54eb4 --- /dev/null +++ b/src/providers/ga4.test.ts @@ -0,0 +1,68 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it } from 'vitest'; + +import { GoogleAnalyticsProvider } from './ga4'; + +const measurementId = 'G-TEST123'; +const disableFlag = `ga-disable-${measurementId}`; + +const getQueuedCommands = (): unknown[][] => + ((window as Window & { dataLayer?: IArguments[] }).dataLayer ?? []).map((args) => + Array.from(args), + ); + +describe('GoogleAnalyticsProvider', () => { + beforeEach(() => { + document.head.innerHTML = ''; + delete (window as Window & { dataLayer?: IArguments[] }).dataLayer; + delete (window as Window & { gtag?: unknown }).gtag; + Reflect.deleteProperty(window, disableFlag); + }); + + it('prevents the Google tag from sending data while capture is disabled', async () => { + const provider = new GoogleAnalyticsProvider( + { + enabled: true, + measurementId, + }, + 'test', + ); + + provider.setCaptureEnabled(false); + await provider.initialize(); + await provider.track({ name: 'private_event' }); + + expect(Reflect.get(window, disableFlag)).toBe(true); + expect(getQueuedCommands()).toContainEqual([ + 'config', + measurementId, + expect.objectContaining({ send_page_view: false }), + ]); + expect(getQueuedCommands().some(([command]) => command === 'event')).toBe(false); + + provider.setCaptureEnabled(true); + + expect(Reflect.get(window, disableFlag)).toBe(false); + expect( + getQueuedCommands().filter(([command, id]) => command === 'config' && id === measurementId), + ).toHaveLength(2); + }); + + it('does not send another automatic page view after capture resumes', async () => { + const provider = new GoogleAnalyticsProvider( + { + enabled: true, + measurementId, + }, + 'test', + ); + + await provider.initialize(); + provider.setCaptureEnabled(false); + provider.setCaptureEnabled(true); + + expect( + getQueuedCommands().filter(([command, id]) => command === 'config' && id === measurementId), + ).toHaveLength(1); + }); +}); diff --git a/src/providers/ga4.ts b/src/providers/ga4.ts index 3ec85ae..df6da7e 100644 --- a/src/providers/ga4.ts +++ b/src/providers/ga4.ts @@ -11,6 +11,7 @@ import type { AnalyticsEvent, GoogleAnalyticsProviderConfig } from '@/types'; */ export class GoogleAnalyticsProvider extends BaseAnalytics { private readonly config: GoogleAnalyticsProviderConfig; + private initialPageViewSuppressed = false; private initialized = false; constructor(config: GoogleAnalyticsProviderConfig, business: string) { @@ -34,6 +35,8 @@ export class GoogleAnalyticsProvider extends BaseAnalytics { return; } + this.syncCaptureState(); + // Initialize dataLayer if not exists (window as any).dataLayer = (window as any).dataLayer || []; const gtag = @@ -60,15 +63,8 @@ export class GoogleAnalyticsProvider extends BaseAnalytics { // Initialize gtag gtag('js', new Date()); - // Configure GA4 with user config and our defaults - const configOptions = { - // User's gtag config options - ...this.config.gtagConfig, - // Our internal config (these override user config for consistency) - debug_mode: this.debug || this.config.gtagConfig?.debug_mode, - }; - - gtag('config', this.config.measurementId, configOptions); + this.initialPageViewSuppressed = !this.isCaptureEnabled(); + this.configureGtag(gtag); this.initialized = true; this.log('Google Analytics 4 initialized successfully'); @@ -81,7 +77,12 @@ export class GoogleAnalyticsProvider extends BaseAnalytics { } async track(event: AnalyticsEvent): Promise { - if (!this.isEnabled() || !this.initialized || !this.validateEvent(event)) { + if ( + !this.isEnabled() || + !this.isCaptureEnabled() || + !this.initialized || + !this.validateEvent(event) + ) { return; } @@ -110,7 +111,7 @@ export class GoogleAnalyticsProvider extends BaseAnalytics { } async identify(userId: string, properties?: Record): Promise { - if (!this.isEnabled() || !this.initialized) { + if (!this.isEnabled() || !this.isCaptureEnabled() || !this.initialized) { return; } @@ -147,7 +148,7 @@ export class GoogleAnalyticsProvider extends BaseAnalytics { } async trackPageView(page: string, properties?: Record): Promise { - if (!this.isEnabled() || !this.initialized) { + if (!this.isEnabled() || !this.isCaptureEnabled() || !this.initialized) { return; } @@ -192,15 +193,36 @@ export class GoogleAnalyticsProvider extends BaseAnalytics { user_properties: {}, }); - // 3. Track logout event - gtag('event', 'logout', this.enrichProperties()); + // 3. Track logout only when capture is still allowed + if (this.isCaptureEnabled()) { + gtag('event', 'logout', this.enrichProperties()); + } - this.log('Reset user identity and tracked logout'); + this.log('Reset user identity'); } catch (error) { this.logError('Failed to reset user identity', error); } } + override setCaptureEnabled(enabled: boolean): void { + const previousCaptureEnabled = this.getCaptureEnabled(); + super.setCaptureEnabled(enabled); + this.syncCaptureState(); + + if ( + enabled && + previousCaptureEnabled === false && + this.initialized && + this.initialPageViewSuppressed + ) { + const gtag = (window as any).gtag; + if (gtag) { + this.configureGtag(gtag); + this.initialPageViewSuppressed = false; + } + } + } + /** * Check if feature flag is enabled * Note: GA4 doesn't have built-in feature flags like PostHog, @@ -255,6 +277,29 @@ export class GoogleAnalyticsProvider extends BaseAnalytics { return this.config.measurementId; } + private configureGtag(gtag: (...args: unknown[]) => void): void { + const configOptions = { + // User's gtag config options + ...this.config.gtagConfig, + // Our internal config (these override user config for consistency) + debug_mode: this.debug || this.config.gtagConfig?.debug_mode, + ...(!this.isCaptureEnabled() && { send_page_view: false }), + }; + + gtag('config', this.config.measurementId, configOptions); + } + + private syncCaptureState(): void { + const captureEnabled = this.getCaptureEnabled(); + if (typeof window === 'undefined' || captureEnabled === undefined) { + return; + } + + // Google documents this flag as the strict opt-out mechanism that prevents + // the tag from sending data, including cookieless consent-mode pings. + (window as any)[`ga-disable-${this.config.measurementId}`] = !captureEnabled; + } + /** * Get current business context */ diff --git a/src/providers/posthog-node.test.ts b/src/providers/posthog-node.test.ts new file mode 100644 index 0000000..1f6126b --- /dev/null +++ b/src/providers/posthog-node.test.ts @@ -0,0 +1,54 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { PostHogNodeAnalyticsProvider } from './posthog-node'; + +const { getFeatureFlag, isFeatureEnabled } = vi.hoisted(() => ({ + getFeatureFlag: vi.fn(), + isFeatureEnabled: vi.fn(), +})); + +vi.mock('posthog-node', () => ({ + PostHog: class { + getFeatureFlag = getFeatureFlag; + isFeatureEnabled = isFeatureEnabled; + }, +})); + +describe('PostHogNodeAnalyticsProvider', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('suppresses feature flag events while capture is disabled', async () => { + const provider = new PostHogNodeAnalyticsProvider( + { + enabled: true, + key: 'test-key', + }, + 'test', + ); + const groups = { company: 'company-id' }; + + provider.setCaptureEnabled(false); + await provider.initialize(); + await provider.isFeatureEnabled('enabled-flag', 'user-id', groups); + await provider.getFeatureFlag('variant-flag', 'user-id', groups); + + expect(isFeatureEnabled).toHaveBeenCalledWith('enabled-flag', 'user-id', { + groups, + sendFeatureFlagEvents: false, + }); + expect(getFeatureFlag).toHaveBeenCalledWith('variant-flag', 'user-id', { + groups, + sendFeatureFlagEvents: false, + }); + + provider.setCaptureEnabled(true); + await provider.getFeatureFlag('variant-flag', 'user-id', groups); + + expect(getFeatureFlag).toHaveBeenLastCalledWith('variant-flag', 'user-id', { + groups, + sendFeatureFlagEvents: true, + }); + }); +}); diff --git a/src/providers/posthog-node.ts b/src/providers/posthog-node.ts index 438ad92..8545bd2 100644 --- a/src/providers/posthog-node.ts +++ b/src/providers/posthog-node.ts @@ -50,7 +50,13 @@ export class PostHogNodeAnalyticsProvider extends BaseAnalytics { } async track(event: AnalyticsEvent): Promise { - if (!this.isEnabled() || !this.initialized || !this.client || !this.validateEvent(event)) { + if ( + !this.isEnabled() || + !this.isCaptureEnabled() || + !this.initialized || + !this.client || + !this.validateEvent(event) + ) { return; } @@ -71,7 +77,7 @@ export class PostHogNodeAnalyticsProvider extends BaseAnalytics { } async identify(userId: string, properties?: Record): Promise { - if (!this.isEnabled() || !this.initialized || !this.client) { + if (!this.isEnabled() || !this.isCaptureEnabled() || !this.initialized || !this.client) { return; } @@ -90,7 +96,7 @@ export class PostHogNodeAnalyticsProvider extends BaseAnalytics { } async trackPageView(page: string, properties?: Record): Promise { - if (!this.isEnabled() || !this.initialized || !this.client) { + if (!this.isEnabled() || !this.isCaptureEnabled() || !this.initialized || !this.client) { return; } @@ -136,7 +142,10 @@ export class PostHogNodeAnalyticsProvider extends BaseAnalytics { } try { - const result = await this.client.isFeatureEnabled(flag, distinctId, groups); + const result = await this.client.isFeatureEnabled(flag, distinctId, { + groups, + sendFeatureFlagEvents: this.isCaptureEnabled(), + }); return Boolean(result); } catch (error) { this.logError(`Failed to check feature flag: ${flag}`, error); @@ -157,7 +166,10 @@ export class PostHogNodeAnalyticsProvider extends BaseAnalytics { } try { - return await this.client.getFeatureFlag(flag, distinctId, groups); + return await this.client.getFeatureFlag(flag, distinctId, { + groups, + sendFeatureFlagEvents: this.isCaptureEnabled(), + }); } catch (error) { this.logError(`Failed to get feature flag: ${flag}`, error); return undefined; @@ -237,7 +249,7 @@ export class PostHogNodeAnalyticsProvider extends BaseAnalytics { groupKey: string, properties?: Record, ): Promise { - if (!this.isEnabled() || !this.initialized || !this.client) { + if (!this.isEnabled() || !this.isCaptureEnabled() || !this.initialized || !this.client) { return; } @@ -260,7 +272,7 @@ export class PostHogNodeAnalyticsProvider extends BaseAnalytics { * Create alias between user IDs */ async alias(distinctId: string, alias: string): Promise { - if (!this.isEnabled() || !this.initialized || !this.client) { + if (!this.isEnabled() || !this.isCaptureEnabled() || !this.initialized || !this.client) { return; } diff --git a/src/providers/posthog.test.ts b/src/providers/posthog.test.ts index d5131c2..57beaff 100644 --- a/src/providers/posthog.test.ts +++ b/src/providers/posthog.test.ts @@ -5,15 +5,21 @@ import { PostHogAnalyticsProvider } from './posthog'; vi.mock('posthog-js', () => ({ posthog: { + capture: vi.fn(), init: vi.fn(), + opt_in_capturing: vi.fn(), + opt_out_capturing: vi.fn(), }, })); describe('PostHogAnalyticsProvider', () => { + const capture = vi.mocked(posthog.capture); const init = vi.mocked(posthog.init); + const optInCapturing = vi.mocked(posthog.opt_in_capturing); + const optOutCapturing = vi.mocked(posthog.opt_out_capturing); beforeEach(() => { - init.mockClear(); + vi.clearAllMocks(); }); it('defaults pageview capture to history changes for SPA navigation', async () => { @@ -54,4 +60,45 @@ describe('PostHogAnalyticsProvider', () => { }), ); }); + + it('starts opted out and suppresses events when capture is disabled', async () => { + const provider = new PostHogAnalyticsProvider( + { + enabled: true, + key: 'test-key', + }, + 'test', + ); + + provider.setCaptureEnabled(false); + await provider.initialize(); + await provider.track({ name: 'private_event' }); + + expect(init).toHaveBeenCalledWith( + 'test-key', + expect.objectContaining({ + opt_out_capturing_by_default: true, + opt_out_persistence_by_default: true, + }), + ); + expect(optOutCapturing).toHaveBeenCalledOnce(); + expect(capture).not.toHaveBeenCalled(); + }); + + it('synchronizes capture changes with the native PostHog consent API', async () => { + const provider = new PostHogAnalyticsProvider( + { + enabled: true, + key: 'test-key', + }, + 'test', + ); + + await provider.initialize(); + provider.setCaptureEnabled(false); + provider.setCaptureEnabled(true); + + expect(optOutCapturing).toHaveBeenCalledOnce(); + expect(optInCapturing).toHaveBeenCalledWith({ captureEventName: false }); + }); }); diff --git a/src/providers/posthog.ts b/src/providers/posthog.ts index 011d20b..41416f9 100644 --- a/src/providers/posthog.ts +++ b/src/providers/posthog.ts @@ -1,4 +1,5 @@ -import { BeforeSendFn, CaptureResult, posthog } from 'posthog-js'; +import { posthog } from 'posthog-js'; +import type { BeforeSendFn, CaptureResult } from 'posthog-js'; import { BaseAnalytics } from '@/base'; import type { AnalyticsEvent, PostHogProviderAnalyticsConfig } from '@/types'; @@ -27,22 +28,29 @@ export class PostHogAnalyticsProvider extends BaseAnalytics { try { // Extract provider-specific properties and prepare posthog config - const { key, host, ...posthogConfig } = this.config; + const { debug, enabled, host, key, ...posthogConfig } = this.config; + void debug; + void enabled; // Build init config: start with user's posthog config, then apply our defaults/overrides const initConfig = { ...posthogConfig, // User's posthog-js config options api_host: host || posthogConfig.api_host || 'https://app.posthog.com', - capture_pageview: posthogConfig.capture_pageview ?? 'history_change', // Use before_send to dynamically add business context to all events before_send: this.createBeforeSendHandler(posthogConfig.before_send), + capture_pageview: posthogConfig.capture_pageview ?? 'history_change', debug: this.debug, loaded: () => this.log('PostHog loaded and ready'), + opt_out_capturing_by_default: + !this.isCaptureEnabled() || posthogConfig.opt_out_capturing_by_default, + opt_out_persistence_by_default: + !this.isCaptureEnabled() || posthogConfig.opt_out_persistence_by_default, }; posthog.init(key, initConfig); this.initialized = true; + this.syncCaptureState(); this.log('PostHog initialized successfully'); this.log(`Using before_send to add business context: ${this.business}`); } catch (error) { @@ -52,7 +60,12 @@ export class PostHogAnalyticsProvider extends BaseAnalytics { } async track(event: AnalyticsEvent): Promise { - if (!this.isEnabled() || !this.initialized || !this.validateEvent(event)) { + if ( + !this.isEnabled() || + !this.isCaptureEnabled() || + !this.initialized || + !this.validateEvent(event) + ) { return; } @@ -71,7 +84,7 @@ export class PostHogAnalyticsProvider extends BaseAnalytics { } async identify(userId: string, properties?: Record): Promise { - if (!this.isEnabled() || !this.initialized) { + if (!this.isEnabled() || !this.isCaptureEnabled() || !this.initialized) { return; } @@ -85,7 +98,7 @@ export class PostHogAnalyticsProvider extends BaseAnalytics { } async trackPageView(page: string, properties?: Record): Promise { - if (!this.isEnabled() || !this.initialized) { + if (!this.isEnabled() || !this.isCaptureEnabled() || !this.initialized) { return; } @@ -115,6 +128,11 @@ export class PostHogAnalyticsProvider extends BaseAnalytics { } } + override setCaptureEnabled(enabled: boolean): void { + super.setCaptureEnabled(enabled); + this.syncCaptureState(); + } + /** * Check if feature flag is enabled */ @@ -239,4 +257,17 @@ export class PostHogAnalyticsProvider extends BaseAnalytics { getCurrentBusiness(): string { return this.business; } + + private syncCaptureState(): void { + const captureEnabled = this.getCaptureEnabled(); + if (!this.initialized || captureEnabled === undefined) { + return; + } + + if (captureEnabled) { + posthog.opt_in_capturing({ captureEventName: false }); + } else { + posthog.opt_out_capturing(); + } + } } diff --git a/src/providers/xads.test.ts b/src/providers/xads.test.ts index e0e72a7..3bdf8e5 100644 --- a/src/providers/xads.test.ts +++ b/src/providers/xads.test.ts @@ -3,6 +3,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { XAdsAnalyticsProvider } from './xads'; +const xAdsScriptSelector = 'script[src="https://static.ads-twitter.com/uwt.js"]'; + const getQueuedCommands = () => { return ((window.twq?.queue ?? []) as unknown[][]).map((args) => [...args]); }; @@ -40,6 +42,27 @@ describe('XAdsAnalyticsProvider', () => { expect(configCommands).toEqual([['config', 'tw-pixel_123']]); }); + it('should defer loading the pixel until capture is enabled', async () => { + const provider = new XAdsAnalyticsProvider( + { + enabled: true, + pixelId: 'tw-pixel_123', + }, + 'test', + ); + + provider.setCaptureEnabled(false); + await provider.initialize(); + + expect(document.querySelector(xAdsScriptSelector)).toBeNull(); + expect(window.twq).toBeUndefined(); + + provider.setCaptureEnabled(true); + + expect(document.querySelector(xAdsScriptSelector)).not.toBeNull(); + expect(getQueuedCommands()).toContainEqual(['config', 'tw-pixel_123']); + }); + it('should track purchase events with mapped parameters', async () => { const provider = new XAdsAnalyticsProvider( { diff --git a/src/providers/xads.ts b/src/providers/xads.ts index 622e8f9..7b8b3c2 100644 --- a/src/providers/xads.ts +++ b/src/providers/xads.ts @@ -43,6 +43,7 @@ declare global { */ export class XAdsAnalyticsProvider extends BaseAnalytics { private readonly config: XAdsProviderAnalyticsConfig; + private initializationRequested = false; private initialized = false; constructor(config: XAdsProviderAnalyticsConfig, business: string) { @@ -59,6 +60,13 @@ export class XAdsAnalyticsProvider extends BaseAnalytics { return; } + this.initializationRequested = true; + + if (!this.isCaptureEnabled()) { + this.log('Deferring X Ads initialization until capture is enabled'); + return; + } + if (typeof window === 'undefined') { this.logError('X Ads provider requires browser environment'); return; @@ -90,7 +98,12 @@ export class XAdsAnalyticsProvider extends BaseAnalytics { } async track(event: AnalyticsEvent): Promise { - if (!this.isEnabled() || !this.initialized || !this.validateEvent(event)) { + if ( + !this.isEnabled() || + !this.isCaptureEnabled() || + !this.initialized || + !this.validateEvent(event) + ) { return; } @@ -137,6 +150,22 @@ export class XAdsAnalyticsProvider extends BaseAnalytics { this.log('Reset is not supported in X Ads provider'); } + override setCaptureEnabled(enabled: boolean): void { + const previousCaptureEnabled = this.getCaptureEnabled(); + super.setCaptureEnabled(enabled); + + if ( + enabled && + previousCaptureEnabled === false && + this.initializationRequested && + !this.initialized + ) { + void this.initialize().catch((error) => { + this.logError('Failed to initialize X Ads after enabling capture', error); + }); + } + } + private ensureScript(state: XAdsGlobalState) { if (state.scriptRequested || document.querySelector(X_ADS_SCRIPT_SELECTOR)) { state.scriptRequested = true; diff --git a/src/react/index.ts b/src/react/index.ts index a35c958..691a7bb 100644 --- a/src/react/index.ts +++ b/src/react/index.ts @@ -1,4 +1,5 @@ // React Provider & Hooks +export type { AnalyticsProviderProps } from './provider'; export { AnalyticsProvider, useAnalytics, diff --git a/src/react/provider.tsx b/src/react/provider.tsx index 58e791d..b175bf3 100644 --- a/src/react/provider.tsx +++ b/src/react/provider.tsx @@ -23,9 +23,11 @@ const AnalyticsContext = createContext(undefi /** * Analytics Provider Props */ -interface AnalyticsProviderProps { +export interface AnalyticsProviderProps { /** 是否自动初始化(默认: true) */ autoInitialize?: boolean; + /** 是否允许采集;传入 false 会在初始化前进入 opt-out 状态 */ + captureEnabled?: boolean; /** 子组件 */ children: ReactNode; /** 配置好的 Analytics 实例 */ @@ -73,6 +75,7 @@ export function AnalyticsProvider({ client, children, autoInitialize = true, + captureEnabled, globalName = '__default__', onInitializeError, onInitializeSuccess, @@ -89,6 +92,13 @@ export function AnalyticsProvider({ } }, [client, globalName, registerGlobal]); + // 在初始化 provider 前同步同意状态,避免自动采集抢跑。 + useLayoutEffect(() => { + if (captureEnabled !== undefined) { + client.setCaptureEnabled(captureEnabled); + } + }, [captureEnabled, client]); + useLayoutEffect(() => { if (!autoInitialize || isInitialized || isInitializing) { return; diff --git a/src/server.ts b/src/server.ts index f65e6eb..fbabff3 100644 --- a/src/server.ts +++ b/src/server.ts @@ -34,7 +34,7 @@ export { * Create server analytics with full provider support including posthog-node */ export function createServerAnalytics(config: AnalyticsConfig): AnalyticsManager { - const manager = new AnalyticsManager(config.business, config.debug); + const manager = new AnalyticsManager(config.business, config.debug, config.captureEnabled); // Register PostHog browser if enabled if (config.providers.posthog?.enabled) { diff --git a/src/types.ts b/src/types.ts index 08c0ba1..57baf6a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -64,15 +64,13 @@ export interface ProviderConfig { } export interface PostHogProviderAnalyticsConfig - extends Partial>, - ProviderConfig { + extends Partial>, ProviderConfig { host?: string; key: string; } export interface PostHogNodeProviderAnalyticsConfig - extends Partial, - ProviderConfig { + extends Partial, ProviderConfig { key: string; } @@ -104,6 +102,13 @@ export interface XAdsProviderAnalyticsConfig extends ProviderConfig { // Main analytics configuration export interface AnalyticsConfig { business: string; + /** + * Whether analytics capture is allowed. + * + * Leave undefined to preserve the provider's existing consent state. + * Set to false before initialization to start providers in an opted-out state. + */ + captureEnabled?: boolean; debug?: boolean; providers: { ga4?: GoogleAnalyticsProviderConfig;