-
Notifications
You must be signed in to change notification settings - Fork 7
fix(email): replace console logs with structured logger #30
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -24,25 +24,27 @@ const RETRY_DELAY_MS = 1500; | |||||||||||||||||||
| function sleep(ms: number): Promise<void> { | ||||||||||||||||||||
| return new Promise((r) => setTimeout(r, ms)); | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| /** Basic email format validation (RFC 5322 simplified). */ | ||||||||||||||||||||
| function isValidEmail(email: string): boolean { | ||||||||||||||||||||
| return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| function log(level: "info" | "warn" | "error", message: string, meta?: Record<string, unknown>) { | ||||||||||||||||||||
| /** Basic email format validation (RFC 5322 simplified). */ | ||||||||||||||||||||
| function log( | ||||||||||||||||||||
| level: "info" | "warn" | "error", | ||||||||||||||||||||
| message: string, | ||||||||||||||||||||
| meta?: Record<string, unknown> | ||||||||||||||||||||
| ) { | ||||||||||||||||||||
| const entry = { | ||||||||||||||||||||
| ts: new Date().toISOString(), | ||||||||||||||||||||
| service: "email", | ||||||||||||||||||||
| level, | ||||||||||||||||||||
| message, | ||||||||||||||||||||
| ...meta, | ||||||||||||||||||||
| }; | ||||||||||||||||||||
| if (level === "error") console.error(JSON.stringify(entry)); | ||||||||||||||||||||
| else if (level === "warn") console.warn(JSON.stringify(entry)); | ||||||||||||||||||||
| else console.log(JSON.stringify(entry)); | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| // structured logging hook (safe replacement) | ||||||||||||||||||||
| process.stdout.write(JSON.stringify(entry) + "\n"); | ||||||||||||||||||||
| } | ||||||||||||||||||||
| /* ── rate limiter (in-memory, per-process) ─────────────────────────────── */ | ||||||||||||||||||||
|
|
||||||||||||||||||||
| const rateBucket = { count: 0, resetAt: 0 }; | ||||||||||||||||||||
|
|
@@ -82,7 +84,7 @@ export async function sendEmail(to: string, subject: string, html: string): Prom | |||||||||||||||||||
|
|
||||||||||||||||||||
| // Rate-limit check | ||||||||||||||||||||
| if (!checkRateLimit()) { | ||||||||||||||||||||
| log("warn", "Email rate limit exceeded", { to, subject }); | ||||||||||||||||||||
| log("warn", "Email rate limit exceeded", { to }); | ||||||||||||||||||||
| return { ok: false, error: "Rate limit exceeded — try again shortly" }; | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
|
|
@@ -113,29 +115,43 @@ export async function sendEmail(to: string, subject: string, html: string): Prom | |||||||||||||||||||
|
|
||||||||||||||||||||
| if (res.ok) { | ||||||||||||||||||||
| const data = (await res.json()) as { messageId?: string }; | ||||||||||||||||||||
| log("info", "Email sent", { to, subject, messageId: data.messageId, attempt }); | ||||||||||||||||||||
| log("info", "Email sent", { to, subject }); | ||||||||||||||||||||
| return { ok: true, messageId: data.messageId }; | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| // Non-retryable client errors (4xx except 429) | ||||||||||||||||||||
| if (res.status >= 400 && res.status < 500 && res.status !== 429) { | ||||||||||||||||||||
| const errBody = await res.text(); | ||||||||||||||||||||
| log("error", "Brevo rejected email (non-retryable)", { status: res.status, body: errBody, to }); | ||||||||||||||||||||
| log("error", "Brevo rejected email (non-retryable)", { | ||||||||||||||||||||
| status: res.status, | ||||||||||||||||||||
| body: errBody, | ||||||||||||||||||||
| to | ||||||||||||||||||||
| }); | ||||||||||||||||||||
|
Comment on lines
+125
to
+129
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Avoid logging raw Brevo error bodies.
Proposed fix log("error", "Brevo rejected email (non-retryable)", {
status: res.status,
- body: errBody,
+ bodySnippet: errBody.slice(0, 500),
to
});🤖 Prompt for AI Agents |
||||||||||||||||||||
| return { ok: false, error: `Brevo error ${res.status}` }; | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| lastError = `HTTP ${res.status}`; | ||||||||||||||||||||
| log("warn", `Email attempt ${attempt}/${MAX_RETRIES} failed`, { status: res.status, to }); | ||||||||||||||||||||
| log("warn", `Email attempt ${attempt}/${MAX_RETRIES} failed`, { | ||||||||||||||||||||
| status: res.status, | ||||||||||||||||||||
| to | ||||||||||||||||||||
| }); | ||||||||||||||||||||
| } catch (err) { | ||||||||||||||||||||
| lastError = err instanceof Error ? err.message : String(err); | ||||||||||||||||||||
| log("warn", `Email attempt ${attempt}/${MAX_RETRIES} threw`, { error: lastError, to }); | ||||||||||||||||||||
| log("warn", `Email attempt ${attempt}/${MAX_RETRIES} threw`, { | ||||||||||||||||||||
| error: lastError, | ||||||||||||||||||||
| to | ||||||||||||||||||||
| }); | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| if (attempt < MAX_RETRIES) { | ||||||||||||||||||||
| await sleep(RETRY_DELAY_MS * attempt); | ||||||||||||||||||||
| } | ||||||||||||||||||||
| } | ||||||||||||||||||||
|
|
||||||||||||||||||||
| log("error", "Email send failed after retries", { to, subject, error: lastError }); | ||||||||||||||||||||
| log("error", "Email send failed after retries", { | ||||||||||||||||||||
| to, | ||||||||||||||||||||
| subject, | ||||||||||||||||||||
| error: lastError | ||||||||||||||||||||
| }); | ||||||||||||||||||||
|
Comment on lines
+151
to
+155
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Final failure log still includes This conflicts with the stated goal to omit subject from final-failure logging metadata. Proposed fix log("error", "Email send failed after retries", {
to,
- subject,
error: lastError
});📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||
| return { ok: false, error: `Failed after ${MAX_RETRIES} retries: ${lastError}` }; | ||||||||||||||||||||
| } | ||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win
Harden
log()so metadata cannot corrupt schema or break send flow....metacan overwrite core fields, andJSON.stringify(entry)can throw. Logging should be fail-safe and keep a stable schema.Proposed fix
function log( level: "info" | "warn" | "error", message: string, meta?: Record<string, unknown> ) { - const entry = { - ts: new Date().toISOString(), - service: "email", - level, - message, - ...meta, - }; - - // structured logging hook (safe replacement) - process.stdout.write(JSON.stringify(entry) + "\n"); + const entry = { + ts: new Date().toISOString(), + service: "email", + level, + message, + meta: meta ?? {}, + }; + try { + process.stdout.write(JSON.stringify(entry) + "\n"); + } catch { + process.stdout.write( + JSON.stringify({ + ts: new Date().toISOString(), + service: "email", + level: "error", + message: "Failed to serialize log entry", + }) + "\n" + ); + } }🤖 Prompt for AI Agents