diff --git a/src/app.js b/src/app.js index 2f545fc..3e13ac7 100644 --- a/src/app.js +++ b/src/app.js @@ -49,7 +49,12 @@ function sanitizeReturnTo(value, fallback = "/app") { return value; } -function buildApp({ config, initialState = null, onMutation = null }) { +function buildApp({ + config, + initialState = null, + onMutation = null, + logger = console, +}) { const engine = new XArtAudioEngine({ creditConfig: config.credit, initialState: initialState && initialState.engine ? initialState.engine : null, @@ -79,8 +84,8 @@ function buildApp({ config, initialState = null, onMutation = null }) { updatedAt: new Date().toISOString(), engine: engine.exportState(), }); - } catch { - // avoid breaking request flow on persistence callback issues + } catch (error) { + logger.error({ err: error }, "failed to persist mutation"); } } @@ -165,6 +170,7 @@ function buildApp({ config, initialState = null, onMutation = null }) { publicLink: result.reply ? result.reply.publicLink : `/audio/${result.job.assetId}`, }); } catch (error) { + logger.warn({ err: error }, "x webhook request failed"); return json(400, { error: error.message }); } } @@ -192,6 +198,7 @@ function buildApp({ config, initialState = null, onMutation = null }) { persistMutation(); return json(200, { status: "credited" }); } catch (error) { + logger.warn({ err: error }, "polar webhook request failed"); return json(400, { error: error.message }); } } diff --git a/src/config.js b/src/config.js index fe42547..fb0510b 100644 --- a/src/config.js +++ b/src/config.js @@ -22,6 +22,7 @@ function strFromEnv(name, fallback) { const parsed = { port: intFromEnv("PORT", 3000), stateFilePath: strFromEnv("STATE_FILE_PATH", "./data/state.json"), + logLevel: strFromEnv("LOG_LEVEL", "info"), xWebhookSecret: process.env.X_WEBHOOK_SECRET || "dev-x-secret", polarWebhookSecret: process.env.POLAR_WEBHOOK_SECRET || "dev-polar-secret", rateLimits: { @@ -41,6 +42,7 @@ const parsed = { const ConfigSchema = z.object({ port: z.number().int().positive(), stateFilePath: z.string().min(1), + logLevel: z.enum(["fatal", "error", "warn", "info", "debug", "trace", "silent"]), xWebhookSecret: z.string().min(1), polarWebhookSecret: z.string().min(1), rateLimits: z.object({ diff --git a/src/lib/logger.js b/src/lib/logger.js new file mode 100644 index 0000000..05998c7 --- /dev/null +++ b/src/lib/logger.js @@ -0,0 +1,18 @@ +"use strict"; + +const pino = require("pino"); + +function createLogger({ level = "info", name = "xartaudio" } = {}) { + return pino({ + name, + level, + base: { + service: name, + }, + timestamp: pino.stdTimeFunctions.isoTime, + }); +} + +module.exports = { + createLogger, +}; diff --git a/src/server.js b/src/server.js index 0ab6792..a0a6a3a 100644 --- a/src/server.js +++ b/src/server.js @@ -4,6 +4,7 @@ const http = require("node:http"); const { buildApp } = require("./app"); const { config } = require("./config"); const { JsonFileStateStore } = require("./lib/state-store"); +const { createLogger } = require("./lib/logger"); function readBody(req) { return new Promise((resolve, reject) => { @@ -68,7 +69,7 @@ function createMutationPersister({ stateStore, logger = console }) { queue = queue .then(() => stateStore.save(state)) .catch((error) => { - logger.error("failed to persist state", error); + logger.error({ err: error }, "failed to persist state"); }); return queue; @@ -87,6 +88,7 @@ async function createRuntime({ runtimeConfig = config, logger = console } = {}) const app = buildApp({ config: runtimeConfig, initialState, + logger, onMutation(state) { void persister.enqueue(state); }, @@ -102,7 +104,11 @@ async function createRuntime({ runtimeConfig = config, logger = console } = {}) } async function start() { - const runtime = await createRuntime({ runtimeConfig: config }); + const logger = createLogger({ + level: config.logLevel, + name: "xartaudio", + }); + const runtime = await createRuntime({ runtimeConfig: config, logger }); const { server, persister } = runtime; let shuttingDown = false; @@ -112,7 +118,7 @@ async function start() { } shuttingDown = true; - console.log(`received ${signal}, shutting down`); + logger.info({ signal }, "received shutdown signal"); server.close(); await persister.flush(); @@ -126,13 +132,17 @@ async function start() { }); server.listen(config.port, () => { - console.log(`xartaudio server listening on :${config.port}`); + logger.info({ port: config.port }, "xartaudio server listening"); }); } if (require.main === module) { start().catch((error) => { - console.error("failed to start server", error); + const logger = createLogger({ + level: config.logLevel, + name: "xartaudio", + }); + logger.error({ err: error }, "failed to start server"); process.exitCode = 1; }); } diff --git a/test/config.test.js b/test/config.test.js index f4e652b..34b3327 100644 --- a/test/config.test.js +++ b/test/config.test.js @@ -7,11 +7,13 @@ test("config uses defaults when env is missing", () => { const previous = { PORT: process.env.PORT, STATE_FILE_PATH: process.env.STATE_FILE_PATH, + LOG_LEVEL: process.env.LOG_LEVEL, WEBHOOK_RPM: process.env.WEBHOOK_RPM, }; delete process.env.PORT; delete process.env.STATE_FILE_PATH; + delete process.env.LOG_LEVEL; delete process.env.WEBHOOK_RPM; delete require.cache[require.resolve("../src/config")]; @@ -19,6 +21,7 @@ test("config uses defaults when env is missing", () => { assert.equal(config.port, 3000); assert.equal(config.stateFilePath, "./data/state.json"); + assert.equal(config.logLevel, "info"); assert.equal(config.rateLimits.webhookPerMinute, 120); if (previous.PORT === undefined) { @@ -33,6 +36,12 @@ test("config uses defaults when env is missing", () => { process.env.STATE_FILE_PATH = previous.STATE_FILE_PATH; } + if (previous.LOG_LEVEL === undefined) { + delete process.env.LOG_LEVEL; + } else { + process.env.LOG_LEVEL = previous.LOG_LEVEL; + } + if (previous.WEBHOOK_RPM === undefined) { delete process.env.WEBHOOK_RPM; } else { @@ -44,11 +53,13 @@ test("config reads state path and numeric env overrides", () => { const previous = { PORT: process.env.PORT, STATE_FILE_PATH: process.env.STATE_FILE_PATH, + LOG_LEVEL: process.env.LOG_LEVEL, WEBHOOK_RPM: process.env.WEBHOOK_RPM, }; process.env.PORT = "8080"; process.env.STATE_FILE_PATH = "/data/prod-state.json"; + process.env.LOG_LEVEL = "debug"; process.env.WEBHOOK_RPM = "77"; delete require.cache[require.resolve("../src/config")]; @@ -56,6 +67,7 @@ test("config reads state path and numeric env overrides", () => { assert.equal(config.port, 8080); assert.equal(config.stateFilePath, "/data/prod-state.json"); + assert.equal(config.logLevel, "debug"); assert.equal(config.rateLimits.webhookPerMinute, 77); if (previous.PORT === undefined) { @@ -69,6 +81,11 @@ test("config reads state path and numeric env overrides", () => { } else { process.env.STATE_FILE_PATH = previous.STATE_FILE_PATH; } + if (previous.LOG_LEVEL === undefined) { + delete process.env.LOG_LEVEL; + } else { + process.env.LOG_LEVEL = previous.LOG_LEVEL; + } if (previous.WEBHOOK_RPM === undefined) { delete process.env.WEBHOOK_RPM; } else { diff --git a/test/logger.test.js b/test/logger.test.js new file mode 100644 index 0000000..626fb39 --- /dev/null +++ b/test/logger.test.js @@ -0,0 +1,12 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { createLogger } = require("../src/lib/logger"); + +test("createLogger returns pino-compatible logger", () => { + const logger = createLogger({ level: "debug", name: "test" }); + assert.equal(typeof logger.info, "function"); + assert.equal(typeof logger.error, "function"); + assert.equal(logger.level, "debug"); +});