Become a member!

Monitorare più clienti da un solo processo con ExeWatch

ExeWatch Logo

TL;DR: in un processo che serve più clienti, l’identità del cliente può stare su un’istanza dell’SDK (una per cliente, con la sua coda e il suo StoragePath) oppure sul singolo evento, dentro extra_data. Non può stare su un tag e non può stare in SetCustomerId chiamato per richiesta: customer_id e device viaggiano una volta per batch, non una volta per evento. Il sample completo su DelphiMVCFramework è pubblico.

Qualche giorno fa mi ha scritto uno sviluppatore con una cinquantina di applicazioni Delphi in produzione, tra desktop e servizi. I servizi sono la parte interessante: ognuno parla con più database, ognuno serve due o più clienti, e sulla stessa macchina girano fino a dieci copie dello stesso eseguibile. La sua domanda era una sola:

“Come faccio a essere sicuro che ogni richiesta finisca sotto il cliente a cui appartiene davvero?”

È la domanda giusta, e la risposta non dipende da quanto codice scrivi. Dipende da dove metti l’identità del cliente. Ci sono due posti che funzionano, e uno che sembra funzionare finché il server ha un utente solo.

Il vincolo sta nel payload, non nel tuo codice

Prima di scegliere conviene guardare cosa l’SDK mette davvero su disco. Questo è un file di coda vero, preso da una delle prove di questo articolo e tagliato per stare in una pagina:

{
  "customer_id": "deployment-01",
  "device": { "device_id": "dteti@DANIELETETIPC", "os_type": "windows" },
  "events": [
    {
      "level": "info",
      "message": "ACME-Corp#1",
      "tag": "orders",
      "timestamp": "2026-08-21T15:42:19.300Z",
      "thread_id": 20124,
      "extra_data": { "tenant": "ACME-Corp" }
    }
  ]
}

Guarda dov’è la linea. customer_id e device stanno fuori dall’array: sono proprietà del batch, valgono per tutti gli eventi che partono insieme. Livello, messaggio, tag ed extra_data stanno dentro ogni evento.

Tutto il resto dell’articolo è una conseguenza di quella linea.

Il modo che sembra funzionare

L’istinto, quando arriva una richiesta, è dire all’SDK di chi è: SetCustomerId(Tenant) e via. Ho scritto un banco di prova per misurare cosa succede: un’istanza sola, quattro tenant, quattro thread che partono insieme, duecento eventi a testa, e poi un lettore che riapre la coda su disco e confronta evento per evento il tenant scritto nel messaggio con quello che l’SDK ha effettivamente allegato. SDK Delphi 0.23.0, Delphi 12, Win32.

Con SetCustomerId chiamato per richiesta:

customer_id: 800 eventi in coda, 600 attribuiti al tenant sbagliato (75,0%), 801 file .ewdevice

Il 75% non è sfortuna, è aritmetica: quattro tenant, e ogni batch ne può portare uno solo. Con due clienti per istanza, come nel caso di chi mi ha scritto, sarebbe il 50%.

L’altro numero è quello che non mi aspettavo di trovare così grosso. Cambiare customer_id significa, per l’SDK, che è comparso un cliente nuovo, e un cliente nuovo ha bisogno del suo record di device: 801 file di device info accodati per 800 richieste. Il giro è durato 77 secondi, contro i 32 della stessa identica prova senza cambi di customer. La chiamata sbagliata non produce solo dati sbagliati, produce anche una montagna di lavoro che nessuno ha chiesto.

Seconda idea, più furba: lascio customer_id fermo e metto il tenant in un tag, SetTag('tenant', Tenant), prima di ogni log. Stesso banco di prova, cinque corse:

tag_tenant: 800 eventi, 36 attribuiti al tenant sbagliato (4,5%)
tag_tenant: 800 eventi, 99 attribuiti al tenant sbagliato (12,4%)
tag_tenant: 800 eventi, 125 attribuiti al tenant sbagliato (15,6%)
tag_tenant: 800 eventi, 250 attribuiti al tenant sbagliato (31,3%)
tag_tenant: 800 eventi, 281 attribuiti al tenant sbagliato (35,1%)

I tag appartengono all’istanza e vengono letti nel momento in cui l’evento viene composto: tra il tuo SetTag e il tuo Log un altro thread ha tutto il tempo di cambiarlo. Nota però la cosa peggiore, che non è il 35%: è la distanza tra 4,5 e 35. Questa è una percentuale che scende quando il carico scende, e in sviluppo, con un utente solo che clicca, vale zero. Un difetto che a volte non si vede è più caro di uno che si vede sempre, perché arriva in produzione con la tua benedizione.

Restano due strade buone, e la scelta tra le due è una decisione di prodotto, non di codice.

Strada A: un’istanza per tenant

Se il batch può portare un solo cliente, allora servono tanti produttori di batch quanti sono i clienti. Un registro creato all’avvio tiene un TExeWatch per tenant e li distribuisce a chi serve la richiesta:

function TTenantLoggers.CreateLoggerFor(const ATenantId: string): TExeWatch;
var
  Cfg: TExeWatchConfig;
begin
  Cfg := TExeWatchConfig.Create(FApiKey, ATenantId);
  Cfg.AppVersion := FAppVersion;

  // Due coordinate: la copia in esecuzione e il tenant.
  Cfg.StoragePath := TPath.Combine(TPath.Combine(FQueueRoot, FInstanceName),
    SafeFolderName(ATenantId));

  // L'unità utile è il processo, non il tenant.
  Cfg.DeviceInfo.DeviceId := FInstanceName + '@' + TExeWatchHelper.GetHostname;

  Result := TExeWatch.Create(Cfg);
end;

Quelle due righe di configurazione sono le uniche che non si indovinano.

StoragePath va messo a mano perché di default l’SDK ricava la cartella di coda dalla sola API key (%LOCALAPPDATA%\ExeWatch\pending\<primi 16 caratteri della chiave>), e qui la chiave è una per tutti: la chiave identifica l’applicazione, non la copia e non il cliente. Senza quella riga i tenant finiscono nella stessa cartella, i loro thread di spedizione si contendono gli stessi file, e ogni istanza appena creata passa il suo giro di recovery iniziale su file che le sorelle stanno ancora spedendo. Il problema si moltiplica quando le copie dello stesso server sulla stessa macchina sono dieci, ed è il motivo per cui nel path ci sono due coordinate e non una.

DeviceId va messo a mano perché il default è utente@host: dieci copie sotto lo stesso account di servizio si presenterebbero come un device solo. L’unità che ti serve davvero è il processo. Tutti i tenant dentro un processo condividono il device e si distinguono per il customer_id, che è esattamente la lettura che vuoi durante un incidente: “questa copia sta sbagliando, e sta sbagliando per tre clienti su cinque”.

Il tenant, poi, è una proprietà della richiesta e non del thread. In DelphiMVCFramework si risolve una volta prima del routing e si mette nel contesto, che muore con la richiesta:

procedure TExeWatchTenantMiddleware.OnBeforeRouting(AContext: TWebContext;
  var AHandled: Boolean);
begin
  TenantId := AContext.Request.Headers[TENANT_HEADER];

  if not FLoggers.IsKnownTenant(TenantId) then    // rifiuta prima di allocare
  begin
    AContext.Response.StatusCode := HTTP_STATUS.BadRequest;
    AHandled := True;
    Exit;
  end;

  AContext.Data[CTX_TENANT_ID] := TenantId;
  FLoggers.ForTenant(TenantId).ClearBreadcrumbs;  // il thread viene dal pool
end;

Il controllo sulla lista dei tenant noti non è pignoleria. Ogni istanza costa un thread, una cartella e un device record: senza quel controllo, chi può scrivere un header decide quanti thread fa girare il tuo processo. Quali clienti serve una copia lo dice la sua configurazione, e tutto il resto è un 400.

Da lì in poi il codice delle action non sa più niente di tenant, perché EW è già il logger del cliente di quella richiesta:

function TOrdersController.GetAll: TObjectList<TOrder>;
begin
  EW.AddBreadcrumb('Listing orders', 'orders');
  EW.StartTiming('orders.list', 'orders');
  try
    ...
  finally
    EW.EndTiming('orders.list');
  end;
end;

Quel finally è l’altra metà dell’igiene dei thread di pool. Breadcrumb e timing sono tenuti per thread, e un thread di server serve una richiesta dopo l’altra: i breadcrumb vengono azzerati solo quando un errore se li porta via, quindi una richiesta finita bene lascia la sua scia addosso al thread, e un timing mai chiuso viene ereditato da chi arriva dopo. Non è una fuga di dati tra clienti, perché ogni tenant ha la sua istanza e quindi i suoi dizionari per thread. È una fuga tra richieste dello stesso cliente, che è abbastanza silenziosa da sopravvivere a lungo.

Un’ultima cosa sullo spegnimento. Liberare un TExeWatch lo chiude e fa un tentativo di spedizione; per aspettare davvero c’è WaitForSending, e il suo timeout va inteso come budget per l’intero processo, non per tenant. Non è una finezza: con la coda che non riusciva a partire, venti secondi a testa su tre tenant sono diventati sessanta secondi per fermare un server, e un service manager un processo che si ferma così lentamente lo ammazza. Chi resta fuori dal budget si scrive comunque il buffer su disco e riparte al giro dopo.

Il modello regge da pochi tenant a qualche decina. Non regge migliaia, e il motivo è quello di sempre: un thread, una cartella e un device record a testa.

Strada B: il tenant sull’evento

L’alternativa è rinunciare a fare del cliente un’entità e farne un dato. Una sola istanza per tutto il server, customer_id che identifica il deployment, e il tenant che viaggia dentro extra_data, l’unica parte del payload che cambia da evento a evento:

function TenantExtra(const ATenant: string): TJSONObject;
begin
  Result := TJSONObject.Create;
  Result.AddPair('tenant', ATenant);
end;

// Log prende la proprietà dell'oggetto: niente Free qui.
EW.Log(llInfo, 'Ordine spedito', 'orders', TenantExtra(TenantId));

Stesso banco di prova di prima, stessi quattro thread concorrenti, unica differenza dove finisce il tenant:

tenant: 800 eventi in coda, 0 attribuiti al tenant sbagliato (0,0%)

Zero, e zero anche ripetendo. Non c’è niente da sincronizzare: il valore viene scritto nell’evento mentre l’evento nasce, sul thread che sta servendo quella richiesta, e nessun altro thread può toccarlo.

Il prezzo è la dimensione cliente. Niente pagina per cliente, niente device per cliente, niente “ultimo contatto” per cliente: gli eventi di un tenant si trovano filtrando un campo, non selezionando un cliente. In compenso il numero di clienti che il processo serve smette di essere un parametro di dimensionamento, e questo per un servizio dove il tenant arriva da un sottodominio, e sono migliaia, è l’unica strada percorribile.

Una trappola sola, e l’ho già mostrata: in questa strada il tenant deve stare negli extra_data dell’evento e non in un tag. Il tag sembra la stessa cosa e non lo è, e la differenza si misura in percentuale di righe attribuite al cliente sbagliato.

Come si sceglie

Contare i tenant aiuta, ma c’è una domanda che decide più in fretta: il cliente è qualcosa che voglio vedere in dashboard come entità, con la sua pagina e la sua storia, oppure è una chiave di partizione dei dati?

Se è un’entità, strada A. È il caso di chi mi ha scritto: due o tre clienti per istanza, ognuno con un contratto, ognuno che quando chiama in assistenza chiama per nome. Se è una chiave, strada B costa meno e non ti mette un tetto.

La scelta non è per sempre, ma cambiare idea non è gratis: il giorno che passi da B ad A i dati vecchi restano dove sono, dentro un customer_id che vale per l’intero deployment.

Quello che vorrei cambiare

Il limite vero non sta nel codice di integrazione, sta nel payload: finché customer_id è del batch, le strade sono due e non tre. Se scendesse sull’evento, un’istanza sola potrebbe attribuire ogni riga al suo cliente, con un thread solo e una coda sola, e questo articolo sarebbe molto più corto. È una modifica della piattaforma, non un workaround da scrivere nel tuo server, ed è in lista.

Nel frattempo, il sample completo della strada A gira, ed è pubblico: un’API DelphiMVCFramework con un processo, una chiave, tre tenant e un pool di thread dove nessun thread possiede un tenant.

github.com/danieleteti/ExeWatchSamples › DelphiSDKSingleProcessMultiTenant

Si avvia da console, si interroga con curl e mostra in dashboard tre clienti che si dividono lo stesso processo. Nel README ci sono i dettagli operativi che qui ho solo accennato: dove mettere le code quando il servizio gira come LocalSystem, come esentare gli health check dal controllo del tenant, e perché il DeviceId deve identificare la copia in esecuzione.

Tutti i numeri di questo articolo vengono da un banco di prova che rilegge la coda su disco e conta le righe attribuite male. Se ti interessa il codice scrivimi: sono un centinaio di righe, e la parte che conta è il lettore, non lo scrittore.



ExeWatch — Monitoring per applicazioni server, desktop e web. Realizzato da bit Time Professionals.

Comments

comments powered by Disqus