Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 44 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
<AnalyticsProvider captureEnabled={hasAnalyticsConsent} client={analytics}>
<App />
</AnalyticsProvider>
```

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):**
Expand Down Expand Up @@ -389,8 +430,9 @@ Main class for managing analytics providers.
- `identify(userId: string, properties?): Promise<void>` - Identify user
- `trackPageView(page: string, properties?): Promise<void>` - Track page view
- `reset(): Promise<void>` - 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

Expand Down Expand Up @@ -434,6 +476,7 @@ getGlobalAnalyticsNames(): string[]
<AnalyticsProvider
client={AnalyticsManager}
autoInitialize?: boolean // Default: true
captureEnabled?: boolean // Synchronize capture consent before initialization
registerGlobal?: boolean // Default: true
globalName?: string // Default: '__default__'
>
Expand Down
33 changes: 33 additions & 0 deletions src/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
*/
Expand All @@ -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
*/
Expand Down
2 changes: 1 addition & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
23 changes: 23 additions & 0 deletions src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
57 changes: 52 additions & 5 deletions src/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,25 @@ import type { AnalyticsEvent, EventContext, PredefinedEvents, ProviderTypeMap }
export class AnalyticsManager {
private readonly providers = new Map<string, BaseAnalytics>();
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;
Expand Down Expand Up @@ -74,7 +80,7 @@ export class AnalyticsManager {
* 追踪事件到所有提供商
*/
async track(event: AnalyticsEvent): Promise<void> {
if (!this.ensureInitialized()) return;
if (!this.ensureCaptureEnabled()) return;

const enrichedEvent = this.enrichEvent(event);
await this.executeOnAllProviders('track', enrichedEvent);
Expand All @@ -97,7 +103,7 @@ export class AnalyticsManager {
* 识别用户
*/
async identify(userId: string, properties?: Record<string, any>): Promise<void> {
if (!this.ensureInitialized()) return;
if (!this.ensureCaptureEnabled()) return;
const mergedProperties = { ...this.globalContext, ...properties };
await this.executeOnAllProviders('identify', userId, mergedProperties);
}
Expand All @@ -106,7 +112,7 @@ export class AnalyticsManager {
* 追踪页面浏览
*/
async trackPageView(page: string, properties?: Record<string, any>): Promise<void> {
if (!this.ensureInitialized()) return;
if (!this.ensureCaptureEnabled()) return;
const mergedProperties = { ...this.globalContext, ...properties };
await this.executeOnAllProviders('trackPageView', page, mergedProperties);
}
Expand All @@ -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;
}

/**
* 设置全局上下文
*/
Expand All @@ -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,
};
Expand All @@ -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;
}

/**
* 在所有提供商上执行操作
*/
Expand Down
68 changes: 68 additions & 0 deletions src/providers/ga4.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading
Loading