@racoelho/analisa-desktop
v0.1.0
Published
Analisa analytics SDK for desktop apps (Electron and Tauri) on Windows, macOS and Linux
Maintainers
Readme
@racoelho/analisa-desktop
SDK do Analisa (analytics com privacidade) para apps desktop Electron e Tauri v2 no Windows, macOS e Linux. Implementa o protocolo de ingestão v1: telas, sessões, ciclo de vida do app, crashes e fila offline persistente.
English version below — jump to English.
npm install @racoelho/analisa-desktop| Import | Onde roda |
|---|---|
| @racoelho/analisa-desktop/electron | Processo principal do Electron |
| @racoelho/analisa-desktop/electron/preload | Preload do Electron (window.analisa no renderer) |
| @racoelho/analisa-desktop/tauri | Front-end (webview) do Tauri v2 |
| @racoelho/analisa-desktop | Núcleo agnóstico (AnalisaCore) e tipos do protocolo |
Configure o app no painel
Apps desktop mandam eventos sem Origin de navegador. No painel, em Configurações do app:
- Ligue "Receber eventos de apps" (
allow_native). Sem isso o ingestor responde403 native_not_allowede o SDK para de enviar (e avisa no console). - Opcional: preencha "App ids permitidos" (
allowed_app_ids) com o app id que o SDK manda (appIdno Electron; oidentifierdotauri.conf.jsonno Tauri). - Nada a fazer para o Tauri: os Origins dos webviews do Tauri (
tauri://localhostno macOS/Linux ehttp(s)://tauri.localhostno WebView2 do Windows) são tratados como app nativo, ou seja, valem as regras dos itens 1 e 2. Não precisa mexer em "Origens permitidas".
Electron
1. Processo principal
// main.ts
import { app, BrowserWindow } from 'electron'
import { init } from '@racoelho/analisa-desktop/electron'
const analisa = init({
siteKey: 'pk_...',
appId: 'com.empresa.app', // o appId do electron-builder; vai em X-Analisa-App-Id e context.app.id
appBuild: '451', // opcional
// endpoint: 'https://seu-ingestor/v1/i',
})
app.whenReady().then(() => {
const win = new BrowserWindow({
webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true },
})
analisa.screen('Home')
})O que acontece sozinho:
- Contexto:
process.platform(win32→windows,darwin→macos,linux),process.getSystemVersion()(fallbackos.release()),app.getVersion(), app id,app.getLocale(), fuso, tela principal (screen.getPrimaryDisplay(), atualizada quando muda),device.type: "desktop",framework: "electron",sdk.name: "analisa-electron". - Envio do processo principal com o
fetchdo Node (Electron ≥ 28): semOrigin, comUser-AgenteX-Analisa-App-Id. Não envia quandonet.isOnline()é falso. - Persistência em
app.getPath('userData')/analisa/(state.json,queue.json,crash.json). - Ciclo de vida:
app_open {launch:"cold"}noready(comfirst_launchna primeira vez),app_updatequando a versão/build mudou,app_background/app_foregroundquando nenhuma janela tem foco (browser-window-blur/focus,powerMonitorsuspend/resume/lock). CombackgroundOn: 'visibility', conta só janelas escondidas/minimizadas. Nobefore-quito SDK segura o quit por até 2 s para mandar a fila. - Crashes:
uncaughtExceptionMonitorgrava o erro no disco de forma síncrona (sem rede) e ele sai no próximo lançamento com ots, a sessão e a versão do crash, antes doapp_open(error_kind: "crash"). Se o processo sobrevive (ex.: só apareceu o diálogo de erro do Electron), vai na hora comounhandled.unhandledRejectionviraunhandled(o SDK repete o aviso no console).render-process-goneechild-process-gone(excetoclean-exit/killed) viramcrashcomprocess_typeeexit_reason.
2. Preload (bridge para o renderer)
// preload.ts
import { exposeAnalisa } from '@racoelho/analisa-desktop/electron/preload'
exposeAnalisa() // window.analisa; também manda erros não tratados do rendererCom sandbox: true (padrão desde o Electron 20) o preload só pode dar require('electron'):
empacote o preload (Vite, esbuild, webpack — o electron-vite já faz isso). O helper não
importa o núcleo; ele só repassa as chamadas por IPC (analisa:call) para o processo principal,
que valida os argumentos. Desligue o bridge com init({ ipc: false }).
3. Renderer
declare global { interface Window { analisa: import('@racoelho/analisa-desktop/electron').AnalisaBridge } }
window.analisa.screen('Settings') // → page_view path "/settings"
window.analisa.track('export_clicked', { format: 'pdf' })
window.analisa.identify('user_123', { plan: 'pro' }, 'plan')Alternativa: SDK web no renderer. Se preferir o @racoelho/analisa no renderer, declare a
plataforma real com config.context (o contexto vem do main pelo bridge):
import { Analytics } from '@racoelho/analisa'
const context = await window.analisa.getContext() // { platform: 'macos', framework: 'electron', app: {...} }
const analytics = new Analytics({ siteKey: 'pk_...', context, autoWebVitals: false })Nesse caso a página carregada por file:// manda Origin: null (tratado como app nativo:
precisa de allow_native); servida por http(s):// é origem web (allowed origins). O SDK web
guarda anon_id no localStorage do renderer — use um dos dois (SDK do main + bridge, ou SDK
web no renderer), não os dois, ou o mesmo usuário vira duas pessoas. Ciclo de vida e crashes do
processo principal só existem no SDK do main.
Tauri v2
// src/analytics.ts (front-end)
import { init } from '@racoelho/analisa-desktop/tauri'
import * as app from '@tauri-apps/api/app'
import * as win from '@tauri-apps/api/window'
import * as os from '@tauri-apps/plugin-os' // opcional
import * as store from '@tauri-apps/plugin-store' // opcional (senão: localStorage)
import * as http from '@tauri-apps/plugin-http' // opcional (senão: fetch do webview)
export const analisa = init({
siteKey: 'pk_...',
modules: { app, window: win, os, store, http },
})
analisa.screen('Home')Os módulos do Tauri são opcionais e injetados. Sem modules, o SDK tenta window.__TAURI__
(app.withGlobalTauri: true) e as APIs internas do Tauri v2 (__TAURI_INTERNALS__.invoke,
__TAURI_OS_PLUGIN_INTERNALS__), e no fim o navigator. Ele não faz import() dos pacotes por
conta própria: um import de pacote que o app não instalou quebra o build do Vite/Webpack.
- Contexto: plataforma e versão do SO (plugin-os),
getVersion(),getIdentifier()(app id), locale, fuso,screen+devicePixelRatio,framework: "tauri",sdk.name: "analisa-tauri". - Envio:
transport: 'auto'(padrão) usa ofetchdo plugin-http quando injetado, senão ofetchdo webview comContent-Type: text/plaine sem headers customizados (sem preflight; o app id vai emcontext.app.id). O ingestor responde CORS ecoando o Origin, então o fetch do webview funciona; se a sua CSP bloquear, inclua a URL do ingestor emconnect-srcou use o plugin-http (sem CORS/CSP). O plugin-http também manda Origin (tauri://localhostouhttp://tauri.localhost, ambos tratados como nativos); compilado com a featureunsafe-headers,stripOrigin: truefaz o plugin remover o header. - Persistência: plugin-store (
analisa.json) quando injetado, senãolocalStorage. - Ciclo de vida:
visibilitychange,focus/blureonFocusChangedda janela (backgroundOn: 'visibility'para ignorar perda de foco). Inicie o SDK só na janela principal. - Erros:
error/unhandledrejectiondo webview viramerror_kind: "unhandled". Panics do Rust não passam pelo JS — não são capturados.
Rust (src-tauri/src/lib.rs), só para os plugins que você usar:
tauri::Builder::default()
.plugin(tauri_plugin_os::init())
.plugin(tauri_plugin_store::Builder::new().build())
.plugin(tauri_plugin_http::init())Permissões (src-tauri/capabilities/default.json):
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"windows": ["main"],
"permissions": [
"core:default",
"core:app:allow-version",
"core:app:allow-name",
"core:app:allow-identifier",
"core:window:default",
"os:default",
"store:default",
{
"identifier": "http:default",
"allow": [{ "url": "https://function-bun-production-3339.up.railway.app/*" }]
}
]
}(Troque a URL se usar outro endpoint. os:, store: e http: só se usar esses plugins.)
API (Electron e Tauri)
| Método | O que faz |
|---|---|
| track(name, props?) | Evento customizado (snake_case). |
| screen(name, props?) | page_view com path de rota ("ProductDetail" → /product-detail; um nome começando com / é usado como path) e props.screen_name. Ids vão em props, não no path. |
| identify(userId, traits?, idProp?) | Associa o usuário (persistido até reset()); não reenvia o mesmo userId em < 5 min. |
| reset() | Logout: limpa o usuário e abre sessão nova (mantém anon_id). |
| captureException(error, props?) | error com error_kind: "handled". |
| setConsent('granted' \| 'denied') / optOut() | denied para a coleta, limpa fila e usuário, e vale após reiniciar. |
| flush() | Envia a fila agora. |
| signup, login, logout, search, purchase | Mesmos nomes/props do SDK web. |
| ready | Promise: estado carregado e app_open enfileirado. |
| shutdown() | Remove listeners, grava a fila e tenta um último envio. |
Opções comuns: endpoint, debug, trackLifecycle (padrão true), captureErrors (true),
defaultConsent ('granted'), flushIntervalMs (10 000), flushAt (20), maxBatchSize (50),
maxQueueSize (1000), sessionTimeoutMs (30 min), requestTimeoutMs (10 000), context
(sobrescreve campos detectados), fetch, logger.
Entrega
- Cada evento recebe um
id(UUID v4) na criação, salvo com ele e reusado em toda nova tentativa (idempotência no ingestor). - Sessão: nova após 30 min sem eventos (persistida; reiniciar o app dentro de 30 min continua
a sessão). Voltar do segundo plano depois de 30 min emite
app_open {launch:"warm"}. - Fila persistente de até 1000 eventos (descarta os mais antigos); eventos com mais de 7 dias são descartados no envio.
- Flush com 20 eventos, a cada 10 s em primeiro plano, ao ir para segundo plano e no início.
Até 50 eventos por request, um request por vez,
sent_atem cada tentativa. - Rede/timeout/408/429/5xx: backoff
min(2^n s, 5 min)±20 % (respeitaRetry-After).413: divide o lote ao meio (um evento sozinho grande demais é descartado). Outros 4xx: descarta o lote.native_not_allowed,app_id_required,app_id_not_allowed,site_inactive,401,404: descarta e para de enviar neste processo (a coleta continua na fila e vai no próximo lançamento, depois de corrigir o painel).402: descarta e pausa 1 h.
Privacidade
- Nenhum dado pessoal por padrão:
anon_idé um UUID aleatório do app (não vem de hardware nem de ids de publicidade; reinstalar gera outro). Sem IP, sem fingerprinting no cliente. - Stacks de erro têm caminhos absolutos removidos (que podem conter o nome do usuário do SO).
- A site key num binário é pública:
allow_native/allowed_app_idssão mitigação de spam, não autenticação. Nunca ponha a secret key no app. setConsent('denied')apaga fila e usuário e para tudo, inclusive após reiniciar.
Testes
npm test -w @racoelho/analisa-desktop # unitários (vitest, electron/tauri falsos)
# E2E contra um ingestor real + Postgres:
ANALISA_E2E_ENDPOINT=http://localhost:8787/v1/i ANALISA_E2E_SITE_KEY=pk_... \
ANALISA_E2E_SITE_KEY_OFF=pk_sem_allow_native DATABASE_URL=postgresql://... \
npm run test:e2e -w @racoelho/analisa-desktop
# Smoke com Electron real (headless com xvfb), depois de `npm run build`:
bash packages/sdk-desktop/e2e/real-electron/run.shEnglish
Analisa SDK for Electron and Tauri v2 desktop apps (Windows, macOS, Linux), implementing the ingestion protocol v1: screens, sessions, app lifecycle, crashes and a persistent offline queue.
Dashboard setup
- Turn on "Receive events from apps" (
allow_native) in the app settings. Otherwise the ingestor answers403 native_not_allowedand the SDK stops sending (with a console message). - Optional: list your app id in "Allowed app ids" (
appIdon Electron, thetauri.conf.jsonidentifieron Tauri). - Nothing else for Tauri: its webview origins (
tauri://localhoston macOS/Linux,http(s)://tauri.localhoston Windows WebView2) are treated as native requests, so rules 1 and 2 apply. No need to touch Allowed origins.
Electron
- Main process:
init({ siteKey, appId, appBuild? })from@racoelho/analisa-desktop/electron. Sends from the main process with Nodefetch(no Origin) plusUser-AgentandX-Analisa-App-Id; context fromprocess.platform,process.getSystemVersion()/os.release(),app.getVersion(),app.getLocale(),screen.getPrimaryDisplay()(after ready),device.type: desktop,framework: electron. State, queue and pending crashes live inuserData/analisa/*.json. - Lifecycle:
app_open(cold,first_launch) on ready,app_updatewhen version/build changed,app_background/app_foregroundwhen no app window is focused (or, withbackgroundOn: 'visibility', when all windows are hidden/minimized) and onpowerMonitorsuspend/resume.before-quitwaits up to 2 s to flush. - Crashes: main-process uncaught exceptions are written synchronously to disk
(
uncaughtExceptionMonitor, no network) and sent on the next launch with the crash's ownts, session and app version, beforeapp_open. If the process survives, it is sent right away asunhandled. Unhandled rejections →unhandled.render-process-gone/child-process-gone→crash. - Preload:
exposeAnalisa()from@racoelho/analisa-desktop/electron/preloadexposeswindow.analisathroughcontextBridge(IPC to the main process, arguments validated there). With the sandbox on, bundle your preload.window.analisa.getContext()returns the main-process context if you prefer the web SDK in the renderer:new Analytics({ siteKey, context })— then use that instead of the main-process SDK, not both (twoanon_ids).
Tauri v2
init({ siteKey, modules: { app, window, os, store, http } }) from
@racoelho/analisa-desktop/tauri, in the main window only. Tauri modules are optional and
injected; without them the SDK falls back to window.__TAURI__, Tauri v2 internals and
navigator. It never import()s them itself (a missing optional package breaks Vite/Webpack
builds). Transport: the http plugin when provided (no CORS), otherwise webview fetch with
text/plain and no custom headers (no preflight; the ingestor echoes the Origin in CORS). Storage:
plugin-store, otherwise localStorage. See the capabilities JSON and Rust plugin setup above.
Rust panics are not captured.
API
track, screen (→ page_view with a route-like path and props.screen_name), identify,
reset, captureException, setConsent/optOut, flush, helpers signup/login/logout/
search/purchase, plus ready and shutdown(). Delivery follows the protocol exactly: UUID per
event reused on retry, 30-minute sessions, persistent queue capped at 1000, flush at 20 events /
every 10 s / on background, ≤ 50 events per request, one request in flight, backoff
min(2^n s, 5 min) ±20 %, split on 413, drop on other 4xx, stop on
native_not_allowed/app_id_*/site_inactive/401/404, pause 1 h on 402.
Privacy
No personal data by default; random install-scoped anon_id; absolute paths stripped from stack
traces; the site key in a binary is public (spam mitigation, not auth) — never ship the secret key;
setConsent('denied') clears the queue and user and persists.
