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', },});Automatic data
Section titled “Automatic data”The following data is collected with zero extra code as long as the SDK is initialized:
| Data | How | Where it appears |
|---|---|---|
Screen views (deepdots_page_view) | History API (pushState / popstate / hashchange) | Events |
Active engagement time (deepdots_user_engagement) | visibilitychange listener | Events |
Persistent user identity (user_id) | Generated on first visit, stored in localStorage | Metadata |
| Device type | Parsed from User-Agent (mobile / tablet / desktop) | Context |
| User agent | navigator.userAgent | Context |
Language (deepdots_language) | Auto-detected (see Language detection) | Context |
| App version | appVersion 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.
Language detection
Section titled “Language detection”The language reported on the analytics context — sent as deepdots_language in the Feedback metadata — is resolved automatically, in this order:
- The
languagepassed toinit()— 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. navigator.language— the browser language (web).- The
Intllocale (Intl.DateTimeFormat().resolvedOptions().locale) — the fallback used whennavigator.languageis unavailable. This is what makes detection work on React Native with Hermes, wherenavigator.languagedoes not exist. - 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.
Sessions
Section titled “Sessions”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 meansinit(), returning to the foreground, granting consent withsetTrackingEnabled(true), and after a user change. If you init withtrackingEnabled: false, the first session opens when consent is granted.deepdots_session_end— on close, with areason. The closing batch is sent withcompleted: 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.
reason | When |
|---|---|
page_hide | The page closes (pagehide) — web |
background | The app goes to background (onBackground()) — React Native |
user_change | setUserId() switched the user |
tracking_disabled | setTrackingEnabled(false) |
manual | endSession() |
endSession()
Section titled “endSession()”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.
setUserId(userId?)
Section titled “setUserId(userId?)”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 idpopups.setUserId('customer-123');
// Logout: back to the SDK's anonymous idpopups.setUserId();Custom events
Section titled “Custom events”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' });Search
Section titled “Search”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: falsepopups.trackSearch('t-shirt', 142); // has_results: trueFindability friction
Section titled “Findability friction”Record moments where users struggle to find what they need:
popups.trackFindabilityFriction('checkout_address');popups.trackFindabilityFriction('plan_comparison');Funnel steps
Section titled “Funnel steps”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');Meaningful interactions
Section titled “Meaningful interactions”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.
Mini-service tracking
Section titled “Mini-service tracking”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 flowpopups.enterMiniService('checkout', 'home_banner');
// … user completes or abandons the flow …
// User leaves — pass the same name; duration is computed automaticallypopups.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 nowpopups.exitMiniService('checkout'); // closes checkout; support_chat stays openAny 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.
User attributes
Section titled “User attributes”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.
Contact record
Section titled “Contact record”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' },});Metrics
Section titled “Metrics”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): voidMetrics land in a dedicated metrics field of the analytics payload (POST /sdk/feedback), kept separate from metadata and from user attributes.
Behavior
Section titled “Behavior”- Persistent — once set, the value is re-sent on every flush until it changes.
- Overwrites by key — calling
setMetricagain 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
keyis a no-op. - Respects the kill-switch — it is a no-op while tracking is disabled (see Privacy and consent).
Metrics vs. user attributes
Section titled “Metrics vs. user attributes”Both attach context to the user, but they answer different questions:
setUserAttributes | setMetric | |
|---|---|---|
| Represents | Dimensions to break down by | Measurable values to report |
| Example | plan: 'pro', sector: 'retail' | cart_value: 49.99, items_in_cart: 3 |
| Payload field | metadata | metrics |
Use attributes for the who — the categories you filter and group by — and metrics for the how much — the quantities you measure.
Messaging
Section titled “Messaging”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 itpopups.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' });| Field | Type | Description |
|---|---|---|
stage (1st arg) | 'delivered' / 'clicked' / 'converted' | Stage of the message funnel |
id | string | Correlates the stages of the same message |
title | string | Grouping dimension for the Messaging metrics |
channel | 'push' / 'in_app' | Delivery channel |
campaign | string? | Campaign name (optional) |
value / currency | number / string | Conversion value (typical on converted) |
params | object? | 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.
Rules for a correct funnel
Section titled “Rules for a correct funnel”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.
- Send all three stages.
deliveredgoes 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. - Use the same
idacross the three stages. It is what correlates the funnel, and it must be unique per send, not per campaign. - One
id, one channel. If a campaign goes out both as a push and as an in-app message, use two differentidvalues sharing the samecampaign. - 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.
Validation
Section titled “Validation”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"| Rule | What is discarded | reason |
|---|---|---|
channel must be push or in_app | Any other value | invalid_channel |
Each (id, stage) pair is sent once | The 2nd call to the same stage of the same message | duplicate_stage |
An id keeps its channel | Events on a channel other than the first one seen | channel_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.
Crash & error reporting
Section titled “Crash & error reporting”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.
Automatic capture
Section titled “Automatic capture”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.
Reporting errors manually
Section titled “Reporting errors manually”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' } });}| Option | Values | Default |
|---|---|---|
severity | 'fatal' / 'error' / 'warning' | 'error' |
handled | boolean | true |
context | free-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).
Privacy and consent
Section titled “Privacy and consent”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.
React Native
Section titled “React Native”In React Native, two automatic behaviors require explicit host integration:
Navigation tracking
Section titled “Navigation tracking”Because History API is unavailable, report screen changes manually after each navigation event:
// In React Navigation's onStateChange callback:popups.setScreen(route.name);Lifecycle (engagement time)
Section titled “Lifecycle (engagement time)”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});Previewing events before sending
Section titled “Previewing events before sending”During development, inspect the current event buffer without flushing:
const preview = popups.previewAnalytics();console.log(preview.events); // all events queued since last flushTo force a flush manually (useful for testing):
popups.flushAnalytics();Delivery guarantees
Section titled “Delivery guarantees”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 tonavigator.sendBeacon. Browsers no longer cancel it mid-flight. - Retries transient failures — a network error or a
5xx/408/429puts 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 a406for 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 });