feat(api): logs fichier en dev + traces du flow relance/mail
config/logger.ts: en dev, on duplique les logs vers apps/api/storage/logs/app.log via un `multistream` pino (pretty stdout + JSON file en parallèle). Plus fiable que `transport.targets` qui tourne dans un worker thread et fail silencieusement quand le path n'est pas accessible. Logs ciblés sur le pipeline relance pour debug rapide : - relance_scheduler : tâche créée + delaySec + queueJobId - send_relance_job : pick-up / skip / envoi / OK / KO - mail_dispatcher : driver actif (smtp/resend) + send OK / err .gitignore : storage/uploads + storage/logs. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
92a9fac62b
commit
68ed8f2ec6
4
apps/api/.gitignore
vendored
4
apps/api/.gitignore
vendored
@ -24,3 +24,7 @@ yarn-error.log
|
|||||||
|
|
||||||
# Platform specific
|
# Platform specific
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
# Storage (uploads locaux, logs dev)
|
||||||
|
storage/uploads
|
||||||
|
storage/logs
|
||||||
|
|||||||
@ -20,16 +20,21 @@ import logger from '@adonisjs/core/services/logger'
|
|||||||
* - Sinon : envoi de l'email + bump rubis (1 rubis = 10 min libérées).
|
* - Sinon : envoi de l'email + bump rubis (1 rubis = 10 min libérées).
|
||||||
*/
|
*/
|
||||||
export async function sendRelanceJob(jobData: { taskId: string }) {
|
export async function sendRelanceJob(jobData: { taskId: string }) {
|
||||||
|
logger.info({ taskId: jobData.taskId }, 'sendRelanceJob: pick-up')
|
||||||
|
|
||||||
const task = await RelanceTask.query()
|
const task = await RelanceTask.query()
|
||||||
.where('id', jobData.taskId)
|
.where('id', jobData.taskId)
|
||||||
.preload('planStep')
|
.preload('planStep')
|
||||||
.first()
|
.first()
|
||||||
if (!task) {
|
if (!task) {
|
||||||
logger.warn({ taskId: jobData.taskId }, 'relance task not found, skipping')
|
logger.warn({ taskId: jobData.taskId }, 'sendRelanceJob: task not found, skipping')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (task.status !== 'scheduled') {
|
if (task.status !== 'scheduled') {
|
||||||
logger.info({ taskId: task.id, status: task.status }, 'relance task not scheduled, skipping')
|
logger.info(
|
||||||
|
{ taskId: task.id, status: task.status },
|
||||||
|
'sendRelanceJob: task not scheduled (already sent / cancelled), skipping'
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -39,6 +44,10 @@ export async function sendRelanceJob(jobData: { taskId: string }) {
|
|||||||
.preload('organization')
|
.preload('organization')
|
||||||
.first()
|
.first()
|
||||||
if (!invoice) {
|
if (!invoice) {
|
||||||
|
logger.warn(
|
||||||
|
{ taskId: task.id, invoiceId: task.invoiceId },
|
||||||
|
'sendRelanceJob: invoice not found, cancelling task'
|
||||||
|
)
|
||||||
task.status = 'cancelled'
|
task.status = 'cancelled'
|
||||||
await task.save()
|
await task.save()
|
||||||
return
|
return
|
||||||
@ -47,6 +56,10 @@ export async function sendRelanceJob(jobData: { taskId: string }) {
|
|||||||
// Hook critique : la facture peut avoir été payée entre la programmation
|
// Hook critique : la facture peut avoir été payée entre la programmation
|
||||||
// et l'exécution. On vérifie avant d'envoyer.
|
// et l'exécution. On vérifie avant d'envoyer.
|
||||||
if (invoice.status === 'paid' || invoice.status === 'cancelled') {
|
if (invoice.status === 'paid' || invoice.status === 'cancelled') {
|
||||||
|
logger.info(
|
||||||
|
{ taskId: task.id, invoiceId: invoice.id, status: invoice.status },
|
||||||
|
'sendRelanceJob: invoice paid/cancelled between schedule and execution, cancelling task'
|
||||||
|
)
|
||||||
task.status = 'cancelled'
|
task.status = 'cancelled'
|
||||||
await task.save()
|
await task.save()
|
||||||
return
|
return
|
||||||
@ -79,6 +92,17 @@ export async function sendRelanceJob(jobData: { taskId: string }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Envoi normal
|
// Envoi normal
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
taskId: task.id,
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
numero: invoice.numero,
|
||||||
|
to: invoice.client.email,
|
||||||
|
stepOrder: step.order,
|
||||||
|
offsetDays: step.offsetDays,
|
||||||
|
},
|
||||||
|
'sendRelanceJob: envoi mail relance'
|
||||||
|
)
|
||||||
await sendRelanceEmail({
|
await sendRelanceEmail({
|
||||||
invoice,
|
invoice,
|
||||||
client: invoice.client,
|
client: invoice.client,
|
||||||
@ -86,6 +110,10 @@ export async function sendRelanceJob(jobData: { taskId: string }) {
|
|||||||
user,
|
user,
|
||||||
organization: invoice.organization,
|
organization: invoice.organization,
|
||||||
})
|
})
|
||||||
|
logger.info(
|
||||||
|
{ taskId: task.id, invoiceId: invoice.id, numero: invoice.numero },
|
||||||
|
'sendRelanceJob: mail relance envoyé OK'
|
||||||
|
)
|
||||||
|
|
||||||
const sentAt = await clock.now(invoice.organizationId)
|
const sentAt = await clock.now(invoice.organizationId)
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import mail from '@adonisjs/mail/services/main'
|
import mail from '@adonisjs/mail/services/main'
|
||||||
|
import logger from '@adonisjs/core/services/logger'
|
||||||
import env from '#start/env'
|
import env from '#start/env'
|
||||||
import { DateTime } from 'luxon'
|
import { DateTime } from 'luxon'
|
||||||
import { renderTemplate, formatAmountFr, formatDateFr } from '#services/template'
|
import { renderTemplate, formatAmountFr, formatDateFr } from '#services/template'
|
||||||
@ -112,9 +113,28 @@ export async function sendRelanceEmail({
|
|||||||
body,
|
body,
|
||||||
meta: { invoiceId: invoice.id, clientId: client.id, stepOrder: step.order },
|
meta: { invoiceId: invoice.id, clientId: client.id, stepOrder: step.order },
|
||||||
})
|
})
|
||||||
if (captured) return // demo : ne pas envoyer pour de vrai
|
if (captured) {
|
||||||
|
logger.info(
|
||||||
|
{ invoiceId: invoice.id, numero: invoice.numero, to: client.email },
|
||||||
|
'sendRelanceEmail: capturé en mode démo (pas d\'envoi réel)'
|
||||||
|
)
|
||||||
|
return // demo : ne pas envoyer pour de vrai
|
||||||
|
}
|
||||||
|
|
||||||
const mailer = mail.use(env.get('MAIL_DRIVER', 'smtp'))
|
const driver = env.get('MAIL_DRIVER', 'smtp')
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
numero: invoice.numero,
|
||||||
|
to: client.email,
|
||||||
|
from: fromAddress,
|
||||||
|
driver,
|
||||||
|
subjectPreview: subject.slice(0, 80),
|
||||||
|
},
|
||||||
|
'sendRelanceEmail: envoi via driver'
|
||||||
|
)
|
||||||
|
try {
|
||||||
|
const mailer = mail.use(driver)
|
||||||
await mailer.send((m) => {
|
await mailer.send((m) => {
|
||||||
m.from(fromAddress, fromName)
|
m.from(fromAddress, fromName)
|
||||||
.to(client.email, client.name)
|
.to(client.email, client.name)
|
||||||
@ -129,6 +149,17 @@ export async function sendRelanceEmail({
|
|||||||
m.replyTo(user.email, user.fullName ?? user.email)
|
m.replyTo(user.email, user.fullName ?? user.email)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
logger.info(
|
||||||
|
{ invoiceId: invoice.id, numero: invoice.numero, driver },
|
||||||
|
'sendRelanceEmail: send OK'
|
||||||
|
)
|
||||||
|
} catch (err) {
|
||||||
|
logger.error(
|
||||||
|
{ err, invoiceId: invoice.id, numero: invoice.numero, driver },
|
||||||
|
'sendRelanceEmail: échec envoi'
|
||||||
|
)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type CheckinPayload = {
|
type CheckinPayload = {
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import type Invoice from '#models/invoice'
|
|||||||
import { getQueue } from '#services/queue'
|
import { getQueue } from '#services/queue'
|
||||||
import * as clock from '#services/clock'
|
import * as clock from '#services/clock'
|
||||||
import app from '@adonisjs/core/services/app'
|
import app from '@adonisjs/core/services/app'
|
||||||
|
import logger from '@adonisjs/core/services/logger'
|
||||||
import type { TransactionClientContract } from '@adonisjs/lucid/types/database'
|
import type { TransactionClientContract } from '@adonisjs/lucid/types/database'
|
||||||
|
|
||||||
const RELANCE_QUEUE = 'relances'
|
const RELANCE_QUEUE = 'relances'
|
||||||
@ -50,6 +51,14 @@ export async function scheduleRelancesForInvoice(
|
|||||||
.where('invoice_id', invoice.id)
|
.where('invoice_id', invoice.id)
|
||||||
.whereIn('status', ['scheduled', 'sent'])
|
.whereIn('status', ['scheduled', 'sent'])
|
||||||
if (alreadyActive.length > 0) {
|
if (alreadyActive.length > 0) {
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
numero: invoice.numero,
|
||||||
|
existingTasks: alreadyActive.length,
|
||||||
|
},
|
||||||
|
'scheduleRelancesForInvoice: tasks déjà actives, no-op'
|
||||||
|
)
|
||||||
return alreadyActive
|
return alreadyActive
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -126,6 +135,39 @@ export async function scheduleRelancesForInvoice(
|
|||||||
task.queueJobId = job?.id ?? null
|
task.queueJobId = job?.id ?? null
|
||||||
await task.save()
|
await task.save()
|
||||||
created.push(task)
|
created.push(task)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
numero: invoice.numero,
|
||||||
|
stepOrder: step.order,
|
||||||
|
offsetDays: step.offsetDays,
|
||||||
|
sendAt: sendAt.toISO(),
|
||||||
|
delayMs: delay,
|
||||||
|
delaySec: Math.round(delay / 1000),
|
||||||
|
taskId: task.id,
|
||||||
|
queueJobId: task.queueJobId,
|
||||||
|
enqueued: queue !== null,
|
||||||
|
},
|
||||||
|
'scheduleRelancesForInvoice: tâche créée + job BullMQ enqueué'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (created.length === 0) {
|
||||||
|
logger.warn(
|
||||||
|
{ invoiceId: invoice.id, numero: invoice.numero, planId: plan.id, stepCount: steps.length },
|
||||||
|
'scheduleRelancesForInvoice: aucune tâche créée (plan vide ?)'
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
logger.info(
|
||||||
|
{
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
numero: invoice.numero,
|
||||||
|
taskCount: created.length,
|
||||||
|
enqueueEnabled: queue !== null,
|
||||||
|
},
|
||||||
|
'scheduleRelancesForInvoice: terminé'
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return created
|
return created
|
||||||
|
|||||||
@ -1,6 +1,52 @@
|
|||||||
|
import { mkdirSync } from 'node:fs'
|
||||||
import env from '#start/env'
|
import env from '#start/env'
|
||||||
import app from '@adonisjs/core/services/app'
|
import app from '@adonisjs/core/services/app'
|
||||||
import { defineConfig, syncDestination, targets } from '@adonisjs/core/logger'
|
import {
|
||||||
|
defineConfig,
|
||||||
|
syncDestination,
|
||||||
|
targets,
|
||||||
|
multistream,
|
||||||
|
destination,
|
||||||
|
} from '@adonisjs/core/logger'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* En dev on duplique les logs vers `apps/api/storage/logs/app.log` pour
|
||||||
|
* pouvoir grep / tail / partager facilement (notamment quand un job BullMQ
|
||||||
|
* tourne en arrière-plan et qu'on veut voir si l'envoi mail s'est bien fait
|
||||||
|
* sans devoir scroller le terminal).
|
||||||
|
*
|
||||||
|
* En prod on garde uniquement stdout (les logs sont collectés par K8s via
|
||||||
|
* `kubectl logs`).
|
||||||
|
*
|
||||||
|
* Implémentation : on utilise `multistream` (pino) qui écrit en parallèle
|
||||||
|
* - vers la sortie sync pretty (TTY) — la sortie habituelle dev
|
||||||
|
* - vers le fichier app.log en JSON (parseable, grep-able)
|
||||||
|
*
|
||||||
|
* Plus fiable que `transport.targets` qui tourne dans un worker thread et
|
||||||
|
* peut échouer silencieusement quand le path n'est pas accessible.
|
||||||
|
*/
|
||||||
|
const logFilePath = app.makePath('storage/logs/app.log')
|
||||||
|
if (!app.inProduction) {
|
||||||
|
mkdirSync(app.makePath('storage/logs'), { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
const prettyStream = !app.inProduction ? await syncDestination() : null
|
||||||
|
const devDestination =
|
||||||
|
!app.inProduction && prettyStream
|
||||||
|
? multistream([
|
||||||
|
// Sortie console pretty (sync) — c'est la sortie habituelle dev.
|
||||||
|
{ stream: prettyStream },
|
||||||
|
// Fichier JSON ligne — destination pino classique avec append+mkdir.
|
||||||
|
{
|
||||||
|
stream: destination({
|
||||||
|
dest: logFilePath,
|
||||||
|
sync: true,
|
||||||
|
append: true,
|
||||||
|
mkdir: true,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
: undefined
|
||||||
|
|
||||||
const loggerConfig = defineConfig({
|
const loggerConfig = defineConfig({
|
||||||
/**
|
/**
|
||||||
@ -26,16 +72,19 @@ const loggerConfig = defineConfig({
|
|||||||
level: env.get('LOG_LEVEL'),
|
level: env.get('LOG_LEVEL'),
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Use sync destination in non-production for immediate flush.
|
* Dev : multistream (pretty stdout + JSON file).
|
||||||
|
* Prod : pas de destination → AdonisJS bascule sur transport.
|
||||||
*/
|
*/
|
||||||
destination: !app.inProduction ? await syncDestination() : undefined,
|
destination: devDestination,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configure where logs are written.
|
* En prod : un seul transport vers stdout (collecté par K8s).
|
||||||
|
* En dev : pas de transport — le multistream ci-dessus s'occupe de
|
||||||
|
* tout, et mélanger les deux fait que pino ignore le destination.
|
||||||
*/
|
*/
|
||||||
transport: {
|
transport: app.inProduction
|
||||||
targets: [targets.file({ destination: 1 })],
|
? { targets: [targets.file({ destination: 1 })] }
|
||||||
},
|
: undefined,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user