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
|
||||
.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).
|
||||
*/
|
||||
export async function sendRelanceJob(jobData: { taskId: string }) {
|
||||
logger.info({ taskId: jobData.taskId }, 'sendRelanceJob: pick-up')
|
||||
|
||||
const task = await RelanceTask.query()
|
||||
.where('id', jobData.taskId)
|
||||
.preload('planStep')
|
||||
.first()
|
||||
if (!task) {
|
||||
logger.warn({ taskId: jobData.taskId }, 'relance task not found, skipping')
|
||||
logger.warn({ taskId: jobData.taskId }, 'sendRelanceJob: task not found, skipping')
|
||||
return
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@ -39,6 +44,10 @@ export async function sendRelanceJob(jobData: { taskId: string }) {
|
||||
.preload('organization')
|
||||
.first()
|
||||
if (!invoice) {
|
||||
logger.warn(
|
||||
{ taskId: task.id, invoiceId: task.invoiceId },
|
||||
'sendRelanceJob: invoice not found, cancelling task'
|
||||
)
|
||||
task.status = 'cancelled'
|
||||
await task.save()
|
||||
return
|
||||
@ -47,6 +56,10 @@ export async function sendRelanceJob(jobData: { taskId: string }) {
|
||||
// Hook critique : la facture peut avoir été payée entre la programmation
|
||||
// et l'exécution. On vérifie avant d'envoyer.
|
||||
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'
|
||||
await task.save()
|
||||
return
|
||||
@ -79,6 +92,17 @@ export async function sendRelanceJob(jobData: { taskId: string }) {
|
||||
}
|
||||
|
||||
// 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({
|
||||
invoice,
|
||||
client: invoice.client,
|
||||
@ -86,6 +110,10 @@ export async function sendRelanceJob(jobData: { taskId: string }) {
|
||||
user,
|
||||
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)
|
||||
await db.transaction(async (trx) => {
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import mail from '@adonisjs/mail/services/main'
|
||||
import logger from '@adonisjs/core/services/logger'
|
||||
import env from '#start/env'
|
||||
import { DateTime } from 'luxon'
|
||||
import { renderTemplate, formatAmountFr, formatDateFr } from '#services/template'
|
||||
@ -112,9 +113,28 @@ export async function sendRelanceEmail({
|
||||
body,
|
||||
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) => {
|
||||
m.from(fromAddress, fromName)
|
||||
.to(client.email, client.name)
|
||||
@ -129,6 +149,17 @@ export async function sendRelanceEmail({
|
||||
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 = {
|
||||
|
||||
@ -4,6 +4,7 @@ import type Invoice from '#models/invoice'
|
||||
import { getQueue } from '#services/queue'
|
||||
import * as clock from '#services/clock'
|
||||
import app from '@adonisjs/core/services/app'
|
||||
import logger from '@adonisjs/core/services/logger'
|
||||
import type { TransactionClientContract } from '@adonisjs/lucid/types/database'
|
||||
|
||||
const RELANCE_QUEUE = 'relances'
|
||||
@ -50,6 +51,14 @@ export async function scheduleRelancesForInvoice(
|
||||
.where('invoice_id', invoice.id)
|
||||
.whereIn('status', ['scheduled', 'sent'])
|
||||
if (alreadyActive.length > 0) {
|
||||
logger.info(
|
||||
{
|
||||
invoiceId: invoice.id,
|
||||
numero: invoice.numero,
|
||||
existingTasks: alreadyActive.length,
|
||||
},
|
||||
'scheduleRelancesForInvoice: tasks déjà actives, no-op'
|
||||
)
|
||||
return alreadyActive
|
||||
}
|
||||
|
||||
@ -126,6 +135,39 @@ export async function scheduleRelancesForInvoice(
|
||||
task.queueJobId = job?.id ?? null
|
||||
await task.save()
|
||||
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
|
||||
|
||||
@ -1,6 +1,52 @@
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import env from '#start/env'
|
||||
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({
|
||||
/**
|
||||
@ -26,16 +72,19 @@ const loggerConfig = defineConfig({
|
||||
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: {
|
||||
targets: [targets.file({ destination: 1 })],
|
||||
},
|
||||
transport: app.inProduction
|
||||
? { targets: [targets.file({ destination: 1 })] }
|
||||
: undefined,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user