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;