Skip to content

Analytics

The Deepdots Popup SDK includes a built-in analytics layer that collects behavioral data from your users and forwards it to a dedicated integration in your Deepdots workspace. This lets you measure engagement, navigation patterns, and business-critical events without adding a separate analytics tool.

Add an analytics object to init() with the publicKey and integration ID of the integration created in your Deepdots workspace. Without it the SDK runs in dry-run mode — all events are logged to the console but nothing is sent.

import { DeepdotsPopups } from '@magicfeedback/popup-sdk';
const popups = new DeepdotsPopups();
popups.init({
apiKey: 'YOUR_PUBLIC_API_KEY',
analytics: {
publicKey: 'YOUR_ANALYTICS_PUBLIC_KEY',
integration: 'YOUR_INTEGRATION_ID',
},
});

The following data is collected with zero extra code as long as the SDK is initialized:

DataHowWhere it appears
Screen views (deepdots_page_view)History API (pushState / popstate / hashchange)Events
Active engagement time (deepdots_user_engagement)visibilitychange listenerEvents
Persistent user identity (user_id)Generated on first visit, stored in localStorageMetadata
Device typeParsed from User-Agent (mobile / tablet / desktop)Context
User agentnavigator.userAgentContext
Language (deepdots_language)Auto-detected (see Language detection)Context
App versionappVersion passed to init()Context

Each flush (tab hidden, page closed, or manual flushAnalytics()) sends the accumulated events as a batch. The backend groups batches by session so you see a single timeline per user visit, not one record per flush.

The language reported on the analytics context — sent as deepdots_language in the Feedback metadata — is resolved automatically, in this order:

  1. The language passed to init() — an explicit BCP-47 tag such as 'es-ES'. Set this when your app has its own i18n and you want to force the reported language.
  2. navigator.language — the browser language (web).
  3. The Intl locale (Intl.DateTimeFormat().resolvedOptions().locale) — the fallback used when navigator.language is unavailable. This is what makes detection work on React Native with Hermes, where navigator.language does not exist.
  4. If none of these resolve, the field is omitted.
popups.init({
apiKey: 'YOUR_PUBLIC_API_KEY',
analytics: { publicKey: 'YOUR_ANALYTICS_PUBLIC_KEY', integration: 'YOUR_INTEGRATION_ID' },
language: 'es-ES', // optional — force the analytics language; auto-detected when omitted
});

The resolved language is also what popup language targeting (segments.lang) is matched against, so setting language explicitly pins both at once. In React Native this requires 1.1.8 or newer — see React Native → Language segments.


A session is one continuous visit. The backend owns the session id and stitches batches together by user_id, so a visit reads as a single timeline instead of one record per flush.

Since 1.2.0 both ends of a session are signalled explicitly:

  • deepdots_session_start — on every session open. That means init(), returning to the foreground, granting consent with setTrackingEnabled(true), and after a user change. If you init with trackingEnabled: false, the first session opens when consent is granted.
  • deepdots_session_end — on close, with a reason. The closing batch is sent with completed: true, which is what tells the backend the record is finished.

The closing batch flushes everything still open, in order: the current screen’s deepdots_page_view, any pending deepdots_mini_service_exit, the accumulated deepdots_user_engagement, and finally deepdots_session_end. Nothing is left behind for a flush that will never come.

reasonWhen
page_hideThe page closes (pagehide) — web
backgroundThe app goes to background (onBackground()) — React Native
user_changesetUserId() switched the user
tracking_disabledsetTrackingEnabled(false)
manualendSession()

Closes the session explicitly. Use it at logout or at the end of a self-contained flow, when the visit is over but the page or app is not:

popups.endSession();

The next tracked event opens a new session.

Reports a user change — login, logout, or account switch. It closes the previous user’s session with reason: 'user_change', swaps the identity, and opens a new session, so the two users never share a timeline:

// Login: attribute what follows to your own user id
popups.setUserId('customer-123');
// Logout: back to the SDK's anonymous id
popups.setUserId();

Use track(name, params?) to record any business event. Event names are free-form strings — use lowercase snake_case to stay consistent with the automatic events.

popups.track('add_to_cart', { product_id: 'p-123', value: 49.9, currency: 'EUR' });
popups.track('checkout_started');
popups.track('plan_upgraded', { plan: 'pro', billing: 'annual' });

trackSearch records a search query together with the number of results. The SDK automatically adds has_results: boolean from the count.

popups.trackSearch('running shoes', 0); // no results — has_results: false
popups.trackSearch('t-shirt', 142); // has_results: true

Record moments where users struggle to find what they need:

popups.trackFindabilityFriction('checkout_address');
popups.trackFindabilityFriction('plan_comparison');

Track steps inside a named funnel. Group related steps under the same funnel and taskId so the backend can compute conversion rates:

popups.trackFunnelStep('onboarding', 'account_created', 'task-42');
popups.trackFunnelStep('onboarding', 'profile_completed', 'task-42');
popups.trackFunnelStep('onboarding', 'first_popup_seen', 'task-42');

Record a meaningful interaction — a moment that signals the user got real value out of your app. interactionType is the grouping dimension, so keep a small, stable set of names (get_help, homepage, contact_support):

popups.trackMeaningfulInteraction('get_help');
popups.trackMeaningfulInteraction('homepage', { screen: '/home' });

Each call emits a deepdots_meaningful_interaction event that powers the Effectiveness dashboard.


A mini-service is any bounded workflow inside your app (checkout flow, onboarding wizard, support chat). The SDK tracks entry, exit, and duration automatically once you signal the boundaries:

// User enters the checkout flow
popups.enterMiniService('checkout', 'home_banner');
// … user completes or abandons the flow …
// User leaves — pass the same name; duration is computed automatically
popups.exitMiniService('checkout');

Multiple mini-services can be active at once (e.g. a support chat opened during checkout). Always close each one by name so the right workflow gets its deepdots_mini_service_exit and duration:

popups.enterMiniService('checkout', 'home_banner');
popups.enterMiniService('support_chat', 'fab'); // both active now
popups.exitMiniService('checkout'); // closes checkout; support_chat stays open

Any survey shown while a mini-service is active automatically receives a mini_service metadata tag (the most recently entered one), which lets you filter CSAT results by workflow context in Deepdots.


Call setUserAttributes to attach business-level attributes to the user’s analytics context. These are included in every subsequent flush.

popups.setUserAttributes({
plan: 'pro',
registration_status: 'registered',
sector: 'retail',
});

Attributes are cumulative — each call merges with previously set ones.

setContactAttributes sends the attributes to POST /sdk/popups/contact, creating or updating the user’s contact record in Deepdots. This endpoint is only called when a userId was provided in init() and tracking is enabled.

const sent = await popups.setContactAttributes({ language: 'en', age: 34, plan: 'premium' });
// sent: true if a POST was made, false if attributes haven't changed (deduplication)

You can also pass contactAttributes directly in init() to fire the contact update on startup:

popups.init({
apiKey: 'YOUR_PUBLIC_API_KEY',
userId: 'user-123',
contactAttributes: { plan: 'premium', language: 'en' },
});

Call setMetric(key, value) to record a measurable value — a quantity you want to report alongside the user’s analytics context, such as cart value or number of items in the cart.

popups.setMetric('cart_value', 49.99);
popups.setMetric('items_in_cart', 3);

The signature is:

setMetric(key: string, value: string | number | boolean): void

Metrics land in a dedicated metrics field of the analytics payload (POST /sdk/feedback), kept separate from metadata and from user attributes.

  • Persistent — once set, the value is re-sent on every flush until it changes.
  • Overwrites by key — calling setMetric again with the same key replaces the previous value.
  • Coerced to string — the value is stored as a string on the wire (49.99"49.99").
  • Empty keys are ignored — a call with an empty key is a no-op.
  • Respects the kill-switch — it is a no-op while tracking is disabled (see Privacy and consent).

Both attach context to the user, but they answer different questions:

setUserAttributessetMetric
RepresentsDimensions to break down byMeasurable values to report
Exampleplan: 'pro', sector: 'retail'cart_value: 49.99, items_in_cart: 3
Payload fieldmetadatametrics

Use attributes for the who — the categories you filter and group by — and metrics for the how much — the quantities you measure.


Track the lifecycle of your app’s notifications (push and in-app) so Deepdots can measure delivery, click-through, and conversion per message. Use a single method, trackMessage(stage, options), at each stage of the message funnel:

// The notification was delivered (push received, or in-app message shown)
popups.trackMessage('delivered', { id: 'msg-42', title: 'Summer Sale', channel: 'push', campaign: 'summer_sale' });
// The user tapped / clicked it
popups.trackMessage('clicked', { id: 'msg-42', title: 'Summer Sale', channel: 'push' });
// The user completed the intended action (e.g. purchased)
popups.trackMessage('converted', { id: 'msg-42', title: 'Summer Sale', channel: 'push', value: 49.9, currency: 'EUR' });
FieldTypeDescription
stage (1st arg)'delivered' / 'clicked' / 'converted'Stage of the message funnel
idstringCorrelates the stages of the same message
titlestringGrouping dimension for the Messaging metrics
channel'push' / 'in_app'Delivery channel
campaignstring?Campaign name (optional)
value / currencynumber / stringConversion value (typical on converted)
paramsobject?Any extra key/value pairs

Each call emits one deepdots_message event; the backend groups by title (and breaks down by registration status / channel) to compute delivered counts, CTR, unique click-through users, conversion rate, and action users.

CTR and conversion rate are ratios over delivered. If the stages don’t line up, those metrics come out wrong — and a missing delivered produces impossible values, because the denominator is zero.

  1. Send all three stages. delivered goes out when the message reaches the device, before the user opens it — for in-app messages, when it is rendered. Without it there is no denominator.
  2. Use the same id across the three stages. It is what correlates the funnel, and it must be unique per send, not per campaign.
  3. One id, one channel. If a campaign goes out both as a push and as an in-app message, use two different id values sharing the same campaign.
  4. One call per stage. If your click handler can run through two paths — opening the notification plus a deep link — make sure only one of them emits clicked.

Starting in 1.2.0 the SDK discards calls that break these rules instead of forwarding them, and warns on the console (the warning text ships in Spanish):

[DeepdotsPopups] trackMessage descartado (channel_conflict): message_id "msg-42" ya se reportó en channel "push"; se descarta "in_app"
RuleWhat is discardedreason
channel must be push or in_appAny other valueinvalid_channel
Each (id, stage) pair is sent onceThe 2nd call to the same stage of the same messageduplicate_stage
An id keeps its channelEvents on a channel other than the first one seenchannel_conflict

The checks last for the session and are per device, and they track up to 500 message ids (oldest evicted first). A rejected call doesn’t consume state: after a channel_conflict on in_app, the same stage on the correct channel is still sent.

If these warnings show up while you integrate, they are pointing at a real double-count — fix the call site rather than ignoring them.


The SDK captures application errors and surfaces them as deepdots_app_crash events, powering the Stability metrics (crash-free users, crashes by release and device). A deepdots_session_start event is emitted on every session open so the backend can compute crash-free rates.

Unhandled errors are captured automatically — on the web via window.onerror / unhandledrejection, and in React Native via global.ErrorUtils (wired by setupReactNative). Captured crashes are persisted locally and replayed on the next launch, because the process may die before the next flush — so the crash that ended a session still reaches Deepdots.

Use reportError for handled errors, with an optional severity and free-form context:

try {
await checkout();
} catch (e) {
popups.reportError(e, { severity: 'error', context: { screen: 'Checkout', order_id: 'o-42' } });
}
OptionValuesDefault
severity'fatal' / 'error' / 'warning''error'
handledbooleantrue
contextfree-form key/value map (prefixed ctx_ in the payload)

Crash context (app version, OS, device) is captured at the moment of the crash, so a crash on an older release still reports the version it happened on.

Crash reporting respects the same consent kill-switch as the rest of analytics (trackingEnabled / setTrackingEnabled).


Set trackingEnabled: false in init() to start with all analytics and contact tracking disabled — useful when you need explicit user consent before collecting data.

popups.init({
apiKey: 'YOUR_PUBLIC_API_KEY',
trackingEnabled: false,
});
// Later, once the user gives consent:
popups.setTrackingEnabled(true);

setTrackingEnabled(false) closes the current session with reason: 'tracking_disabled' and suspends all outbound calls (analytics, contact) — the data collected before the opt-out is still delivered, rather than dropped. setTrackingEnabled(true) resumes them, assigns a persistent user_id if one was not already stored, and opens a new session.


In React Native, two automatic behaviors require explicit host integration:

Because History API is unavailable, report screen changes manually after each navigation event:

// In React Navigation's onStateChange callback:
popups.setScreen(route.name);

Connect the SDK to the app’s foreground/background lifecycle so engagement time is measured correctly and events are flushed when the app goes to the background:

import { AppState } from 'react-native';
AppState.addEventListener('change', (state) => {
if (state === 'active') popups.onForeground();
else popups.onBackground(); // ends the session and flushes
});

During development, inspect the current event buffer without flushing:

const preview = popups.previewAnalytics();
console.log(preview.events); // all events queued since last flush

To force a flush manually (useful for testing):

popups.flushAnalytics();

Flushes happen automatically — every 30 s in the foreground, when the buffer reaches 20 events, when the tab is hidden, and when the page or app closes. You rarely need to call flushAnalytics() yourself. From 1.1.8 onwards the channel is hardened so that the last batch of a visit — the one carrying the closing deepdots_page_view and deepdots_user_engagement — is not lost:

  • Survives navigation and close — the request uses keepalive, and the final flush at page close switches to navigator.sendBeacon. Browsers no longer cancel it mid-flight.
  • Retries transient failures — a network error or a 5xx / 408 / 429 puts the batch back at the front of the buffer, in chronological order, to be retried on the next flush. Up to 200 events are held; beyond that the oldest are dropped.
  • Reports permanent failures — a 4xx (for example a 406 for an unknown Contact) is logged with its status and response body and the batch is discarded, instead of failing silently.
  • Keeps one record per visit — until the backend has returned a session id, batches are serialized rather than sent in parallel, so a visit doesn’t get split across two records.

flushAnalytics() accepts a final flag, which is what the SDK uses internally at page close. Pass it only if you are implementing your own shutdown path — it prefers sendBeacon and does not wait for the response:

popups.flushAnalytics({ final: true });