# closeyourit-js

SDK JavaScript di [CloseYourIt](https://www.closeyour.it) per il monitoring di errori, log e
performance — **isomorfo: browser + Node 20.16+**. TypeScript, **una sola dipendenza runtime**
([`web-vitals`](https://github.com/GoogleChrome/web-vitals), che a sua volta non ne ha), formato
wire Sentry-compatibile identico ai client
[`closeyourit-ruby`](https://github.com/bussolabs/closeyourit-ruby) e
[`closeyourit-dart`](https://github.com/bussolabs/closeyourit-dart).

Nel browser cattura automaticamente le eccezioni non gestite, i rifiuti di Promise, i log
strutturati e i breadcrumb (click, navigazione, console, richieste HTTP); su Node aggancia
`uncaughtException`/`unhandledRejection` e flusha su `beforeExit`/`SIGTERM`. In più: metriche
`slow_method` via `measure()` e — opt-in — verdetti performance (`slow_external_http`,
`repeated_http`, `jank`). Scrubbing PII lato client, invio fire-and-forget: non fa mai crashare
l'host.

> **Credenziali — server vs browser.** Il progetto ha **due** credenziali per lo **stesso**
> token:
>
> - **`token`** — il bearer **segreto** `cyi_…`. È a piena potenza (ingest **e** read): è una
>   credenziale **SERVER-ONLY**. Usalo su Node/SSR (`Authorization: Bearer`). **Non deve MAI finire
>   in un bundle browser**: chiunque lo estrarrebbe dal tab Network in pochi secondi.
> - **`publicKey`** — la **DSN public key** (esadecimale, come lo `sentry_key` di Sentry). Per design
>   **non è segreta**: è la credenziale **browser-safe**. Nel browser l'SDK la usa via `?sentry_key=`
>   sul path drop-in Sentry, **mai il bearer**.
>
> Nel browser configura `publicKey`; se passi solo il bearer segreto, l'SDK **resta no-op** (avvisa in
> console) invece di esporlo. Prendi entrambe le credenziali dalla pagina del progetto su CloseYourIt.

## Installazione

### npm (bundler: Vite, webpack, Rollup, …)

```bash
pnpm add @bussolabs/closeyourit-js
# oppure: npm install @bussolabs/closeyourit-js
```

```ts
import * as CloseYourIt from '@bussolabs/closeyourit-js'

CloseYourIt.init({
  endpointUrl: 'https://www.closeyour.it',
  publicKey: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', // DSN public key (browser-safe)
  projectId: '00000000-0000-0000-0000-000000000000',
  environment: 'production',
  release: 'web@1.4.2',
})
```

### `<script>` (IIFE, senza build step)

Il bundle IIFE espone il globale `window.CloseYourIt` (~20 KB gzip, ~18 KB brotli: il tetto è
committato in `bundle-budget.json` e la CI lo verifica a ogni run). In produzione **pinna la
versione** e aggiungi la [Subresource Integrity](https://developer.mozilla.org/docs/Web/Security/Subresource_Integrity)
(`integrity` + `crossorigin`) per proteggerti da una compromissione della CDN:

```html
<script
  src="https://cdn.jsdelivr.net/npm/@bussolabs/closeyourit-js@0.2.0/dist/closeyourit.min.js"
  integrity="sha384-…"
  crossorigin="anonymous"
></script>
<script>
  CloseYourIt.init({
    endpointUrl: 'https://www.closeyour.it',
    publicKey: 'xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', // DSN public key (browser-safe)
    projectId: '00000000-0000-0000-0000-000000000000',
    environment: 'production',
  })
</script>
```

L'hash `integrity` reale della versione pubblicata si ottiene da jsDelivr (pulsante "SRI" sulla
pagina del file) o localmente con
`curl -s https://cdn.jsdelivr.net/npm/@bussolabs/closeyourit-js@0.2.0/dist/closeyourit.min.js | openssl dgst -sha384 -binary | openssl base64 -A`.

Metti lo `<script>` il più in alto possibile nel `<head>`: dopo `init`, i gestori globali
catturano ogni errore successivo, anche quelli sollevati durante il resto del caricamento.

### Node 20.16+

Stesso package, stessa `init`. Su Node l'SDK installa i gestori di processo
(`uncaughtException`/`unhandledRejection`: cattura → flush → se non hai handler tuoi, esce con
codice 1 preservando la semantica crash di default) e flusha i buffer su `beforeExit`/`SIGTERM`.
Gli eventi Node portano anche `server_name` (hostname, parità con la gem): richiede
`process.getBuiltinModule` (Node ≥20.16) — da cui il minimo dichiarato in `engines`.

```ts
import * as CloseYourIt from '@bussolabs/closeyourit-js'

CloseYourIt.init({
  endpointUrl: 'https://www.closeyour.it',
  token: process.env.CLOSEYOURIT_TOKEN!, // bearer segreto: lecito lato server
  projectId: process.env.CLOSEYOURIT_PROJECT_ID!,
  environment: process.env.NODE_ENV ?? 'production',
})

// Cattura manuale + flush esplicito (es. script batch/CLI)
try {
  await job()
} catch (e) {
  CloseYourIt.captureException(e, { handled: true })
  await CloseYourIt.flush()
  throw e
}
```

Su Node più richieste sono elaborate insieme: per tenere user/tag/request/breadcrumb **isolati
per richiesta** (e mai attraverso il confine tra flussi concorrenti) avvolgi l'handler in
`CloseYourIt.runWithScope(...)` — in Express/Connect basta un middleware:
`app.use((req, res, next) => CloseYourIt.runWithScope(() => next()))`. Dentro il blocco imposta il
contesto con `CloseYourIt.setUser(...)` / `client.scope.setRequest({ url, method })` o passa
`contexts` all'hint. Dove non puoi avvolgere l'intera richiesta (es. un hook `error` globale) usa
`CloseYourIt.withScope(...)` per una cattura con contesto temporaneo, senza toccare lo scope
condiviso. Il wrapper automatico di `fetch` è solo browser: per l'HTTP server-side usa
breadcrumb/`measure` manuali.

## Uso

Dopo `init` gli errori non gestiti e i rifiuti di Promise vengono catturati automaticamente. Le
API aggiuntive:

```ts
// Cattura manuale di un'eccezione gestita
try {
  rischioso()
} catch (e) {
  CloseYourIt.captureException(e, { handled: true })
}

// Messaggio diagnostico
CloseYourIt.captureMessage('cache miss sul carrello', 'warning')

// Log strutturato (bufferizzato e inviato in batch)
CloseYourIt.log('info', 'ordine creato', { order_id: 42, total_cents: 1990 })

// Logger nominato con metodi per livello (warn → warning sul wire)
CloseYourIt.logger.warn('risposta lenta dal gateway')
CloseYourIt.logger.named('payments').info('rimborso emesso', { order_id: 42 })

// Metrica slow_method: misura un blocco (sync o async) e invia oltre soglia
const totale = CloseYourIt.measure('checkout.total', () => calcolaTotale(cart))
const dati = await CloseYourIt.measure('api.orders', () => fetchOrders())

// Contesto allegato a errori e messaggi successivi
CloseYourIt.setUser({ id: 'acc_123' }) // senza sendPii viaggia solo l'id
CloseYourIt.setTag('area', 'checkout')
CloseYourIt.setTags({ tenant: 'acme', tier: 'gold' })
CloseYourIt.setExtra('plan', 'pro')
CloseYourIt.setContext('billing', { seats: 3 })

// Breadcrumb manuale (oltre a quelli automatici)
CloseYourIt.addBreadcrumb({ category: 'ui.action', message: 'apre modale pagamento' })

// Correlazione: il trace_id di pagina è generato all'init; sovrascrivibile
CloseYourIt.setTraceId(myRequestId)
CloseYourIt.getTraceId()

// Svuota i log bufferizzati e attende gli invii in volo (idempotente)
await CloseYourIt.flush()

// Spegne l'SDK e ripristina i patch globali
await CloseYourIt.close()
```

## Performance issues (opt-in)

Con `detectPerformanceIssues: true` l'SDK emette verdetti `performance_issue` su `/metrics`:

- **`slow_external_http`** — un `fetch`/XHR verso un host esterno oltre
  `slowExternalThresholdMs` (default 1s). Il path è templatizzato (`/v1/charges/<n>`,
  `/users/<uuid>`), mai la query string.
- **`repeated_http`** — la stessa richiesta (host + path templatizzato) ripetuta
  `repeatedHttpThreshold` volte (default 5) in `repeatedHttpWindowMs` (default 5s): l'"N+1 del
  client". Emesso una volta per finestra.
- **`jank`** — long task del main thread oltre `jankThresholdMs` (default 100ms) via
  `PerformanceObserver(longtask)`; di fatto Chromium-only, altrove è un no-op silenzioso. Route
  = `location.pathname` templatizzato.

```ts
CloseYourIt.init({
  /* … */
  detectPerformanceIssues: true,
})
```

Indipendentemente dall'opt-in, il breadcrumb `http` marca `slow_external_http: true`
(`level: warning`) sulle chiamate esterne oltre soglia — utile come contesto negli errori.

## Web Vitals (opt-in)

Con `trackWebVitals: true` l'SDK misura sul browser dei visitatori veri le metriche con cui Google
giudica la velocità di un sito, e le invia a `POST /api/v1/projects/{projectId}/web_vitals`:

| Metrica | Cosa misura                                                | Ruolo                      |
| ------- | ---------------------------------------------------------- | -------------------------- |
| `lcp`   | Quanto ci mette a comparire il contenuto principale (ms)   | Core Web Vital             |
| `cls`   | Quanto la pagina si sposta sotto gli occhi (adimensionale) | Core Web Vital             |
| `inp`   | Quanto tarda la pagina a rispondere a un'interazione (ms)  | Core Web Vital             |
| `ttfb`  | Quanto tarda il server a mandare il primo byte (ms)        | Diagnostica di un LCP alto |
| `fcp`   | Quando appare il primo contenuto, qualunque esso sia (ms)  | Diagnostica di un LCP alto |

```ts
CloseYourIt.init({
  /* … */
  trackWebVitals: true,
  webVitalsSampleRate: 0.2, // opzionale: una pagina su cinque
})
```

**Chi misura.** [`web-vitals`](https://github.com/GoogleChrome/web-vitals) 6.x, la libreria di
riferimento di Google, ed è l'**unica dipendenza** del pacchetto. LCP e CLS a mano sono fattibili,
l'INP no — dipende dal percentile sulle interazioni e da eccezioni che cambiano tra versioni di
Chrome — e una versione sbagliata produce numeri plausibili che nessuno smaschera per mesi. È anche
l'unico modo di essere confrontabili con i dati di CrUX, che è il punto di affiancare le due colonne
nella stessa pagina. Viene importata **per metrica** (`web-vitals/onLCP.js`) e nella sola build
standard, **mai** `web-vitals/attribution`: quella pesa molto di più e il suo dato oggi non avrebbe
dove andare. Il bundle browser ha un tetto committato (`bundle-budget.json`) che la CI verifica a
ogni run: la URL su jsDelivr non è pinnata, quindi ogni versione arriva da sola su tutti i siti
installati e un byte in più lo scaricano tutti i loro visitatori.

**Quando partono.** Tutte insieme, in **un solo invio**, quando la pagina esce di scena
(`pagehide`/`visibilitychange(hidden)`): prima di allora i valori sono provvisori — l'LCP può ancora
essere superato, il CLS può ancora crescere. TTFB e FCP sono definitivi molto prima, ma aspettano in
memoria insieme agli altri: mandarli subito significherebbe due richieste per caricamento invece di
una. Il comportamento è quello di **default** della libreria, non `reportAllChanges`: ogni metrica
arriva una volta sola, col valore definitivo.

**Una misura per metrica, per caricamento**, ed è la prima a valere: quello che la libreria riporta
dopo che il batch è partito viene scartato. Il rovescio da conoscere: se l'utente passa a un'altra
scheda e poi torna, il report è già partito e quello che accade dopo non viene misurato. È una scelta
deliberata — due valori della stessa pagina dentro un percentile lo falsano più di una misura
troncata. Una metrica che invece arriva **in ritardo per la prima volta** (dopo il primo invio non
c'è un secondo `pagehide` su cui contare) parte da sola entro cinque secondi, così non resta in
ostaggio del buffer.

**Cosa viaggia.** `hostname`, `path` (**mai** la query né il frammento), il tipo di navigazione,
`environment` e `release` se configurata. Nessun identificatore del visitatore: come per le
pageview, il resto lo ricava il server dalla richiesta. L'**esito** (buono/da migliorare/scarso) non
viene inviato: lo calcola il backend dalle soglie ufficiali, così un SDK vecchio non può dipingere di
verde ciò che oggi è rosso. Il tipo di navigazione che l'ingest non conosce (`back-forward-cache`,
`soft-navigation`) viene semplicemente omesso: la misura parte lo stesso, perderla per un campo
accessorio sarebbe uno scambio pessimo.

**Sampling.** `webVitalsSampleRate` (default `1.0`) decide sul **caricamento intero**: le misure di
una pagina partono tutte o nessuna, anche quando arrivano in due invii. Campionarle una per una
costruirebbe i percentili di metriche diverse su insiemi di pagine diversi, che non sono più
confrontabili. Sotto `1.0` il percentile diventa inaffidabile sui siti a basso traffico, che sono la
maggioranza: abbassarlo conviene solo con volumi alti.

**Browser non supportati.** Ogni metrica è indipendente: quella che il browser non sa misurare non
viene raccolta e le altre partono lo stesso, e l'`init` non cambia comportamento. Oggi LCP e INP sono
di fatto Chromium-only; TTFB e FCP arrivano quasi ovunque. L'INP di una pagina che nessuno ha toccato
non viene inventato: senza interazioni non c'è misura.

In una SPA le misure sono attribuite al **percorso al momento del caricamento**, non all'ultima route
visitata: LCP, CLS e INP descrivono quel caricamento, e assegnarli alla pagina dove l'utente è
arrivato darebbe la colpa a quella sbagliata.

## Errori delle risorse e violazioni CSP (opt-in)

Con `captureResourceErrors: true` l'SDK segnala i guasti che nessuno stack trace racconta: uno
script o un foglio di stile che non arriva, un'immagine che non carica, una regola di Content
Security Policy che blocca qualcosa che alla pagina serviva.

```ts
CloseYourIt.init({
  /* … */
  captureResourceErrors: true,
})
```

Arrivano sul canale eventi, come gli errori JS, con `level: warning` — una risorsa bloccata è quasi
sempre ambiente o terze parti (un blocco pubblicità, una CDN irraggiungibile), non un difetto
dell'applicazione: alzarla a `error` riempirebbe l'alerting di rumore che nessuno può correggere.

| Tipo evento     | Messaggio (chiave di raggruppamento)                          | Contesto allegato                                                       |
| --------------- | ------------------------------------------------------------- | ----------------------------------------------------------------------- |
| `ResourceError` | `script non caricato: https://cdn.example.com/app.js`         | `contexts.resource` = `{ url, element }`                                |
| `CSPViolation`  | `script-src ha bloccato https://tracker.example.com/pixel.js` | `contexts.csp` = `{ blocked_uri, effective_directive, disposition, … }` |

**Gli URL viaggiano sempre senza query string** (può portare token di firma delle CDN, e per sapere
quale risorsa manca non serve), indipendentemente da `sendPii`.

**Throttle.** Massimo **10 segnalazioni per caricamento pagina e per famiglia** (risorse e CSP hanno
tetti distinti: una CDN giù non deve nascondere una policy che blocca, sono due problemi diversi), e
la stessa risorsa che continua a fallire vale una segnalazione sola. Vale il campionamento
`sampleRate` degli altri eventi; `excludedExceptions: ['ResourceError']` (o `'CSPViolation'`) spegne
una sola famiglia.

Delle immagini responsive viene segnalato il file che il browser ha davvero scelto (`currentSrc`):
con `srcset` l'attributo `src` può essere assente o indicare un'altra variante.

## Pageview tracking (web analytics, opt-in)

Con `trackPageviews: true` l'SDK invia un pageview a `POST /api/v1/projects/{projectId}/pageviews`
al caricamento e a ogni navigazione SPA (`pushState`/`replaceState`/`popstate`, **dedup sul
pathname**: hash change e `replaceState` sullo stesso path non contano). Sostituisce Plausible:
cookieless, il visitatore è un hash giornaliero calcolato **server-side** — nel body viaggiano solo
`hostname`, `pathname` (mai la query), `referrer` e le 3 chiavi UTM estratte client-side.

```ts
CloseYourIt.init({
  endpointUrl: 'https://www.closeyour.it',
  publicKey: '…', // DSN public key (browser-safe)
  projectId: '…',
  environment: 'production',
  trackPageviews: true,
})

// Pageview manuale (es. virtual pageview di un wizard)
CloseYourIt.capturePageview({ path: '/wizard/step-2' })
```

Da `<script>` CDN (siti senza bundler — versione pinnata + SRI come in [Installazione](#script-iife-senza-build-step)):

```html
<script
  src="https://cdn.jsdelivr.net/npm/@bussolabs/closeyourit-js@0.2.0/dist/closeyourit.min.js"
  integrity="sha384-…"
  crossorigin="anonymous"
></script>
<script>
  CloseYourIt.init({
    endpointUrl: '…',
    publicKey: '…', // DSN public key (browser-safe)
    projectId: '…',
    trackPageviews: true,
  })
</script>
```

I pageview non sono campionati (`sampleRate` non si applica) e non passano da `beforeSend`.

## Tracing distribuito (W3C Trace Context)

Lo SDK legge e propaga gli header standard
[W3C Trace Context](https://www.w3.org/TR/trace-context/) (`traceparent`/`tracestate`), così errori e
log CloseYourIt si correlano con un trace distribuito già esistente — senza adottare un protocollo
proprietario. Il `trace-id` W3C (32 esadecimali) coincide con il `trace_id` interno: adottarlo è un
mapping 1:1.

**Adottare un trace in ingresso** — `continueTrace(carrier)` legge il `traceparent` dalla richiesta
entrante (header server/SSR, un `Headers` o un getter `(name) => value`): il suo trace-id diventa il
`trace_id` di correlazione, così ogni errore/log successivo si aggancia al trace distribuito. Header
assente o malformato: no-op (si tiene il trace corrente).

**Propagare verso valle** — `getTraceHeaders(target?)` restituisce gli header da iniettare in una
richiesta in uscita, **solo verso destinazioni fidate**: quelle in `tracePropagationTargets` (allowlist
di stringhe — match substring, stile Sentry — o RegExp) più il same-origin nel browser. Verso ogni
altra destinazione ritorna `{}`: il trace interno e il `baggage` (potenziale PII) **non raggiungono
host non fidati**. Ogni chiamata genera un nuovo span-id (l'hop corrente).

```ts
import * as CloseYourIt from '@bussolabs/closeyourit-js'

CloseYourIt.init({
  endpointUrl: 'https://www.closeyour.it',
  publicKey: '…', // browser-safe
  projectId: '…',
  tracePropagationTargets: ['https://api.example.com', /\.internal\./],
})

// SSR/server: adotta il trace della richiesta entrante.
CloseYourIt.continueTrace(req.headers)

// Propaga verso un servizio consentito (verso altri host → {}, nessun header).
await fetch('https://api.example.com/orders', {
  headers: { ...CloseYourIt.getTraceHeaders('https://api.example.com/orders') },
})
```

Nessuna iniezione automatica nelle richieste: la propagazione è **opt-in** e passa sempre
dall'allowlist, così l'SDK non altera le richieste verso terze parti né provoca preflight CORS
indesiderati.

## Configurazione

`init(options)` accetta:

| Opzione                   | Tipo                              | Default          | Descrizione                                                                                                                                                                                              |
| ------------------------- | --------------------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `endpointUrl`             | `string`                          | — (obbligatorio) | Base URL del backend, es. `https://www.closeyour.it` (senza path).                                                                                                                                       |
| `token`                   | `string`                          | —                | Bearer **segreto** (`cyi_…`), `Authorization: Bearer`. **SERVER-ONLY**: Node/SSR, mai nel bundle browser.                                                                                                |
| `publicKey`               | `string`                          | —                | **DSN public key** (hex, non segreta): credenziale **browser-safe** via `?sentry_key=`.                                                                                                                  |
| `projectId`               | `string`                          | — (obbligatorio) | UUID del progetto su CloseYourIt.                                                                                                                                                                        |
| `environment`             | `string`                          | `'production'`   | Nome ambiente.                                                                                                                                                                                           |
| `release`                 | `string`                          | auto (Node)      | Versione/release applicativa. **Auto-rilevata su Node** dal deploy (`APP_GIT_TAG`/`GIT_TAG` semver, poi `KAMAL_VERSION`/`GIT_SHA`) se non passata; nel browser va passata esplicita (build-time inject). |
| `sampleRate`              | `number`                          | `1.0`            | Probabilità di invio di errori/messaggi. Le metriche non sono mai campionate.                                                                                                                            |
| `sendPii`                 | `boolean`                         | `false`          | Con `false`: `user` ridotto a `{id}`, URL senza query, referrer strippato.                                                                                                                               |
| `beforeSend`              | `(payload) => payload \| null`    | —                | Hook di redazione/scarto applicato a ogni payload (errori, log, metriche).                                                                                                                               |
| `onDiagnostic`            | `(event, details) => void`        | —                | Hook diagnostico locale (`enqueue`/`send`/`drop`/`timeout`/`shutdown`): rende visibili gli eventi scartati con il motivo. Non ricorsivo, non genera telemetria (vedi sotto).                             |
| `excludedExceptions`      | `string[]`                        | `[]`             | Nomi di errore (`error.name`) da non inviare mai (es. `"AbortError"`).                                                                                                                                   |
| `breadcrumbs`             | `boolean`                         | `true`           | Master switch dei breadcrumb automatici.                                                                                                                                                                 |
| `autoBreadcrumbs`         | `{click,navigation,console,http}` | tutti `true`     | Interruttori granulari dei breadcrumb.                                                                                                                                                                   |
| `maxBreadcrumbs`          | `number`                          | `50`             | Capienza del ring buffer dei breadcrumb.                                                                                                                                                                 |
| `captureRequest`          | `boolean`                         | `true`           | Allega il contesto `request` (URL pagina, User-Agent, referrer) agli eventi (browser).                                                                                                                   |
| `logsEnabled`             | `boolean`                         | `true`           | Invio dei log strutturati.                                                                                                                                                                               |
| `logsSampleRate`          | `number`                          | `1.0`            | Sampling dei log, indipendente da `sampleRate`.                                                                                                                                                          |
| `logsBatchSize`           | `number`                          | `50`             | Log accumulati che forzano il flush del batch.                                                                                                                                                           |
| `logsFlushIntervalMs`     | `number`                          | `5000`           | Flush periodico dei log.                                                                                                                                                                                 |
| `logsMinLevel`            | `LogLevel`                        | `'info'`         | Livello minimo inviato (sotto soglia scartati client-side).                                                                                                                                              |
| `slowMethodThresholdMs`   | `number`                          | `200`            | Soglia oltre cui `measure()` invia la metrica `slow_method`.                                                                                                                                             |
| `captureMethodArguments`  | `boolean`                         | `false`          | Invia gli argomenti passati a `measure(..., { args })` (scrubati, troncati).                                                                                                                             |
| `detectPerformanceIssues` | `boolean`                         | `false`          | Master switch dei verdetti performance.                                                                                                                                                                  |
| `captureExternalHttp`     | `boolean`                         | `true`           | Rileva le HTTP esterne (effettivo solo col detect attivo).                                                                                                                                               |
| `slowExternalThresholdMs` | `number`                          | `1000`           | Soglia HTTP esterna lenta: marca il breadcrumb e (col detect) emette il verdetto. `0` disattiva.                                                                                                         |
| `repeatedHttpThreshold`   | `number`                          | `5`              | Richieste identiche nella finestra che emettono `repeated_http`.                                                                                                                                         |
| `repeatedHttpWindowMs`    | `number`                          | `5000`           | Ampiezza della finestra `repeated_http`.                                                                                                                                                                 |
| `detectJank`              | `boolean`                         | `true`           | Long task del main thread (effettivo solo col detect attivo).                                                                                                                                            |
| `jankThresholdMs`         | `number`                          | `100`            | Durata di un long task oltre cui emettere `jank`.                                                                                                                                                        |
| `trackWebVitals`          | `boolean`                         | `false`          | Raccoglie LCP/CLS/INP/TTFB/FCP dal browser dei visitatori e li invia a `…/web_vitals` all'uscita di pagina. Vedi «Web Vitals».                                                                           |
| `webVitalsSampleRate`     | `number`                          | `1.0`            | Sampling delle misure Web Vitals, indipendente da `sampleRate`. Decide sul caricamento intero.                                                                                                           |
| `captureResourceErrors`   | `boolean`                         | `false`          | Segnala risorse che non caricano e violazioni CSP come eventi `warning`. URL sempre senza query, max 10 per pagina.                                                                                      |
| `tracePropagationTargets` | `(string \| RegExp)[]`            | `[]`             | Allowlist delle destinazioni verso cui `getTraceHeaders(target)` propaga il trace W3C. Nel browser il same-origin è sempre fidato. Vedi «Tracing distribuito».                                           |
| `maxQueue`                | `number`                          | `30`             | Invii HTTP in volo prima di scartare (fire-and-forget, mai backpressure).                                                                                                                                |
| `autoInstall`             | `boolean`                         | `true`           | Installa le integrazioni della piattaforma. Disattivabile per test/setup custom.                                                                                                                         |
| `debug`                   | `boolean`                         | `false`          | Log diagnostici interni su console.                                                                                                                                                                      |
| `enabled`                 | `boolean`                         | `true`           | Master switch: con `false` l'SDK è interamente no-op.                                                                                                                                                    |

Servono sempre `endpointUrl` e `projectId`, più **una credenziale valida per l'ambiente**: `token`
(bearer segreto) lato server, `publicKey` (DSN public key) nel browser. Se manca (o `enabled: false`),
l'SDK **resta no-op senza sollevare**: tutte le chiamate sono innocue. Nel browser, passare **solo** il
bearer segreto lascia l'SDK no-op (con `console.warn`) — non lo espone mai. Una `init` valida
successiva subentra a una configurazione incompleta. Ripetere `init` con **opzioni diverse
riconfigura l'SDK a caldo**: il client precedente viene chiuso e svuotato in sicurezza (gli eventi
già in coda partono verso la vecchia configurazione) e la nuova entra subito in uso — con opzioni
equivalenti resta un no-op idempotente e non serve più un `close()` manuale.

> **Usa l'endpoint canonico `www`.** `https://www.closeyour.it` risponde direttamente all'ingest;
> l'apex (`https://closeyour.it`) fa `301 → www` e, per un `POST`, il browser può degradare la
> richiesta a `GET` perdendo il body. Su Node l'SDK segue da solo fino a 2 redirect preservando il
> POST (solo verso lo stesso host o la variante `www`); nel browser punta sempre al `www`.

## Ricette framework

### Nuxt 3 (client + server)

```ts
// plugins/closeyourit.client.ts — errori del browser
import * as CloseYourIt from '@bussolabs/closeyourit-js'

export default defineNuxtPlugin(() => {
  const config = useRuntimeConfig()
  CloseYourIt.init({
    endpointUrl: 'https://www.closeyour.it',
    publicKey: config.public.closeyouritPublicKey, // public config: browser-safe
    projectId: config.public.closeyouritProjectId,
    environment: config.public.appEnv,
    release: config.public.appVersion,
  })
})
```

```ts
// server/plugins/closeyourit.ts — errori SSR/Nitro
import * as CloseYourIt from '@bussolabs/closeyourit-js'

export default defineNitroPlugin((nitroApp) => {
  const client = CloseYourIt.init({
    endpointUrl: 'https://www.closeyour.it',
    token: process.env.CLOSEYOURIT_TOKEN!,
    projectId: process.env.CLOSEYOURIT_PROJECT_ID!,
    environment: process.env.NODE_ENV ?? 'production',
  })

  // withScope isola il request context sul flusso corrente: con richieste
  // concorrenti i dati di una non finiscono nell'errore di un'altra.
  nitroApp.hooks.hook('error', (error, { event }) => {
    client.withScope((scope) => {
      if (event) scope.setRequest({ url: event.path, method: event.method })
      CloseYourIt.captureException(error, { handled: true })
    })
  })
})
```

Nel browser (`closeyourit.client.ts`) usa la **`publicKey`** (DSN public key, non segreta): è
**browser-safe**, quindi va nella `runtimeConfig.public` (esposta al client). Il **bearer segreto
`token`** vive **solo** nel plugin server (`server/plugins/closeyourit.ts`, `process.env` mai
`public`): è SERVER-ONLY e non deve mai finire nel bundle browser né raggiungere il client.

Nel plugin **server** la `release` può essere omessa: su Node l'SDK la **auto-rileva** dal deploy
(tag semver `APP_GIT_TAG`/`GIT_TAG`, poi `KAMAL_VERSION`/`GIT_SHA` come fallback — parità tag-first
con la gem Ruby). Nel **browser** non c'è auto-detect: passa `release` esplicita (build-time inject,
es. `config.public.appVersion`). Passarla comunque su Node ha sempre la precedenza sull'auto-detect.

### Angular (ErrorHandler)

```ts
// closeyourit.error-handler.ts
import { ErrorHandler, Injectable } from '@angular/core'
import * as CloseYourIt from '@bussolabs/closeyourit-js'

@Injectable()
export class CloseYourItErrorHandler implements ErrorHandler {
  handleError(error: unknown): void {
    CloseYourIt.captureException(error, { handled: false })
    console.error(error)
  }
}

// app.config.ts
import { ApplicationConfig } from '@angular/core'
import * as CloseYourIt from '@bussolabs/closeyourit-js'

CloseYourIt.init({
  endpointUrl: 'https://www.closeyour.it',
  publicKey: environment.closeyouritPublicKey, // DSN public key (browser-safe)
  projectId: environment.closeyouritProjectId,
  environment: environment.production ? 'production' : 'development',
})

export const appConfig: ApplicationConfig = {
  providers: [{ provide: ErrorHandler, useClass: CloseYourItErrorHandler }],
}
```

Angular inghiotte gli errori di zona prima che arrivino a `window.onerror`: l'`ErrorHandler` è il
punto giusto. I gestori globali dell'SDK restano utili per gli errori fuori da Angular.

## Privacy e PII

Tre livelli di difesa prima dell'invio:

- **Policy `sendPii` (default off)**: `user` viaggia ridotto a `{id}` (email & co. non partono
  proprio), gli URL sono privati di query string, il referrer è strippato. Con `sendPii: true`
  dichiari esplicitamente l'intento di inviare l'utente intero e gli URL completi.
- **Scrubbing automatico**: i valori le cui chiavi combaciano con la denylist sensibile
  (`pass`, `secret`, `token`, `api_key`, `authorization`, `cookie`, `csrf`, `credit`, `card`,
  `cvv`, `ssn`, `iban`, `email`) vengono sostituiti con `[FILTERED]` in modo ricorsivo, in tutto
  ciò che viene serializzato (tag, extra, context, attributi dei log, `data` dei breadcrumb,
  argomenti di `measure`). Le assegnazioni `chiave_sensibile=valore` dentro ai messaggi di errore
  sono redatte anch'esse. La denylist è un superset di quella del backend (aggiunge `email`); il
  backend ri-scruba comunque.
- **`beforeSend`**: hook finale sotto il tuo controllo — muta il payload o ritorna `null` per
  scartarlo del tutto. Vale per errori, messaggi, log e metriche.

### Session replay: cosa viene mascherato

Il replay parte già mascherato, e copre sia quello che l'utente digita sia quello che la pagina
gli mostra — in un gestionale i dati sensibili sono soprattutto i secondi (nomi di clienti,
pratiche, importi):

| Opzione                  | Default | Effetto                                                                                         |
| ------------------------ | ------- | ----------------------------------------------------------------------------------------------- |
| `replayMaskAllInputs`    | `true`  | Maschera il contenuto di tutti i campi da compilare.                                            |
| `replayMaskTextSelector` | `'*'`   | Maschera il testo di **tutti** gli elementi: `*` è il selettore rrweb che li fa matchare tutti. |
| `replayBlockSelector`    | —       | Esclude dalla registrazione gli elementi che combaciano: non vengono proprio catturati.         |

**Mostrare qualcosa in chiaro è un opt-out esplicito**: si passa un selettore più stretto, che
restringe il masking a quei soli elementi. Chi imposta `replayMaskTextSelector: '.riservato'`
maschera solo quella parte e lascia in chiaro tutto il resto — è una scelta da fare in modo
consapevole, non un default che si subisce.

Le registrazioni non sono correggibili a posteriori: un dato registrato in chiaro si toglie solo
cancellando la registrazione. Per questo il default è il più stretto.

Lo `User-Agent` custom viaggia solo su Node (nel browser è un forbidden header e allargherebbe la
preflight CORS).

## Contratto wire

`POST` con `Content-Type: application/json`. L'**autenticazione dipende dall'ambiente**:

- **Server (Node/SSR)** — `Authorization: Bearer <token>` (bearer segreto) sui path `/api/v1/projects/*`.
- **Browser** — DSN public key via `?sentry_key=<publicKey>` (nessun header di auth). Gli
  **errori/messaggi** vanno al drop-in Sentry `POST {endpointUrl}/api/{projectId}/store` (stesso
  payload evento); gli altri canali usano gli stessi path `/api/v1/projects/*` con `?sentry_key=`.

| Cosa              | Endpoint (server, bearer)                                   | Payload                                     |
| ----------------- | ----------------------------------------------------------- | ------------------------------------------- |
| Errori / messaggi | `POST {endpointUrl}/api/v1/projects/{projectId}/events`     | oggetto evento (formato Sentry)             |
| Metriche          | `POST {endpointUrl}/api/v1/projects/{projectId}/metrics`    | `kind: slow_method \| performance_issue`    |
| Log               | `POST {endpointUrl}/api/v1/projects/{projectId}/logs`       | array di voci (chunk ≤ 1000 per richiesta)  |
| Web Vitals        | `POST {endpointUrl}/api/v1/projects/{projectId}/web_vitals` | array di misure (`metric`, `value`, pagina) |

Nel browser gli errori/messaggi passano invece dal drop-in Sentry
`POST {endpointUrl}/api/{projectId}/store`. Campi `snake_case`, formato Sentry (`event_id`,
`exception.values[]`, `contexts`, `sdk`, `trace_id`). Il backend risponde `2xx`. Nel browser gli invii
usano `fetch(keepalive: true)` (sotto la quota di 64 KB); il transport non solleva mai e non fa retry
loop. Per la visibilità degli eventi scartati (`getStats()` e `onDiagnostic`) vedi **Diagnostica
locale**.

**Scheda che si chiude.** Da `pagehide`/`visibilitychange(hidden)` in poi l'invio browser passa da
`navigator.sendBeacon`: `fetch(keepalive)` non sopravvive alla chiusura della scheda su Safari iOS e
l'ultima misura resterebbe a terra. Il beacon vale solo in modalità **DSN public key** (non potendo
impostare header, con il bearer partirebbe senza credenziale) e manda il body come
`Content-Type: text/plain` — con `application/json` non sarebbe una richiesta CORS-simple e
scatenerebbe una preflight a cui un invio in chiusura pagina non sopravvive; il body resta il
medesimo JSON, che il backend parsa dalla richiesta grezza. Dove il beacon manca, o rifiuta il
payload perché la quota per-origin è esaurita, l'invio ripiega su `fetch(keepalive)`. Il beacon non
restituisce una risposta: quegli invii contano come `sent` (presi in carico dal browser) e il loro
evento diagnostico `send` non porta uno `status`, che arriva comunque **dopo** l'`enqueue` come nel
ramo `fetch`. Al ritorno in primo piano (`pageshow` da bfcache) si torna al `fetch`, la cui risposta
è ciò su cui si regge la diagnostica.

Le richieste **già partite** col `fetch` quando la pagina si chiude non vengono rispedite col beacon:
le duplicherebbe ogni volta che il `fetch` arriva comunque, e un dato contato due volte è un danno
certo contro una perdita solo possibile. Su quelle vale `keepalive`, per quel che può.

> **CORS**: l'ingest di CloseYourIt espone CORS sugli endpoint `/api/v1/projects/*` e sul drop-in
> Sentry `/api/:id/{store,envelope}` (`Authorization` + `Content-Type`, `POST`/`OPTIONS`). Nessun
> cookie/credenziale di sessione è coinvolto: l'auth è il **Bearer** (server) o la **DSN public key**
> in query `?sentry_key=` (browser). La public key **non è segreta** — la query non allarga la
> preflight (solo `Content-Type`); l'abuso dal browser è mitigato lato backend da rate limit e
> allowlist di Origin, non dalla segretezza della chiave. Gli stessi endpoint accettano anche un body
> `text/plain` con dentro il medesimo JSON: è la forma che usa il beacon di chiusura pagina, l'unica
> che non scatena una preflight.

## Diagnostica locale

Il transport è fire-and-forget: un evento può non partire senza che l'applicazione se ne accorga (coda
piena, `beforeSend` che lo scarta, timeout, `4xx`/`5xx`, rete assente). Due strumenti **process-local**
lo rendono visibile senza generare nuova telemetria.

`getStats()` ritorna lo snapshot dei contatori: gli aggregati più la ripartizione `reasons` per motivo.

```ts
CloseYourIt.getStats()
// {
//   enqueued: 12, sent: 10, dropped: 2, failed: 2, timeout: 1,
//   reasons: { sample_rate: 0, before_send: 1, error: 0, queue_full: 1, response: 1, network: 0 }
// }
```

`dropped` è la somma dei motivi di scarto client-side (`sample_rate`, `before_send`, `error`,
`queue_full`); `failed` la somma dei fallimenti d'invio (`response`, `network`) **più** i `timeout`
(contati anche nell'aggregato `timeout`). `enqueued` conta gli invii accettati in coda — che poi
diventano `sent` o `failed`. Nell'esempio: 12 accodati → 10 spediti, 1 rifiutato (`response`) e 1 in
`timeout`; a parte, 1 scartato da `before_send` e 1 da coda piena.

`onDiagnostic(event, details)` (opzione di `init`) riceve ogni tappa del ciclo di vita di un evento in
tempo reale:

```ts
CloseYourIt.init({
  // …
  onDiagnostic: (event, details) => {
    // event: 'enqueue' | 'send' | 'drop' | 'timeout' | 'shutdown'
    // details: { reason?, status?, error?, path?, batch?, stats? }
    if (event === 'drop') console.warn('[telemetria] evento scartato:', details.reason)
  },
})
```

| `event`    | quando                                   | `details`                                                   |
| ---------- | ---------------------------------------- | ----------------------------------------------------------- |
| `enqueue`  | evento accodato al transport             | `path`, `batch` (per i log)                                 |
| `send`     | risposta `2xx`                           | `status`                                                    |
| `drop`     | evento scartato o rifiutato              | `reason` (+ `status` per `response`, `error` per `network`) |
| `timeout`  | timeout di rete (sotto-conteggio failed) | `error` (nome classe)                                       |
| `shutdown` | `close()` del client                     | `stats` (snapshot finale)                                   |

I `reason` di `drop`: `sample_rate` (campionamento), `before_send` (hook che ritorna `null`), `error`
(hook che solleva), `queue_full` (coda piena), `response` (`4xx`/`429`/`5xx`, vedi `status`), `network`
(errore di rete o `fetch` assente).

L'hook è **non ricorsivo**: mentre è in esecuzione le API di cattura sono no-op e un ulteriore evento
diagnostico è soppresso, così consultare la diagnostica non genera mai nuovi eventi CloseYourIt. I
`details` contengono solo metadati (mai payload, header o segreti): `error` è il **nome** della classe
d'errore, mai il messaggio. Un'eccezione sollevata dall'hook viene assorbita.

## Budget e truncation

Il servizio rifiuta con `413` i payload troppo grandi (contratto condiviso: envelope decompresso ≤ 5
MB, compresso ≤ 1 MB; batch log ≤ 1000, replay ≤ 50 chunk). Senza un budget lato client un evento con
breadcrumb/contesti enormi, un batch di log o un chunk di replay verrebbe **perso interamente**. Prima
di ogni invio l'SDK misura i **byte UTF-8** del payload serializzato (non i caratteri: un emoji è 1
carattere ma 4 byte) e, se supera il budget conservativo per canale (`512 KiB` per richiesta,
ampiamente sotto i limiti del servizio e di un gateway trusted), lo **riduce** invece di perderlo. Il
payload ridotto porta `truncated: true`.

La riduzione toglie **prima i contenuti meno importanti**, preservando identità ed errore principale:

| Canale            | Strategia di truncation                                                                                                                                                                                                                                                         |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Errori / messaggi | Droppa in ordine: breadcrumb (i più vecchi) → `request` → contexts custom (tiene `runtime`/`os`/`app`/`device`/`replay`) → `extra` → `tags` → frame dello stacktrace → infine tronca il `value` dell'errore. `event_id`, `level`, `timestamp`, `exception.type` restano sempre. |
| Log               | Ogni log troppo grande perde prima gli `attributes`, poi il `message` è troncato; il batch è spezzato in più richieste sotto budget e sotto il cap di conteggio, **senza perdere nessuna voce**.                                                                                |
| Replay            | Droppa gli eventi rrweb dalla coda tenendo il prefisso in ordine (timeline continua dall'inizio); `replay_session_id`/`seq`/`started_at` restano.                                                                                                                               |
| Metriche/pageview | Campi già piccoli a monte; una rete generica tronca comunque `label`/`url`/`route`/`path`/`referrer`/`utm` e rimuove i valori custom (anche quelli iniettati da `beforeSend`) se il payload sfora il budget.                                                                    |

## Dimensione bundle

| Output          | File                      | Dimensione                                         |
| --------------- | ------------------------- | -------------------------------------------------- |
| IIFE minificato | `dist/closeyourit.min.js` | **~20 KB gzip, ~18 KB brotli** (tetto in CI)       |
| ESM             | `dist/index.mjs`          | per bundler (tree-shakeable, `sideEffects: false`) |
| CJS             | `dist/index.js`           | per ambienti CommonJS                              |
| Tipi            | `dist/index.d.ts`         | TypeScript                                         |

## Sviluppo

Toolchain pinnata con [mise](https://mise.jdx.dev) (`mise.toml`): Node 24 + pnpm 11.

```bash
mise install
mise exec -- pnpm install
mise exec -- pnpm format:check # prettier (gate anche in CI)
mise exec -- pnpm lint         # tsc --noEmit (strict)
mise exec -- pnpm build        # tsup -> dist/ (ESM + CJS + IIFE + tipi)
mise exec -- pnpm size         # peso del bundle browser contro bundle-budget.json (dopo build)
mise exec -- pnpm test         # vitest (jsdom + node), con gate coverage
mise exec -- pnpm smoke        # npm pack + smoke del pacchetto (ESM/CJS/browser/tipi)
```

I test girano in jsdom (browser) e in ambiente node (`*.node.test.ts`) con `fetch` mockato; la
coverage v8 su `src/**` ha un gate (linee/statement/funzioni ≥ 90 %, branch ≥ 85 %) — le soglie
sono un ratchet, si alzano soltanto.

### Verifica del pacchetto pubblicato

La suite esercita `src/**`; non basta a garantire che l'**archivio npm** sia consumabile. Lo smoke
(`scripts/smoke-package.mjs`, script `pnpm smoke`) prova il pacchetto come un consumatore reale:
`npm pack`, install in un progetto **pulito** (nessuna dependency oltre al tarball), poi `require`
**CommonJS**, `import` **ESM**, load del bundle **browser** (IIFE, globale `CloseYourIt`) e
risoluzione dei **tipi** (`tsc --moduleResolution nodenext`); verifica inoltre che il tarball non
contenga sorgenti o test e che nessun target dichiarato (`main`/`module`/`types`/`exports`/CDN)
punti a un file mancante. In CI il **consumo** (require/import/browser) viene eseguito sul Node
**20.16** (minimo di `engines`) e su quello corrente — mentre build, pack e type-check restano sulla
toolchain pinnata (pnpm richiede Node ≥22.13); il binario del Node target si passa via
`SMOKE_CONSUMER_NODE`. Se il pacchetto costruito non è consumabile, la **release è bloccata**
(`publish` dipende da questo gate). Il contratto statico del manifest è blindato anche dalla suite
(`test/packageManifest.node.test.ts`).

## Licenza

[MIT](./LICENSE) © 2026 Alessio Bussolari
