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
22 changes: 21 additions & 1 deletion packages/audience/core/src/consent.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { createConsentManager } from './consent';
import {
createConsentManager, canTrack, canIdentify,
} from './consent';
import type { HttpSend } from './transport';
import { TransportError } from './errors';

Expand All @@ -25,6 +27,24 @@ beforeEach(() => {
jest.clearAllMocks();
});

describe('consent capability queries', () => {
it.each([
['none', false],
['anonymous', true],
['full', true],
] as const)('canTrack(%s) returns %s', (level, expected) => {
expect(canTrack(level)).toBe(expected);
});

it.each([
['none', false],
['anonymous', false],
['full', true],
] as const)('canIdentify(%s) returns %s', (level, expected) => {
expect(canIdentify(level)).toBe(expected);
});
});

describe('createConsentManager', () => {
it('defaults to none when no initial level provided', () => {
const queue = createMockQueue();
Expand Down
8 changes: 8 additions & 0 deletions packages/audience/core/src/consent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ export interface ConsentManager {
setLevel(next: ConsentLevel): void;
}

export function canTrack(level: ConsentLevel): boolean {
return level !== 'none';
}

export function canIdentify(level: ConsentLevel): boolean {
return level === 'full';
}

export function detectDoNotTrack(): boolean {
if (typeof navigator === 'undefined') return false;
// DNT header
Expand Down
5 changes: 4 additions & 1 deletion packages/audience/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,10 @@ export type { SessionResult } from './session';
export { collectAttribution } from './attribution';
export type { Attribution } from './attribution';

export { createConsentManager, detectDoNotTrack } from './consent';
export {
createConsentManager, detectDoNotTrack,
canTrack, canIdentify,
} from './consent';
export type { ConsentManager } from './consent';

export { detectCmp, startCmpDetection } from './cmp';
Expand Down
7 changes: 4 additions & 3 deletions packages/audience/pixel/src/autocapture.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ConsentLevel } from '@imtbl/audience-core';
import { canTrack, canIdentify } from '@imtbl/audience-core';

export interface AutocaptureOptions {
/** Enable form submission auto-capture. Default: true */
Expand Down Expand Up @@ -68,7 +69,7 @@ export function setupAutocapture(

if (options.forms !== false) {
const onSubmit = (e: Event): void => {
if (getConsent() === 'none') return;
if (!canTrack(getConsent())) return;

const form = e.target as HTMLFormElement;
if (!form || form.tagName !== 'FORM') return;
Expand All @@ -81,7 +82,7 @@ export function setupAutocapture(
};

const consent = getConsent();
if (consent === 'full') {
if (canIdentify(consent)) {
const email = findEmail(form);
if (email) {
// Hash before enqueuing — raw email never enters the queue.
Expand Down Expand Up @@ -109,7 +110,7 @@ export function setupAutocapture(

if (options.clicks !== false) {
const onClick = (e: Event): void => {
if (getConsent() === 'none') return;
if (!canTrack(getConsent())) return;

const target = e.target as HTMLElement;
const anchor = target.closest?.('a') as HTMLAnchorElement | null;
Expand Down
1 change: 1 addition & 0 deletions packages/audience/pixel/src/pixel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ jest.mock('@imtbl/audience-core', () => ({
};
},
),
canTrack: jest.fn().mockImplementation((level: string) => level !== 'none'),
}));

// Mock fetch globally
Expand Down
21 changes: 11 additions & 10 deletions packages/audience/pixel/src/pixel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
collectAttribution,
getOrCreateSession,
createConsentManager,
canTrack,
startCmpDetection,
} from '@imtbl/audience-core';
import { setupAutocapture } from './autocapture';
Expand Down Expand Up @@ -124,7 +125,7 @@ export class Pixel {
if (isAutoConsent) {
// CMP detection will fire the deferred page view when consent upgrades.
this.startCmpDetection();
} else if (this.consent.level !== 'none') {
} else if (canTrack(this.consent.level)) {
// Static consent — fire page view immediately.
this.initialPageViewFired = true;
this.page();
Expand All @@ -139,7 +140,7 @@ export class Pixel {
}

page(properties?: Record<string, unknown>): void {
if (!this.canTrack()) return;
if (!this.isTrackingAllowed()) return;

const { sessionId, isNew } = getOrCreateSession(this.domain);
this.refreshSession(sessionId, isNew);
Expand Down Expand Up @@ -168,7 +169,7 @@ export class Pixel {
// Fire the deferred page view if consent was upgraded from 'none'
// (covers the case where CMP detection failed and the caller
// manually sets consent as a fallback).
if (level !== 'none' && !this.initialPageViewFired) {
if (canTrack(level) && !this.initialPageViewFired) {
this.initialPageViewFired = true;
this.page();
}
Expand Down Expand Up @@ -200,7 +201,7 @@ export class Pixel {
this.consent!.setLevel(level);

// Fire the deferred page view on first consent upgrade from 'none'.
if (level !== 'none' && !this.initialPageViewFired) {
if (canTrack(level) && !this.initialPageViewFired) {
this.initialPageViewFired = true;
this.page();
}
Expand All @@ -210,7 +211,7 @@ export class Pixel {
onCmpUpdate,
(detector: CmpDetector) => {
// CMP found — apply the initial consent level it reported.
if (detector.level !== 'none') {
if (canTrack(detector.level)) {
onCmpUpdate(detector.level);
}
},
Expand All @@ -220,7 +221,7 @@ export class Pixel {
// -- Auto-capture helper --------------------------------------------------

private track(eventName: string, properties: Record<string, unknown>): void {
if (!this.canTrack()) return;
if (!this.isTrackingAllowed()) return;

const { sessionId, isNew } = getOrCreateSession(this.domain);
this.refreshSession(sessionId, isNew);
Expand All @@ -247,7 +248,7 @@ export class Pixel {
}

private fireSessionStart(sessionId: string): void {
if (!this.canTrack()) return;
if (!this.isTrackingAllowed()) return;

const message: TrackMessage = {
...this.buildBase(),
Expand All @@ -261,7 +262,7 @@ export class Pixel {
}

private fireSessionEnd(): void {
if (!this.canTrack() || !this.sessionId) return;
if (!this.isTrackingAllowed() || !this.sessionId) return;

const duration = this.sessionStartTime
? Math.round((Date.now() - this.sessionStartTime) / 1000)
Expand Down Expand Up @@ -337,8 +338,8 @@ export class Pixel {

// -- Guards -------------------------------------------------------------

private canTrack(): boolean {
return this.isReady() && this.consent!.level !== 'none';
private isTrackingAllowed(): boolean {
return this.isReady() && canTrack(this.consent!.level);
}

private isReady(): boolean {
Expand Down
4 changes: 2 additions & 2 deletions packages/audience/pixel/src/snippet.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import type { Environment } from '@imtbl/audience-core';
import type { ConsentLevel, Environment } from '@imtbl/audience-core';

const DEFAULT_CDN_URL = 'https://cdn.immutable.com/pixel/v1/imtbl.js';

export interface SnippetOptions {
key: string;
cdnUrl?: string;
consent?: 'none' | 'anonymous' | 'full';
consent?: ConsentLevel;
environment?: Environment;
}

Expand Down
2 changes: 1 addition & 1 deletion packages/audience/sdk/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export { Audience } from './sdk';
export { IdentityType } from '@imtbl/audience-core';
export { IdentityType, canTrack, canIdentify } from '@imtbl/audience-core';
export type { AudienceConfig } from './types';
export type { Environment, ConsentLevel, UserTraits } from '@imtbl/audience-core';
22 changes: 12 additions & 10 deletions packages/audience/sdk/src/sdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ import {
collectAttribution,
getOrCreateSession,
createConsentManager,
canTrack,
canIdentify,
SESSION_START,
SESSION_END,
} from '@imtbl/audience-core';
Expand Down Expand Up @@ -82,7 +84,7 @@ export class Audience {
this.debug = new DebugLogger(config.debug ?? false);

let isNewSession = false;
if (consentLevel !== 'none') {
if (canTrack(consentLevel)) {
this.anonymousId = getOrCreateAnonymousId(cookieDomain);
isNewSession = this.startSession();
} else {
Expand Down Expand Up @@ -142,14 +144,14 @@ export class Audience {

// --- Helpers ---

/** True when consent is 'none' — SDK should not enqueue anything. */
/** True when the current consent level does not permit tracking. */
private isTrackingDisabled(): boolean {
return this.consent.level === 'none';
return !canTrack(this.consent.level);
}

/** Returns userId if consent is full, undefined otherwise. */
private effectiveUserId(): string | undefined {
return this.consent.level === 'full' ? this.userId : undefined;
return canIdentify(this.consent.level) ? this.userId : undefined;
}

/** Create or resume a session, returning whether it's new. */
Expand Down Expand Up @@ -274,7 +276,7 @@ export class Audience {
identityType?: IdentityType,
traits?: UserTraits,
): void {
if (this.consent.level !== 'full') {
if (!canIdentify(this.consent.level)) {
this.debug.logWarning('identify() requires full consent — call ignored.');
return;
}
Expand Down Expand Up @@ -314,7 +316,7 @@ export class Audience {
from: { id: string; identityType: IdentityType },
to: { id: string; identityType: IdentityType },
): void {
if (this.consent.level !== 'full') {
if (!canIdentify(this.consent.level)) {
this.debug.logWarning('alias() requires full consent — call ignored.');
return;
}
Expand Down Expand Up @@ -350,7 +352,7 @@ export class Audience {

this.debug.logConsent(previous, level);

const isUpgradeFromNone = previous === 'none' && level !== 'none';
const isUpgradeFromNone = !canTrack(previous) && canTrack(level);

// When upgrading from none, create the persisted anonymousId first
// so the consent sync sends the correct ID to the server.
Expand All @@ -360,7 +362,7 @@ export class Audience {

// Web-specific cleanup before core handles queue purge/transform and server sync.
// session_end is intentionally not emitted — no events should be sent after opt-out.
if (level === 'none') {
if (!canTrack(level)) {
this.queue.stop();
}

Expand All @@ -369,10 +371,10 @@ export class Audience {
this.consent.setLevel(level);

// Web-specific cleanup after core's transition.
if (level === 'none') {
if (!canTrack(level)) {
deleteCookie(COOKIE_NAME, this.cookieDomain);
deleteCookie(SESSION_COOKIE, this.cookieDomain);
} else if (level === 'anonymous' && previous === 'full') {
} else if (canIdentify(previous) && !canIdentify(level)) {
this.userId = undefined;
}

Expand Down
Loading