+ );
+}
diff --git a/src/lib/log-capture.ts b/src/lib/log-capture.ts
new file mode 100644
index 0000000..364245e
--- /dev/null
+++ b/src/lib/log-capture.ts
@@ -0,0 +1,229 @@
+/**
+ * CollectFlow — Capture des logs serveur d'un job, pour téléchargement.
+ *
+ * POURQUOI. Les extractions Qlik produisent plusieurs milliers de lignes
+ * (`[qlik-pw][page] …`, timings, diagnostics du modèle). Or ces lignes ne
+ * vivent que dans la sortie standard du conteneur : un terminal les tronque,
+ * et un copier-coller ne rapporte que la fin — c'est-à-dire jamais l'entête,
+ * là où se trouvent la carte du modèle Qlik et le choix des champs. Sans elle,
+ * un diagnostic se fait à l'aveugle.
+ *
+ * COMMENT. `console.log/warn/error` est instrumenté UNE fois au chargement du
+ * module. Tant qu'au moins une capture est ouverte, chaque ligne est recopiée
+ * dans les captures ouvertes, puis transmise telle quelle à la console
+ * d'origine (rien n'est retenu, rien ne change dans les logs du conteneur).
+ *
+ * ⚠️ Deux jobs simultanés partagent la sortie standard du process : leurs
+ * lignes ne portent pas d'identifiant permettant de les démêler. Chaque
+ * capture ouverte reçoit donc TOUTES les lignes de la période — mieux vaut un
+ * peu de bruit qu'une ligne manquante dans un journal de diagnostic.
+ *
+ * Les captures sont écrites sur disque (répertoire temporaire) au fil de l'eau :
+ * un job qui fait tomber le process laisse quand même son journal.
+ */
+
+import { mkdir, readFile, readdir, stat, unlink, writeFile } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+
+/** Répertoire des journaux. Volontairement hors du projet : ce sont des temporaires. */
+const LOG_DIR = join(tmpdir(), "collectflow-logs");
+
+/** Au-delà, on arrête d'accumuler : un job en boucle ne doit pas manger la RAM. */
+const MAX_LIGNES = 200_000;
+/** Une ligne pathologique (dump d'hypercube) est tronquée plutôt que gardée entière. */
+const MAX_LONGUEUR_LIGNE = 8_000;
+/** Nombre de journaux conservés sur disque. */
+const MAX_FICHIERS = 20;
+/** Écriture disque au plus toutes les N ms (le journal complet est réécrit). */
+const FLUSH_MS = 2_000;
+
+interface Capture {
+ id: string;
+ lignes: string[];
+ tronque: boolean;
+ debut: number;
+ flushEnCours: boolean;
+ flushDemande: boolean;
+ dernierFlush: number;
+}
+
+const captures = new Map();
+
+/** Nom de fichier sûr : l'id vient d'un job interne, on le verrouille quand même. */
+function fichierPour(id: string): string {
+ return join(LOG_DIR, `${id.replace(/[^A-Za-z0-9_.-]/g, "_")}.log`);
+}
+
+function horodatage(): string {
+ return new Date().toISOString();
+}
+
+/** Rend une ligne à partir des arguments variadiques de `console.*`. */
+function formater(args: unknown[]): string {
+ return args
+ .map((a) => {
+ if (typeof a === "string") return a;
+ if (a instanceof Error) return `${a.message}\n${a.stack ?? ""}`;
+ try {
+ return JSON.stringify(a);
+ } catch {
+ return String(a);
+ }
+ })
+ .join(" ");
+}
+
+async function flush(capture: Capture): Promise {
+ if (capture.flushEnCours) {
+ capture.flushDemande = true;
+ return;
+ }
+ capture.flushEnCours = true;
+ try {
+ await mkdir(LOG_DIR, { recursive: true });
+ await writeFile(fichierPour(capture.id), capture.lignes.join("\n") + "\n", "utf8");
+ capture.dernierFlush = Date.now();
+ } catch {
+ // Un journal de diagnostic ne doit jamais faire échouer le job qu'il observe.
+ } finally {
+ capture.flushEnCours = false;
+ if (capture.flushDemande) {
+ capture.flushDemande = false;
+ void flush(capture);
+ }
+ }
+}
+
+function ajouter(ligne: string): void {
+ if (captures.size === 0) return;
+ const texte = ligne.length > MAX_LONGUEUR_LIGNE
+ ? ligne.slice(0, MAX_LONGUEUR_LIGNE) + " …[ligne tronquée]"
+ : ligne;
+ const prefixe = `[${horodatage()}] `;
+ for (const capture of captures.values()) {
+ if (capture.lignes.length >= MAX_LIGNES) {
+ if (!capture.tronque) {
+ capture.tronque = true;
+ capture.lignes.push(`${prefixe}[log-capture] limite de ${MAX_LIGNES} lignes atteinte — suite non enregistrée`);
+ }
+ continue;
+ }
+ capture.lignes.push(prefixe + texte);
+ if (Date.now() - capture.dernierFlush >= FLUSH_MS) void flush(capture);
+ }
+}
+
+/**
+ * Instrumente `console` une seule fois, même en développement où le module peut
+ * être réévalué à chaud (le drapeau est porté par `globalThis`).
+ */
+function instrumenterConsole(): void {
+ const g = globalThis as typeof globalThis & { __cfLogCaptureInstalle?: boolean };
+ if (g.__cfLogCaptureInstalle) return;
+ g.__cfLogCaptureInstalle = true;
+
+ const methodes = ["log", "warn", "error", "info"] as const;
+ for (const methode of methodes) {
+ const origine = console[methode].bind(console);
+ console[methode] = (...args: unknown[]) => {
+ try {
+ ajouter(formater(args));
+ } catch {
+ // idem : jamais au détriment du log réel
+ }
+ origine(...args);
+ };
+ }
+}
+
+instrumenterConsole();
+
+/** Supprime les journaux les plus anciens au-delà de `MAX_FICHIERS`. */
+async function purger(): Promise {
+ try {
+ const noms = (await readdir(LOG_DIR)).filter((n) => n.endsWith(".log"));
+ if (noms.length <= MAX_FICHIERS) return;
+ const avecDate = await Promise.all(
+ noms.map(async (nom) => {
+ const s = await stat(join(LOG_DIR, nom)).catch(() => null);
+ return { nom, mtime: s?.mtimeMs ?? 0 };
+ }),
+ );
+ avecDate.sort((a, b) => b.mtime - a.mtime);
+ for (const { nom } of avecDate.slice(MAX_FICHIERS)) {
+ await unlink(join(LOG_DIR, nom)).catch(() => undefined);
+ }
+ } catch {
+ // purge best-effort
+ }
+}
+
+/**
+ * Ouvre une capture. Toutes les lignes écrites sur la console à partir de
+ * maintenant (par n'importe quel code du process, cf. avertissement en tête)
+ * y sont recopiées jusqu'à `stopCapture`.
+ */
+export function startCapture(id: string, entete?: string): void {
+ const capture: Capture = {
+ id,
+ lignes: [],
+ tronque: false,
+ debut: Date.now(),
+ flushEnCours: false,
+ flushDemande: false,
+ dernierFlush: 0,
+ };
+ captures.set(id, capture);
+ capture.lignes.push(`[${horodatage()}] [log-capture] début de capture ${id}`);
+ if (entete) capture.lignes.push(`[${horodatage()}] [log-capture] ${entete}`);
+ void flush(capture);
+ void purger();
+}
+
+/** Ferme la capture et écrit son état final sur disque. */
+export async function stopCapture(id: string, pied?: string): Promise {
+ const capture = captures.get(id);
+ if (!capture) return;
+ const duree = Math.round((Date.now() - capture.debut) / 1000);
+ capture.lignes.push(`[${horodatage()}] [log-capture] fin de capture ${id} — ${capture.lignes.length} lignes, ${duree} s`);
+ if (pied) capture.lignes.push(`[${horodatage()}] [log-capture] ${pied}`);
+ captures.delete(id);
+ await flush(capture);
+}
+
+/**
+ * Contenu d'un journal : la capture en cours si elle est ouverte, sinon le
+ * fichier laissé sur disque. `null` si aucun des deux n'existe.
+ */
+export async function readCapture(id: string): Promise {
+ const capture = captures.get(id);
+ if (capture) return capture.lignes.join("\n") + "\n";
+ try {
+ return await readFile(fichierPour(id), "utf8");
+ } catch {
+ return null;
+ }
+}
+
+/** Journaux disponibles, du plus récent au plus ancien. */
+export async function listCaptures(): Promise> {
+ const resultats: Array<{ id: string; octets: number; modifieLe: string; enCours: boolean }> = [];
+ try {
+ for (const nom of await readdir(LOG_DIR)) {
+ if (!nom.endsWith(".log")) continue;
+ const s = await stat(join(LOG_DIR, nom)).catch(() => null);
+ if (!s) continue;
+ const id = nom.slice(0, -4);
+ resultats.push({
+ id,
+ octets: s.size,
+ modifieLe: new Date(s.mtimeMs).toISOString(),
+ enCours: captures.has(id),
+ });
+ }
+ } catch {
+ // répertoire pas encore créé
+ }
+ return resultats.sort((a, b) => b.modifieLe.localeCompare(a.modifieLe));
+}