Tema
La traccia di una run tracciata
Il report di run dice quanti record sono passati da ogni nodo. La traccia dice quali, e com'erano a ogni passaggio: per i primi N record di ogni source, e per i record che ne derivano, conserva il percorso, cioè nodo, porta, istante e valore all'uscita di ogni nodo. Per i record che vanno in errore fuori da quel campione conserva il cammino, solo nodi e porte, e il valore che ha fatto fallire il nodo. Serve al debug e alle prove manuali di una pipeline.
Una run tracciata è una run normale, lanciata di proposito e solo in locale: scrive sui sink come le altre e produce lo stesso report. Le run del worker non tracciano mai.
Lanciarla
Da riga di comando:
bash
pnpm run etl run orders-enriched-daily --trace # primi 100 record per source
pnpm run etl run orders-enriched-daily --trace --trace-sample 10 # primi 10
pnpm run etl run orders-enriched-daily --trace --trace-max-records 300 # tetto del campione
pnpm run etl run orders-enriched-daily --trace --trace-max-errors 50 # tetto dei record in erroreIl report va su stdout come sempre; su stderr una riga dice dove è finita la traccia: Traccia salvata in .runs/<runId>.trace.json (N record nel campione)., con in coda , campione esaurito al tetto di M se la run ha raggiunto il tetto, e ; K record in errore fuori dal campione se ce ne sono, con , tetto di E raggiunto se qualcuno è rimasto fuori. --trace-sample, --trace-max-records e --trace-max-errors senza --trace sono un errore: la traccia salva valori in chiaro e si chiede per nome.
Dall'app desktop si lancia con Esegui con tracciamento, che chiede N e i due tetti e avvisa dei dati in chiaro. L'app chiama etl serve, che esegue la run nel proprio processo e non attraverso il worker:
http
POST /runs
Content-Type: application/json
{
"pipeline": "orders-enriched-daily",
"trace": { "perSource": 10, "maxRecords": 300, "maxErrors": 50 },
"params": { "referenceDate": "2026-10-01" }
}| Risposta | Quando |
|---|---|
202 | { "runId": "…" }: la run è partita. Si segue con GET /runs/:id/events |
400 | corpo non valido: trace manca, perSource, maxRecords o maxErrors non sono interi ≥ 1 (assenti valgono 100, 1000 e 1000), o params non è un oggetto |
403 | la richiesta non viene dalla macchina che esegue etl serve (vedi sotto) |
404 | la pipeline non è fra quelle che il server espone su /graphs |
503 | storico disattivato (ETL_RUNS_DIR=""): la traccia non avrebbe dove andare |
params è facoltativo: diventa ctx.params della run, come l'input_data di una run del worker. Qui si controlla solo che sia un oggetto (altrimenti 400); un parametro di lancio mancante o del tipo sbagliato fa fallire la run con un ConfigError prima di qualunque nodo.
trace è obbligatorio: la rotta esiste per le run tracciate, e le run normali passano da noeva-server-api e dal worker. La run gira come etl run --trace: stato di pipeline locale, report e traccia nello storico, nessuna riga processes. Report e traccia sono su disco prima dell'evento run-completed. Il worker invece rifiuta un processo con la chiave trace nell'input_data: lo chiude failed senza eseguirlo.
Come libreria, il tracer si crea e si mette sul contesto, come il progress:
ts
const tracer = createRunTracer({
perSource: 10,
maxRecords: 300,
maxErrors: 50,
}); // default 100, 1000, 1000
const ctx = createRunContext({ runId, tracer });
try {
report = await pipeline.run(ctx);
} finally {
const trace = tracer.result(); // anche se la run è fallita
}Un tracer serve una run sola: una seconda run sullo stesso tracer non parte. Il core la raccoglie in memoria e non la scrive da nessuna parte; salvarla è del chiamante, come per il report.
Il tracer sta sul contesto, e non è un parametro di runGraph che restituisce la traccia, per una ragione: la CLI esegue le pipeline attraverso RunnablePipeline.run(ctx), che restituisce il report e basta. Sul contesto la traccia arriva al runner senza cambiare quel contratto né le pipeline registrate, e chi non crea un tracer — il worker — non può tracciare.
Il campione tracciato
- I primi N record di ogni source, nell'ordine in cui la source li legge. N vale per source, non per la run: con due source e N = 100 si tracciano fino a 200 record letti.
- Ogni record che deriva da almeno un record del campione, anche solo in parte. Il record cambia id a ogni nodo che lo deriva, e il tracer lo riconosce dai suoi
derivedFrom: l'uscita di unamap, di unlookupo di unsort, un elemento di unexplode, un aggregato digroupBy, l'uscita di un join. route,union,try-catche i fan-out non cambiano il record: lo stesso id prosegue, e su un fan-out il percorso continua su ogni ramo.
Dove finisce un record che deriva
| Il record… | Nella traccia | Conta verso il tetto |
|---|---|---|
deriva da un solo record (un solo derivedFrom), che è nel campione, ed è il primo che il nodo ne ricava (map, sort) | prosegue il percorso dell'antenato: è lui, trasformato | no |
è un altro record ricavato da quell'antenato (il secondo elemento di un explode) | prosegue lo stesso percorso, come il ramo di un fan-out | sì |
| deriva da più record, tutti nello stesso percorso | prosegue quel percorso | sì |
| deriva da record di percorsi diversi (un aggregato, un join fra due tracciati) | apre un percorso suo, senza source | sì |
Un aggregato di groupBy deriva dai record del suo gruppo; un'uscita di join dal record di build e da quello di probe. Basta che uno di loro sia nel campione: un gruppo con un solo record tracciato prosegue il suo percorso, un join fra un ordine non tracciato e un cliente tracciato prosegue quello del cliente. Il percorso di un derivato porta nel primo passaggio gli id da cui deriva (derivedFrom), che rimandano ai passaggi degli altri percorsi.
Dentro una sub-pipeline il percorso prosegue senza salti: i passaggi portano l'id qualificato del nodo eseguito, dal confine d'ingresso dell'istanza (inst) ai nodi interni (inst/per-due) fino al confine d'uscita (inst/doppi), e da lì al nodo a valle. Un record che viola lo schema dell'istanza si ferma al confine d'ingresso, con l'errore.
Un record con un solo antenato è trattato come quell'antenato trasformato anche quando esce da un join o da una stateful: l'uscita di un join left senza corrispondenza, l'aggregato di un gruppo di un solo record. Non conta verso il tetto, ed è giusto così: non aggiunge record, ne cambia uno.
La discendenza è quella che i nodi dichiarano. groupBy, sort, dedup, pivot, chunk, assert e serialize dichiarano i propri antenati (applyWithLineage); una transform stateful che non lo fa cade su statefulLineage del nodo (scheda), che di default è "last": un suo record deriva dall'ultimo record letto, ed entra nel campione solo se quello ci era entrato. Con "all" deriva da tutti i record letti, e con "none" da nessuno, quindi non entra mai.
Il tetto del campione
Il campione ha un tetto, record letti e derivati insieme: default 1000, maxRecords sul tracer, --trace-max-records dalla CLI, trace.maxRecords su POST /runs. Ha un default e non è obbligatorio, a differenza degli altri tetti di memoria: chi lancia una run tracciata non sceglie nodo per nodo quanti derivati produrrà il grafo, e un join con molte corrispondenze o un explode lungo moltiplicano i record.
Raggiunto il tetto, la traccia è a campione esaurito (sampleExhausted: true):
- non entrano più record, né letti né derivati;
- i percorsi già nel campione proseguono fino in fondo: un record che è solo l'antenato trasformato non conta, quindi non trova il tetto;
- la run prosegue senza errori. Il tetto protegge il debug, non i dati.
Un campione che arriva al tetto senza superarlo non è esaurito: lo diventa quando un record resta fuori.
I record in errore fuori dal campione
I primi N record di una source raramente sono quelli sbagliati. Per questo ogni record di una run tracciata porta con sé il proprio cammino: i nodi da cui è uscito e da quale porta, senza valori né istanti.
- Se il record esce bene fuori dal campione, il cammino se ne va con lui: arriva al sink e nessuno lo conserva.
- Se va in errore — sulla dead-letter del nodo o sul ramo
catchdi un guard — e non è nel campione, la traccia lo conserva inerrorRecords: il cammino fino al nodo che ha fallito e il passaggio d'errore, con il valore che ha fatto fallire il nodo. Nessun valore intermedio. - Un record del campione che va in errore resta nel campione, con i valori a ogni passaggio, e non si ripete in
errorRecords.
Il passaggio d'errore ha la stessa forma di quello di un record del campione: sulla dead-letter nodeId è il nodo che ha fallito, sul ramo catch è il try-catch con la porta, e il nodo che ha fallito è in error.sourceNodeId. Un errore senza un record esatto — un guard in circuit-breaker, un sink che fallisce su un intero blocco, una stateful — non ha un cammino da conservare e non entra.
Il tetto dei record in errore
Anche questi hanno un tetto: default 1000, maxErrors sul tracer, --trace-max-errors dalla CLI, trace.maxErrors su POST /runs, il campo Tetto dei record in errore nel dialog dell'app. Ha un default per la stessa regola del campione: chi lancia la run non sa quanti record andranno in errore, e una source sbagliata li manda in errore tutti.
Raggiunto il tetto la traccia non ne aggiunge altri e lo segna con errorsExhausted: true; la run prosegue. Come per il campione, arrivare al tetto senza superarlo non lo segna.
Quanto costa, e dove il cammino è approssimato
Il cammino viaggia sull'envelope del record (TaggedRecord.trail) ed è internato: due record passati per gli stessi nodi e porte condividono lo stesso oggetto. I cammini distinti sono quanti i percorsi possibili nel grafo, non quanti i record, quindi a ogni record costa un puntatore, anche dentro un sort che ne trattiene 48.000.
Quando un record ne fonde altri il cammino li porta tutti, e path elenca ogni passaggio una volta, in un ordine in cui ciascuno segue quelli da cui arriva: due rami che passano dallo stesso union lo mostrano una volta, dopo entrambi.
- l'uscita di un join porta il lato build e il lato probe (un join
leftsenza corrispondenza solo il probe), e così il probe che fa fallire il join su una coppia trovata; - l'uscita di una stateful porta i cammini dei record che ha letto fin lì. Una stateful dichiara i propri antenati per id, e conservare il cammino di ogni id letto costerebbe un elemento per record: se legge da un cammino solo, il caso comune, il cammino è esatto; dopo un
uniondi cammini diversi li porta tutti, anche quelli dei record che non sono finiti in quell'uscita.
Dentro una sub-pipeline il cammino porta gli id qualificati, come il percorso del campione.
Schema (traceSchemaVersion: 3)
jsonc
{
"traceSchemaVersion": 3,
"sampleExhausted": false,
"errorsExhausted": false,
"runId": "b3f7616b-…",
"pipeline": "orders-enriched-daily",
"startedAt": "2026-10-05T14:40:00.000Z",
"finishedAt": "2026-10-05T14:40:00.120Z",
"perSource": 100,
"maxRecords": 1000,
"sampleSize": 3,
"maxErrors": 1000,
"records": [
{
"sourceId": "src",
"seq": 0,
"recordId": "b3f7616b-…:src:0",
"steps": [
{
"nodeId": "src",
"port": null,
"at": "…",
"recordId": "b3f7616b-…:src:0",
"derivedFrom": [],
"value": "{\"id\":\"O1\",\"amount\":10}",
},
{
// Una map deriva: id nuovo, con l'antenato in `derivedFrom`.
"nodeId": "double",
"port": null,
"at": "…",
"recordId": "b3f7616b-…:double:0",
"derivedFrom": ["b3f7616b-…:src:0"],
"value": "{\"id\":\"O1\",\"amount\":20}",
},
{
// Un route smista e basta: stesso record, con la porta scelta.
"nodeId": "by-size",
"port": "default",
"at": "…",
"recordId": "b3f7616b-…:double:0",
"derivedFrom": ["b3f7616b-…:src:0"],
"value": "{\"id\":\"O1\",\"amount\":20}",
},
],
},
{
// Un derivato da record di percorsi diversi: nessuna source, e il primo
// passaggio dice da quali record deriva.
"sourceId": null,
"seq": null,
"recordId": "b3f7616b-…:by-customer:0",
"steps": [
{
"nodeId": "by-customer",
"port": null,
"at": "…",
"recordId": "b3f7616b-…:by-customer:0",
"derivedFrom": ["b3f7616b-…:double:0", "b3f7616b-…:double:1"],
"value": "{\"customer\":\"C1\",\"total\":70}",
},
],
},
],
"errorRecords": [
{
// Un record fuori dal campione, fallito sul contratto dentro un guard:
// il cammino senza valori, poi il passaggio d'errore con il valore.
"recordId": "b3f7616b-…:double:412",
"path": [
{ "nodeId": "src", "port": null },
{ "nodeId": "double", "port": null },
{ "nodeId": "guard", "port": "out" },
{ "nodeId": "by-size", "port": "big" },
],
"failure": {
"nodeId": "guard",
"port": "catch",
"at": "…",
"recordId": "b3f7616b-…:guard:catch:3",
"derivedFrom": ["b3f7616b-…:double:412"],
"value": "{\"id\":\"O413\",\"amount\":96}",
"error": {
"type": "ValidationError",
"sourceNodeId": "contract",
"…": "…",
},
},
},
],
}| Campo | Significato |
|---|---|
traceSchemaVersion | La versione dello schema della traccia, distinta da quella del report. La 2 aggiunge discendenza e tetto, la 3 i record in errore fuori dal campione. |
sampleExhausted | true se la run ha raggiunto il tetto e qualche record è rimasto fuori dal campione. |
errorsExhausted | true se la run ha raggiunto il tetto dei record in errore e qualcuno è rimasto fuori da errorRecords. |
perSource | N: record tracciati per ogni source. |
maxRecords | Il tetto del campione, record letti e derivati insieme. |
sampleSize | I record entrati nel campione, letti e derivati: al più maxRecords. Non è il numero di records[]. |
maxErrors | Il tetto dei record in errore fuori dal campione. |
errorRecords | I record in errore fuori dal campione, al più maxErrors, nell'ordine in cui sono falliti. |
Campo di records[] | Significato |
|---|---|
sourceId, seq | La source che ha letto il record, e la sua posizione nella lettura (da 0). null per un derivato da percorsi diversi, che nasce da un nodo. |
recordId | L'id del record all'inizio del percorso: coniato dalla source, o dal nodo che ha generato il derivato. |
steps | I passaggi, nell'ordine in cui sono avvenuti. Dopo un fan-out i passaggi dei rami si intrecciano: recordId e derivedFrom dicono quale segue quale, nodeId su quale ramo ci si trova. |
Campo di steps[] | Significato |
|---|---|
nodeId | Il nodo da cui il record è uscito. L'ultimo passaggio di un percorso riuscito è il sink che l'ha ricevuto. |
port | La porta d'uscita, sui nodi che ne hanno più d'una: la route scelta da un route, out o catch per un try-catch. null sugli altri nodi e sui sink. |
at | Istante (ISO 8601) dell'uscita. |
recordId, derivedFrom | L'id del record a quel passaggio e gli id da cui deriva. |
value | Il valore all'uscita, in JSON, tagliato a 2000 caratteri con la stessa serializzazione del raw del report (un valore che JSON non sa rendere diventa "[payload non serializzabile]"). Assente se il valore è undefined. |
error | Solo sui passaggi d'errore: tipo, messaggio, nodo che ha fallito e stage, nella forma di caughtErrors del report. |
Campo di errorRecords[] | Significato |
|---|---|
recordId | L'id del record che ha fatto fallire il nodo. |
path | Il cammino fino all'errore: nodeId e port di ogni nodo da cui il record è uscito, ognuno una volta, ciascuno dopo quelli da cui arriva. Niente valori. |
failure | Il passaggio d'errore, nella forma di steps[]: il valore è il record che ha fatto fallire il nodo. |
Un record che fallisce ha un passaggio d'errore, e il suo value è il record che ha fatto fallire il nodo:
- sul ramo
catchdi un guard:nodeIdè iltry-catch,portècatch, eerror.sourceNodeIdè il nodo che ha fallito. I nodi del ramocatchseguono, con ilDeadLettercome valore; - sulla dead-letter del nodo:
nodeIdè il nodo stesso,portènull. Il percorso finisce lì.
Quando l'errore non ha un record esatto — un guard in circuit-breaker, un sink che fallisce su un intero blocco — il DeadLetter non ha raw e il passaggio non ha value. E finisce nel percorso solo se il suo derivedFrom porta a un unico record del campione.
Un record scartato da un filtro semplicemente non ha altri passaggi. Uno che entra in un join o in una stateful e ne esce fuso in un altro si ritrova nel derivedFrom del derivato, con il proprio percorso che si ferma prima.
Il report non cambia
Il report di una run tracciata ha lo stesso schema e gli stessi conteggi di una run normale, e nessun dato della traccia ci entra. Una sola differenza, voluta: la run tracciata accende l'identità di record, e il recordId sintetico degli errori catturati (caughtErrors[].recordId) è quello con lineage invece di quello posizionale. Ha la stessa forma, e porta dritto al percorso del record nella traccia. Dove la run normale non dichiara un recordId (un errore catturato da un guard in circuit-breaker), non lo dichiara nemmeno quella tracciata: il report non guadagna campi.
Anche la run tracciata lanciata da etl serve (POST /runs) passa da runPipelineOnce come etl run: il suo report porta pipelineVersion, release, core e client come ogni altro.
Accendere l'identità di record vuol dire che una run tracciata esegue i path «con recordIds» descritti nelle schede dei nodi (per esempio una transform stateless gira isolata per record), anche se il grafo non li dichiara. Restano fuori gli snapshot dei guard (guardSnapshots): tengono payload in memoria e, al tetto, possono fermare la run, quindi si accendono solo con recordIds dichiarato dal grafo, come prima.
Dove finisce
Nello storico delle run, accanto al report ma separata: <ETL_RUNS_DIR>/<runId>.trace.json (default .runs/, git-ignored). La scrive RunStore.saveTrace, si rilegge con getTrace e si elimina con deleteTrace, che lascia il report dov'è.
- Retention propria e corta:
ETL_TRACES_KEEP(default 20), indipendente daETL_RUNS_KEEP. A ogni traccia salvata le più vecchie oltre la soglia, perstartedAt, se ne vanno. - Quando una run esce dallo storico per la retention dei report, la sua traccia va con lei.
- Con lo storico disattivato (
ETL_RUNS_DIR="") la traccia non si salva, e la CLI lo dice. - Come per il report, una traccia non salvata non cambia l'esito della run.
Il riepilogo di una run (GET /runs, etl runs list) dice se ha ancora una traccia (hasTrace) e se è a campione esaurito (sampleExhausted, sempre false senza traccia). Nella colonna TRACCIA di etl runs list valgono sì e sì, campione esaurito. Il riepilogo non apre la traccia, che può pesare MB: chi lancia la run passa la traccia anche a RunStore.finalize, e l'envelope del report ne tiene il riassunto (StoredRun.trace). Da etl serve:
| Rotta | Risposta |
|---|---|
GET /runs/:id/trace | la traccia; 404 se la run non è tracciata o la traccia non c'è più |
DELETE /runs/:id/trace | 204, il report resta; 404 se non c'era una traccia |
Queste due rotte e POST /runs rispondono solo alla macchina stessa: client sul loopback e, se la richiesta ha un Origin, un'origine locale (localhost, 127.0.0.1, [::1] o la webview Tauri). Le altre ricevono 403, anche una pagina web aperta sulla stessa macchina. Le rotte del report restano aperte come prima, perché il report non porta valori.
Leggerla dall'app desktop
Nell'app la traccia si legge da un nodo e da un errore. Su una run che ha ancora una traccia (hasTrace), il pannello del nodo offre Mostra i record tracciati: solo allora l'app chiede GET /runs/:id/trace, e tiene in memoria la traccia di ogni run scaricata finché l'app resta sul processo. L'elenco del nodo ha i record usciti di lì, quelli che ci sono falliti, anche se un try-catch più a monte li ha catturati, e su un nodo di scarto quelli finiti lì. I record in errore fuori dal campione (errorRecords) non entrano nell'elenco: si aprono da un errore.
Scelto un record, il DAG evidenzia il suo percorso e un pannello ne mostra i passaggi. La traccia non dice per quali edge è passato un record, e l'app li ricava: il passaggio che ne precede un altro è il più recente dello stesso record, o di un suo antenato (derivedFrom), uscito da un nodo con un edge verso questo. Il vincolo dell'edge serve dopo un fan-out, dove i passaggi dei rami si intrecciano con lo stesso recordId. Un errore senza catch accende lo scarto del nodo; uno catturato accende anche il nodo che ha fallito (error.sourceNodeId).
Il percorso si apre dalla root. Lì un'istanza di sub-pipeline è un nodo chiuso: i passaggi con id qualificato diventano un passaggio solo, con il valore a ogni uscita da cui il record è passato.
Il percorso aperto da un errore
Il percorso si apre anche da un errore, con Mostra percorso: accanto agli errori catturati che il report elenca sul try-catch, e nella sezione Errori nella traccia del nodo che ha fallito, che dalla traccia elenca i record rifiutati sulla sua dead-letter e quelli catturati più a valle, nel campione e fuori. Il report conta i rifiutati ma non li elenca: questa lista è l'unico modo di arrivarci uno per uno.
L'errore si riconosce dall'id del record che ha fatto fallire il nodo: caughtErrors[].recordId nel report di una run tracciata, errorRecords[].recordId nella traccia, e nel campione il recordId del passaggio d'errore sulla dead-letter o il suo derivedFrom sul ramo catch. Se la traccia non c'è ancora, il pulsante la chiede e il percorso si apre quando arriva.
| L'errore è… | Il pannello mostra |
|---|---|
| nel campione | il percorso completo del record, con i valori |
in errorRecords | il cammino evidenziato e il passaggio d'errore col valore di fallimento, con una nota: i valori intermedi non sono salvati |
| da nessuna parte | Non è nella traccia, al posto del pulsante; con errorsExhausted dice che la run ha superato il tetto |
senza recordId (circuit-breaker) | che l'errore non ha un record esatto da seguire |
Il cammino non dice quale passo segue quale: l'app collega ogni passo a tutti i passi precedenti da cui un edge del piano porta lì, così un record uscito da un join accende entrambi i lati.
I valori si mostrano in chiaro, con la stessa resa del raw di un errore catturato nel pannello del try-catch. L'app non ha un mascheramento proprio: per i valori conta quello che fa la pipeline, un mask a monte compreso.
Dati personali
A differenza del report, la traccia contiene i valori dei record in chiaro, compresi i dati personali a monte di un mask. È per questo che si chiede per nome, che non esce dalla macchina di chi fa debug, e che si conserva poco. Non va condivisa come si condivide un report.
Non ancora
- La vista interna di un'istanza di sub-pipeline nel percorso: alla root l'istanza è chiusa.

