Un core, molti client: la nuova architettura del client JavaScript di Flare
Di recente abbiamo rimodellato il client JavaScript di Flare: da una singola libreria per browser e alcuni pacchetti sottili specifici per framework a una piccola famiglia di pacchetti costruiti su un core condiviso e indipendente dalla piattaforma. In questo post spieghiamo perché l'abbiamo fatto, cosa espone il pacchetto core, come sono costruiti sopra di esso gli SDK per browser e Node, perché i pacchetti React, Vue e Svelte si collocano un livello più in alto e come chiunque possa usare lo stesso core per scrivere un client Flare JS per una piattaforma che non distribuiamo direttamente.
Il diagramma riassume l'intera idea. In blu ci sono le utility del core, che solo chi scrive i client utilizza. In giallo le librerie che l'utente finale installa. In verde i plugin per i bundler per l'upload dei sourcemap, che vivono su un binario a sé e sono fuori dallo scopo di questo post.
Il problema: una sola libreria, troppi presupposti
L'originale @flareapp/js faceva bene una cosa: intercettare gli errori in un browser e segnalarli a Flare. Il problema era che «in un browser» era incorporato in ogni livello. La pipeline degli errori ricorreva a window per agganciare i listener, a fetch per leggere i file sorgente e al DOM e ai cookie per raccogliere il contesto. C'era un unico posto globale in cui accumulare i breadcrumb, perché una scheda del browser ha un solo utente che fa una cosa alla volta.
Se questo funziona per il browser, non funziona in un ambiente Node. Un processo server non ha window, legge il codice sorgente dal disco invece che via HTTP e serve molte richieste concorrenti contemporaneamente. Ognuna di quelle richieste vuole i propri breadcrumb e il proprio contesto, e nulla di tutto ciò deve finire per errore nella richiesta che gira accanto.
Avevamo due opzioni pessime. Forkare la libreria e mantenere due copie della stessa pipeline degli errori, oppure disseminare controlli typeof window === 'undefined' in un codebase che in realtà non dovrebbe interessarsi dell'ambiente o della piattaforma su cui gira. Entrambe le opzioni diventano rapidamente insostenibili, e nessuna delle due lascia spazio a una terza o quarta piattaforma. Così abbiamo deciso di separare la pipeline di segnalazione in un pacchetto core indipendente dalla piattaforma.
La mossa: estrarre un core indipendente dalla piattaforma
Ora @flareapp/core possiede tutto ciò che riguarda la segnalazione di un errore e che non dipende da dove viene eseguito il codice:
- Configurazione: la API key, la versione, lo stage, la sampling rate, la denylist degli URL e le callback degli hook.
- Costruzione del report: trasformare un valore sollevato in un
Reportdi Flare, inclusi l'assemblaggio dello stack trace e l'estrazione dello snippet di codice. - Glows: la sequenza di breadcrumb che porta a un errore.
- Hook: la pipeline
beforeEvaluateebeforeSubmitche ti permette di scartare o riscrivere un report prima che venga inviato. - Sampling, denylisting e il transport
Apiche invia in POST il report finito.
Il core non ha alcuna dipendenza dalla piattaforma. Non tocca mai window, fetch, fs o process. Definisce la forma del lavoro e lascia le parti specifiche della piattaforma come buchi da riempire. La classe Flare nel core è l'intera pipeline, e il suo costruttore è il contratto:
constructor(
public api: Api = new Api(),
private contextCollector: ContextCollector = () => ({}),
private fileReader: FileReader = new NullFileReader(),
private scopeProvider: ScopeProvider = new GlobalScopeProvider(),
) {}
Quei quattro parametri sono le giunture. Tutto ciò che differisce tra un browser e un server viene gestito implementando un provider o un collector personalizzato specifico per la piattaforma.
Cosa lascia fuori il core: le quattro giunture
Questo è il cuore del design. Il core decide cosa succede quando viene segnalato un errore. Il consumatore decide come vengono eseguiti i passaggi specifici della piattaforma, passando le implementazioni di tre piccole interfacce più un transport.
1. ContextCollector: quale contesto raccogliere
export type ContextCollector = (config: Readonly<Config>) => Attributes;
Una semplice funzione. Il core la chiama mentre costruisce ogni report e unisce gli attributi restituiti nel payload. La versione browser legge URL, cookie, header della richiesta e referrer. La versione Node legge metodo HTTP, path, header e informazioni sul processo. Il core non sa né si preoccupa di quale sia.
2. FileReader: come leggere il sorgente per gli snippet
export interface FileReader {
read(url: string): Promise<string | null>;
}
Per mostrare la riga di codice in cui è stato sollevato un errore, il core ha bisogno del file sorgente. Il modo in cui recuperi quel file dipende dalla piattaforma, quindi il core lo chiede attraverso un unico metodo e accetta null quando il sorgente non è disponibile. Il browser lo recupera via HTTP. Node lo legge dal disco. Il pacchetto core mette in cache il risultato ed estrae le righe rilevanti. (Sebbene Flare risolva il codice sul server con i sourcemap, questo file reader è utile per fornire un fallback con il codice minificato quando i sourcemap non sono stati caricati su Flare.)
3. ScopeProvider: cosa significa «scope corrente»
export interface ScopeProvider {
active(): Scope;
}
Questa è la giuntura davvero necessaria per costruire un buon client Node. Uno Scope conserva lo stato mutabile per singola chiamata: i glows, gli attributi personalizzati in sospeso e l'entry point corrente. La classe Flare del core non memorizza mai quello stato direttamente. Chiede sempre al provider qual è lo scope attivo.
Nel browser c'è un solo scope per la pagina, quindi il GlobalScopeProvider predefinito restituisce sempre lo stesso Scope. In Node, una singola istanza di Flare serve molte richieste concorrenti, e ognuna ha bisogno dei propri breadcrumb. Lo scope provider di Node, basato su AsyncLocalStorage, assegna a ogni richiesta in corso il proprio scope, così il contesto non si mescola mai tra richieste concorrenti. Il core resta all'oscuro di tutto: esegue la stessa pipeline e chiama active(), senza alcun concetto di concorrenza.
4. L'Api iniettata: il transport
Il pacchetto core fornisce una semplice Api basata su fetch che invia il report in POST. Poiché è iniettata e non hardcoded, un consumatore può sostituirla: un processo Node potrebbe volere un timeout diverso, un client offline-first potrebbe accodare i report, e così via.
Più una cosa che il core non può fare al posto tuo: agganciare i listener
Il core può costruire e inviare un report una volta che gli passi un errore. Non può decidere quando si verifica un errore, perché intercettare gli errori non gestiti è per natura specifico della piattaforma. Qualcuno deve chiamare window.addEventListener('error', ...) o process.on('uncaughtException', ...). Quel cablaggio vive in ogni client, non nel core.
Come @flareapp/js implementa il core
Il client browser è sottile. Estende la classe Flare del core, riempie le giunture con implementazioni per il browser e collega i listener:
// packages/js/src/index.ts
export class Flare extends CoreFlare {
constructor(
api: Api = new Api(),
contextCollector: ContextCollector = collectBrowser,
fileReader: FileReader = new FetchFileReader(),
scopeProvider: ScopeProvider = new GlobalScopeProvider(),
) {
super(api, contextCollector, fileReader, scopeProvider);
this.setSdkInfo({ name: '@flareapp/js', version: CLIENT_VERSION });
}
}
export const flare = new Flare();
if (typeof window !== 'undefined' && window) {
// @ts-expect-error attach to window
window.flare = flare;
catchWindowErrors();
}
collectBrowser è il ContextCollector. FetchFileReader è il FileReader. Lo scope globale va bene perché una scheda ha un solo utente. catchWindowErrors() aggancia i listener error e unhandledrejection e li indirizza entrambi verso il singleton. Questa è l'intera superficie specifica del browser. Ogni riga della pipeline degli errori vive nel core.
Come @flareapp/node implementa il core
Il client Node segue la stessa forma, ma inietta implementazioni diverse dei driver richiesti. Questo mostra quanto il nuovo approccio sia solido e flessibile:
DiskFileReaderlegge il sorgente dal filesystem invece di recuperarlo via rete.AsyncLocalStorageScopeProviderassegna a ogni richiesta il proprioScopee ripiega su uno scope condiviso quando viene chiamato al di fuori di qualsiasi richiesta. Ecco perché due richieste che segnalano errori nello stesso momento non confondono i rispettivi breadcrumb.- Un
ProcessHandlerManageragganciaprocess.on('uncaughtException')eprocess.on('unhandledRejection'), con un comportamento configurabile per scegliere se segnalare e uscire oppure segnalare e continuare. Prima di uscire, chiamaflare.flush()per attendere che i report in corso finiscano l'invio, così un crash non perde proprio il report che lo spiega.
Il client Node estende inoltre Scope in un NodeScope con contenitori aggiuntivi per la richiesta (metodo, path, header) e per l'utente (id, email).
Come React, Vue e Svelte si appoggiano su JS, non sul core
Ecco una distinzione che il diagramma fa di proposito. React, Vue e Svelte non sono nuove piattaforme. Girano tutti nel browser. Quindi non implementano il core. Dipendono da @flareapp/js come peer dependency e riutilizzano il suo singleton flare:
import { flare } from '@flareapp/js';
Ciò che aggiunge ogni pacchetto per framework è poco e ha la forma del framework: un modo per intercettare gli errori che il framework ingoia prima che raggiungano window.onerror.
@flareapp/reactfornisce un componenteFlareErrorBoundaryche si aggancia agetDerivedStateFromErrorecomponentDidCatch, catturando lo stack dei componenti React e inoltrandolo aflare.@flareapp/vuefornisce un pluginflareVueche installaapp.config.errorHandlere aggiunge il contesto della gerarchia dei componenti e della route.@flareapp/sveltee@flareapp/sveltekitforniscono un componente boundary e hook per gli errori, più la serializzazione delle prop.
Nessuno di essi reimplementa il parsing dello stack trace, la raccolta del contesto o il transport. Intercettano un errore specifico del framework, aggiungono contesto specifico del framework e lo passano allo stesso client browser che usa ogni altra pagina. Ecco perché nel diagramma si collocano un livello sotto JS, non accanto.
Costruisci il tuo client: un client React Native
Per mostrare quanto sia facile costruire un nuovo client, implementeremo un client React Native minimale. React Native esegue JavaScript senza DOM, intercetta gli errori tramite il proprio globale ErrorUtils e ha una singola istanza dell'app con un solo utente. Questo si mappa in modo pulito sulle giunture.
Passo 1: il context collector
import { Platform } from 'react-native';
import type { ContextCollector, Attributes } from '@flareapp/core';
export const collectReactNative: ContextCollector = (): Attributes => ({
'flare.entry_point.type': 'mobile',
'os.type': Platform.OS,
'os.version': String(Platform.Version),
});
Una funzione in ingresso, attributi in uscita. Contrassegniamo l'entry point come mobile e aggiungiamo un po' di contesto sul dispositivo. È tutto ciò che il core richiede.
Passo 2: il file reader
Il FileReader produce lo snippet di codice attorno alla riga che ha fallito, e vale la pena essere precisi su a cosa serva quello snippet. Nel browser, FetchFileReader scarica il bundle minificato a cui punta il frame e invia quello snippet minificato insieme alla riga e alla colonna del frame. Non deminifica nulla sul client. La mappatura verso il tuo sorgente originale avviene sui server di Flare: quando viene caricato un sourcemap per quella build, il backend rimappa riga e colonna di ogni frame e ricostruisce lo snippet dal sourcemap, scartando qualsiasi snippet minificato inviato dal client. Lo snippet prodotto dal client è in realtà un fallback, mostrato solo quando non esiste alcun sourcemap per quella build.
Questo ci dice cosa deve fare un reader per React Native e cosa può saltare. In sviluppo Metro serve il bundle via HTTP, quindi lo recuperiamo esattamente come fa il browser:
import type { FileReader } from '@flareapp/core';
export class MetroFileReader implements FileReader {
async read(url: string): Promise<string | null> {
if (!url.startsWith('http://') && !url.startsWith('https://')) {
return null; // production bundle, leave it to Flare to resolve sourcemaps
}
try {
const response = await fetch(url);
return response.ok ? await response.text() : null;
} catch {
return null;
}
}
}
In una build di produzione il bundle vive sul dispositivo come file bytecode HBC compilato AOT, dal quale è impossibile ottenere uno snippet di fallback, e gli URL dei frame non sono qualcosa che possiamo recuperare via HTTP, quindi read() restituisce null. Va bene: purché tu carichi i sourcemap su Flare, il backend mappa ogni frame al file, alla riga e alla colonna originali partendo dai soli numeri di riga e colonna, e ricostruisce da sé lo snippet originale.
Passo 3: lo scope provider
Un'app mobile ha un solo utente e un solo contesto logico, esattamente come una scheda del browser. Quindi non dobbiamo scrivere affatto un provider. Riusiamo quello che il core già fornisce:
import { GlobalScopeProvider } from '@flareapp/core';
Implementi solo le giunture di cui la tua piattaforma ha davvero bisogno. Il meccanismo AsyncLocalStorage per singola richiesta è una preoccupazione di Node, e chi scrive per React Native non deve pensarci.
Passo 4: assemblare il client
// packages/react-native/src/index.ts
import { Api, Flare as CoreFlare, GlobalScopeProvider } from '@flareapp/core';
import { collectReactNative } from './collectReactNative';
import { MetroFileReader } from './MetroFileReader';
import { PACKAGE_VERSION } from './env';
export class Flare extends CoreFlare {
constructor() {
super(
new Api(),
collectReactNative,
new MetroFileReader(),
new GlobalScopeProvider(),
);
this.setSdkInfo({ name: '@flareapp/react-native', version: PACKAGE_VERSION });
}
}
export const flare = new Flare();
Passo 5: intercettare gli errori
L'unico cablaggio specifico della piattaforma che resta è agganciare il gestore globale degli errori di React Native e inoltrarlo alla pipeline:
// packages/react-native/src/index.ts
const previousHandler = ErrorUtils.getGlobalHandler();
ErrorUtils.setGlobalHandler((error, isFatal) => {
flare.reportSilently(error);
previousHandler?.(error, isFatal);
});
Ecco fatto: un client Flare funzionante e del tutto nuovo. Tre piccoli file, uno dei quali è un singolo import, più una manciata di righe per agganciare l'handler. Tutto il resto, il parsing dello stack trace, i glows, gli hook, il sampling, il transport, arriva dal core senza modifiche.
Perché tutto questo rende economici i nuovi client
Il vantaggio è che la parte costosa, delicata e facile da sbagliare di un error tracker, cioè la pipeline di segnalazione, viene scritta una volta sola e condivisa. Un nuovo client non è più una riscrittura. È qualche implementazione di piccole interfacce:
- Una funzione
ContextCollectorche restituisce il contesto che la piattaforma può offrire. - Un
FileReaderche restituisce il sorgente quando può enullquando non può. - Uno
ScopeProvider, ma solo se «scope corrente» significa qualcosa di diverso da «un unico scope globale», cosa che di solito non accade. - Un po' di colla per intercettare gli errori non gestiti della piattaforma.
Ogni giuntura è comprensibile e testabile in modo indipendente. Puoi ragionare sul context collector senza sapere nulla di stack trace, e puoi sostituire il file reader senza toccare la logica dello scope. È questo isolamento a mantenere i client browser e Node così sottili, ed è ciò che rende possibili molti altri client.
Dobbiamo essere onesti sui limiti. Il core oggi è volutamente snello e non copre ancora tutto ciò che offrono gli altri error tracker in circolazione: la cattura automatica dei breadcrumb, l'identificazione dell'utente integrata su tutte le piattaforme, retry e batching a livello di transport e la deduplicazione dei report sono ancora tutte in roadmap. È l'architettura a rendere attraente e facile aggiungerle: qualunque cosa costruiamo nel core arriva in ogni client in una volta sola, browser, Node, React, Vue, Svelte e qualsiasi cosa decideremo di costruire dopo.
Altri articoli recenti
Laravel AI 1.1 e 1.2: Agent Skills, Cohere e structured output più solidi
Le release 1.1.0 e 1.2.0 di laravel/ai portano gli Agent Skills, l'approvazione obbligatoria per i tool built-in e nuovi provider come Cohere e Laya self-hosted. Sul fronte qualità arrivano i fix allo structured output per Bedrock e il supporto alle provider options. Aggiornati anche i modelli di default, con Claude 4.7+ che ignora i sampling params.
Laravel 13.35.0: scheduler più controllabile, code più flessibili e response Markdown
Laravel 13.35.0 porta Schedule::alwaysOnOneServer(), il supporto a schedule:interrupt per i comandi di lunga durata, --memory in percentuale per queue:work e il nuovo metodo markdown() sulla response factory. Tante piccole migliorie anche a test, view compilate in parallelo e dischi local.
BashBox: un interprete Bash in sandbox per agenti AI in PHP
BashBox è un interprete bash scritto in puro PHP che permette di eseguire gli script generati dagli agenti AI senza mai ricorrere a proc_open, exec o shell_exec. Ogni comando è una classe PHP che lavora su un filesystem virtuale, con limiti di esecuzione, quote disco e accesso di rete configurabili. Ideale per chi sviluppa agenti con il Laravel AI SDK o altre librerie simili.