Skip to content
Open
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
4 changes: 4 additions & 0 deletions scripts/mintlify-post-processing/appended-articles.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
{
"interfaces/AnalyticsModule": [
"type-aliases/AnalyticsConsentStatus",
"type-aliases/CreateClientAnalyticsOptions"
],
"interfaces/ConnectorsModule": [
"type-aliases/ConnectorIntegrationType",
"interfaces/ConnectorIntegrationTypeRegistry",
Expand Down
2 changes: 2 additions & 0 deletions scripts/mintlify-post-processing/types-to-expose.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
"AgentsModule",
"AiGatewayConnection",
"AiGatewayModule",
"AnalyticsConsentStatus",
"AnalyticsModule",
"CreateClientAnalyticsOptions",
"AppLogsModule",
"AuthModule",
"ConnectorApiRequest",
Expand Down
2 changes: 2 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export function createClient(config: CreateClientConfig): Base44Client {
requiresAuth = false,
appBaseUrl,
options,
analytics: analyticsOptions,
functionsVersion,
headers: optionalHeaders,
} = config;
Expand Down Expand Up @@ -246,6 +247,7 @@ export function createClient(config: CreateClientConfig): Base44Client {
serverUrl,
appId,
userAuthModule,
options: analyticsOptions,
}),
actors: actorsModule.module,
cleanup: () => {
Expand Down
21 changes: 20 additions & 1 deletion src/client.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ import type { FunctionsModule } from "./modules/functions.types.js";
import type { AgentsModule } from "./modules/agents.types.js";
import type { AiGatewayModule } from "./modules/ai-gateway.types.js";
import type { AppLogsModule } from "./modules/app-logs.types.js";
import type { AnalyticsModule } from "./modules/analytics.types.js";
import type {
AnalyticsModule,
CreateClientAnalyticsOptions,
} from "./modules/analytics.types.js";
import type { ActorsModule } from "./modules/actors.types.js";

/**
Expand Down Expand Up @@ -87,6 +90,22 @@ export interface CreateClientConfig {
* @internal
*/
headers?: Record<string, string>;
/**
* Analytics configuration for this client.
*
* By default, analytics is enabled and starts as soon as the client is created: a persistent visitor ID is stored in `localStorage` and automatic events are sent.
*
* Set `consent: "pending"` to keep analytics dormant until the visitor makes a consent decision, then call {@linkcode AnalyticsModule.optIn | analytics.optIn()} or {@linkcode AnalyticsModule.optOut | analytics.optOut()}. Set `enabled: false` to turn the analytics module off entirely.
*
* @example
* ```typescript
* const base44 = createClient({
* appId: 'my-app-id',
* analytics: { consent: 'pending' }
* });
* ```
*/
analytics?: CreateClientAnalyticsOptions;
/**
* Additional client options.
*/
Expand Down
156 changes: 143 additions & 13 deletions src/modules/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import {
AnalyticsApiBatchRequest,
TrackEventIntrinsicData,
AnalyticsModuleOptions,
AnalyticsConsentStatus,
CreateClientAnalyticsOptions,
SessionContext,
} from "./analytics.types";
import { getSharedInstance } from "../utils/sharedInstance.js";
Expand Down Expand Up @@ -48,6 +50,9 @@ const analyticsSharedState = getSharedInstance(
// Memoized session id for when `localStorage` can't persist one — see
// getAnalyticsSessionId.
fallbackSessionId: null as string | null,
// Consent status shared by every client on the page. `null` means no
// client set one explicitly, which keeps the legacy behavior (granted).
consent: null as AnalyticsConsentStatus | null,
config: {
...defaultConfiguration,
...getAnalyticsConfigFromUrlParams(),
Expand All @@ -62,24 +67,87 @@ export interface AnalyticsModuleArgs {
serverUrl: string;
appId: string;
userAuthModule: InternalAuthModule;
options?: CreateClientAnalyticsOptions;
}

// Lower ranks are more restrictive. Used to merge the consent status of
// multiple clients created on the same page: the shared state (and therefore
// the shared persistent id) can only honor one status, so the most
// restrictive explicitly-configured one wins.
const CONSENT_RESTRICTIVENESS: Record<AnalyticsConsentStatus, number> = {
denied: 0,
pending: 1,
granted: 2,
};

function applyInitialConsent(consent: AnalyticsConsentStatus | undefined) {
if (!consent) return;
const current = analyticsSharedState.consent;
if (
current === null ||
CONSENT_RESTRICTIVENESS[consent] < CONSENT_RESTRICTIVENESS[current]
) {
analyticsSharedState.consent = consent;
}
}

/**
* The effective analytics consent status. `"granted"` when no client set one
* explicitly, preserving the legacy always-on behavior.
*
* @internal
*/
export function getAnalyticsConsentStatus(): AnalyticsConsentStatus {
return analyticsSharedState.consent ?? "granted";
}

function clearPersistedAnalyticsSessionId() {
if (typeof window === "undefined") return;
try {
localStorage.removeItem(ANALYTICS_SESSION_ID_LOCAL_STORAGE_KEY);
} catch {
// Storage unavailable — nothing was persisted, so nothing to clear.
}
}

export const createAnalyticsModule = ({
axiosClient,
serverUrl,
appId,
userAuthModule,
options,
}: AnalyticsModuleArgs) => {
// Consent gates more than this module: getAnalyticsSessionId() also backs
// the anonymous-id HTTP header and the socket handshake, so the client's
// consent choice must be recorded even when the early returns below make
// the module itself a no-op.
applyInitialConsent(options?.consent);

// prevent overflow of events //
const { maxQueueSize, throttleTime, batchSize } = analyticsSharedState.config;

// Disable analytics on React Native. It defines `window` but not `document`,
// so the per-callsite `typeof window` guards below aren't enough to keep it
// from touching `document` (e.g. `document.referrer` on init). Node/SSR is
// still handled by those `window` guards, so this doesn't affect it.
if (!analyticsSharedState.config?.enabled || isReactNative) {
if (
!analyticsSharedState.config?.enabled ||
options?.enabled === false ||
isReactNative
) {
return {
track: () => {},
// Consent still matters with the event pipeline off: it decides whether
// the persistent id may back the anonymous-id header and socket
// handshake, so opting in/out has to work here too.
optIn: () => {
analyticsSharedState.consent = "granted";
},
optOut: () => {
analyticsSharedState.consent = "denied";
clearPersistedAnalyticsSessionId();
},
getConsentStatus: getAnalyticsConsentStatus,
cleanup: () => {},
};
}
Expand Down Expand Up @@ -138,6 +206,12 @@ export const createAnalyticsModule = ({
};

const track = (params: TrackEventParams) => {
const consent = getAnalyticsConsentStatus();
// Denied: drop. Pending: buffer in memory (no network, no storage) so the
// events can be delivered if the visitor opts in later.
if (consent === "denied") {
return;
}
if (analyticsSharedState.requestsQueue.length >= maxQueueSize) {
return;
}
Expand All @@ -146,7 +220,9 @@ export const createAnalyticsModule = ({
...params,
...intrinsicData,
});
startProcessing();
if (consent === "granted") {
startProcessing();
}
};

const onDocVisible = () => {
Expand Down Expand Up @@ -177,27 +253,71 @@ export const createAnalyticsModule = ({
}
};

const cleanup = () => {
// Everything with a side effect beyond this module — the persistent id,
// automatic events, timers, network — starts in activate(), so a client
// created with consent "pending" or "denied" stays fully dormant until the
// visitor opts in.
let isActive = false;

const activate = () => {
if (isActive) return;
isActive = true;
// start the flusing process ///
startProcessing();
// start the heart beat processor //
clearHeartBeatProcessor = startHeartBeatProcessor(track);
// track the referrer event //
trackInitializationEvent(track);
// start the visibility change listener //
if (typeof window !== "undefined") {
window.addEventListener("visibilitychange", onVisibilityChange);
}
};

const deactivate = () => {
if (!isActive) return;
isActive = false;
stopAnalyticsProcessor();
clearHeartBeatProcessor?.();
clearHeartBeatProcessor = undefined;
if (typeof window !== "undefined") {
window.removeEventListener("visibilitychange", onVisibilityChange);
}
};

// start the flusing process ///
startProcessing();
// start the heart beat processor //
clearHeartBeatProcessor = startHeartBeatProcessor(track);
// track the referrer event //
trackInitializationEvent(track);
// start the visibility change listener //
if (typeof window !== "undefined") {
window.addEventListener("visibilitychange", onVisibilityChange);
const optIn = () => {
analyticsSharedState.consent = "granted";
// Persist the id now rather than on the next event: this adopts the
// ephemeral pre-consent id (see getAnalyticsSessionId), keeping the
// visitor's identity continuous across the consent grant.
getAnalyticsSessionId();
activate();
};

const optOut = () => {
analyticsSharedState.consent = "denied";
deactivate();
// Drop anything buffered while consent was pending, and forget the
// identity: both the persisted id and the memoized session context.
analyticsSharedState.requestsQueue.length = 0;
analyticsSharedState.sessionStartTime = null;
resetAnalyticsSessionContext();
clearPersistedAnalyticsSessionId();
};

const cleanup = () => {
deactivate();
};

if (getAnalyticsConsentStatus() === "granted") {
activate();
}

return {
track,
optIn,
optOut,
getConsentStatus: getAnalyticsConsentStatus,
cleanup,
};
};
Expand Down Expand Up @@ -409,12 +529,22 @@ export function getAnalyticsSessionId(): string {
if (typeof window === "undefined") {
return getFallbackSessionId();
}
// Until consent is granted, never read or write the persistent id — hand out
// a per-page-load ephemeral id instead. The anonymous-id HTTP header and the
// socket handshake resolve their id through here too, so this single gate
// covers every place a persistent identifier could be minted pre-consent.
if (getAnalyticsConsentStatus() !== "granted") {
return getFallbackSessionId();
}
try {
const sessionId = localStorage.getItem(
ANALYTICS_SESSION_ID_LOCAL_STORAGE_KEY
);
if (!sessionId) {
const newSessionId = generateUuid();
// Adopt the ephemeral pre-consent id when one was handed out, so the
// visitor keeps a single identity across the consent grant.
const newSessionId =
analyticsSharedState.fallbackSessionId ?? generateUuid();
localStorage.setItem(
ANALYTICS_SESSION_ID_LOCAL_STORAGE_KEY,
newSessionId
Expand Down
Loading
Loading