From 86c87153b48030c6f6dc1acffc21139702aed535 Mon Sep 17 00:00:00 2001 From: Dylan Garvis Date: Sat, 1 Aug 2026 17:37:52 -0400 Subject: [PATCH] feat(logging): add structured server diagnostics --- .env.example | 3 + Dockerfile | 5 +- apps/discord-bot/.env.example | 1 + apps/discord-bot/package.json | 1 + apps/discord-bot/src/deploy-commands.ts | 16 +- apps/discord-bot/src/index.ts | 18 +- apps/discord-bot/src/logger.ts | 3 + apps/web/next.config.ts | 1 + apps/web/package.json | 1 + apps/web/src/app/api/velocity/access/route.ts | 6 +- apps/web/src/lib/ip-intelligence.ts | 34 +++- apps/web/src/lib/logger.ts | 3 + design/log.md | 1 + design/us-015-platform-operations.md | 6 +- package-lock.json | 155 ++++++++++++++++++ packages/logging/package.json | 19 +++ packages/logging/src/index.ts | 35 ++++ packages/logging/test/logger.test.ts | 31 ++++ packages/logging/tsconfig.json | 5 + 19 files changed, 331 insertions(+), 13 deletions(-) create mode 100644 apps/discord-bot/src/logger.ts create mode 100644 apps/web/src/lib/logger.ts create mode 100644 packages/logging/package.json create mode 100644 packages/logging/src/index.ts create mode 100644 packages/logging/test/logger.test.ts create mode 100644 packages/logging/tsconfig.json diff --git a/.env.example b/.env.example index ab01b9c..4e17b30 100644 --- a/.env.example +++ b/.env.example @@ -24,3 +24,6 @@ IP_INTELLIGENCE_PROVIDER=proxycheck PROXYCHECK_API_KEY= IP_INTELLIGENCE_CACHE_HOURS=48 BLOCK_HOSTING_IPS=false + +# Structured Pino logging +LOG_LEVEL=info diff --git a/Dockerfile b/Dockerfile index 3e01a44..8f446fa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,6 +10,7 @@ COPY apps/discord-bot/package.json ./apps/discord-bot/package.json COPY packages/auth/package.json ./packages/auth/package.json COPY packages/contracts/package.json ./packages/contracts/package.json COPY packages/database/package.json ./packages/database/package.json +COPY packages/logging/package.json ./packages/logging/package.json COPY packages/minecraft/package.json ./packages/minecraft/package.json COPY packages/network/package.json ./packages/network/package.json RUN npm ci @@ -25,6 +26,7 @@ LABEL org.opencontainers.image.title="Minecraft Account Manager" \ org.opencontainers.image.source="https://git.garvis.dev/dmg/minecraft-account-manager" WORKDIR /app ENV NODE_ENV=production \ + APP_VERSION=${VERSION} \ HOSTNAME=0.0.0.0 \ PORT=3000 RUN addgroup --system app && adduser --system --ingroup app app @@ -40,7 +42,8 @@ LABEL org.opencontainers.image.title="Minecraft Account Manager Discord Bot" \ org.opencontainers.image.version="${VERSION}" \ org.opencontainers.image.source="https://git.garvis.dev/dmg/minecraft-account-manager" WORKDIR /app -ENV NODE_ENV=production +ENV NODE_ENV=production \ + APP_VERSION=${VERSION} RUN addgroup --system app && adduser --system --ingroup app app COPY --chown=app:app package.json package-lock.json tsconfig.base.json ./ COPY --chown=app:app apps/discord-bot ./apps/discord-bot diff --git a/apps/discord-bot/.env.example b/apps/discord-bot/.env.example index 363a03f..4d7d543 100644 --- a/apps/discord-bot/.env.example +++ b/apps/discord-bot/.env.example @@ -3,3 +3,4 @@ APP_URL=http://localhost:3000 DISCORD_BOT_TOKEN= DISCORD_APPLICATION_ID= DISCORD_GUILD_ID= +LOG_LEVEL=info diff --git a/apps/discord-bot/package.json b/apps/discord-bot/package.json index ff1ecf8..1be9784 100644 --- a/apps/discord-bot/package.json +++ b/apps/discord-bot/package.json @@ -13,6 +13,7 @@ "@minecraft-account-manager/auth": "*", "@minecraft-account-manager/contracts": "*", "@minecraft-account-manager/database": "*", + "@minecraft-account-manager/logging": "*", "discord.js": "^14.25.1", "dotenv": "^17.2.3", "drizzle-orm": "^0.45.1" diff --git a/apps/discord-bot/src/deploy-commands.ts b/apps/discord-bot/src/deploy-commands.ts index 31fb8a0..000a4aa 100644 --- a/apps/discord-bot/src/deploy-commands.ts +++ b/apps/discord-bot/src/deploy-commands.ts @@ -2,10 +2,22 @@ import "dotenv/config"; import { REST, Routes } from "discord.js"; import { commands } from "./commands"; import { requiredEnvironment } from "./config"; +import { logger } from "./logger"; const token = requiredEnvironment("DISCORD_BOT_TOKEN"); const applicationId = requiredEnvironment("DISCORD_APPLICATION_ID"); const rest = new REST({ version: "10" }).setToken(token); -await rest.put(Routes.applicationCommands(applicationId), { body: commands }); -console.log(`Deployed ${commands.length} global Discord commands.`); +try { + await rest.put(Routes.applicationCommands(applicationId), { body: commands }); + logger.info( + { event: "discord.commands_deployed", commandCount: commands.length }, + "Deployed global Discord commands", + ); +} catch (error) { + logger.fatal( + { err: error, event: "discord.commands_deploy_failed" }, + "Failed to deploy global Discord commands", + ); + process.exitCode = 1; +} diff --git a/apps/discord-bot/src/index.ts b/apps/discord-bot/src/index.ts index 3471606..9832ed1 100644 --- a/apps/discord-bot/src/index.ts +++ b/apps/discord-bot/src/index.ts @@ -14,6 +14,7 @@ import { GatewayIntentBits, } from "discord.js"; import { commandNames, requiredEnvironment } from "./config"; +import { logger } from "./logger"; const token = requiredEnvironment("DISCORD_BOT_TOKEN"); const appUrl = requiredEnvironment("APP_URL"); @@ -24,7 +25,10 @@ const authRepository = createAuthRepository(db); const client = new Client({ intents: [GatewayIntentBits.Guilds] }); client.once(Events.ClientReady, (readyClient) => { - console.log(`Discord bot ready as ${readyClient.user.tag}`); + logger.info( + { event: "discord.ready", botUserId: readyClient.user.id, botUsername: readyClient.user.username }, + "Discord bot is ready", + ); }); client.on(Events.InteractionCreate, async (interaction) => { @@ -71,9 +75,17 @@ client.on(Events.InteractionCreate, async (interaction) => { await interaction.editReply("Please wait 30 seconds before requesting another private account link."); return; } - console.error("Failed to create Discord account link", error); + logger.error( + { err: error, event: "discord.magic_link_failed", command: interaction.commandName }, + "Failed to create Discord account link", + ); await interaction.editReply("I could not create an account link. Please try again shortly."); } }); -await client.login(token); +try { + await client.login(token); +} catch (error) { + logger.fatal({ err: error, event: "discord.login_failed" }, "Discord bot login failed"); + process.exitCode = 1; +} diff --git a/apps/discord-bot/src/logger.ts b/apps/discord-bot/src/logger.ts new file mode 100644 index 0000000..293181f --- /dev/null +++ b/apps/discord-bot/src/logger.ts @@ -0,0 +1,3 @@ +import { createLogger } from "@minecraft-account-manager/logging"; + +export const logger = createLogger("minecraft-account-manager-discord-bot"); diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index 0b7c0a1..5cbfdd1 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -34,6 +34,7 @@ const nextConfig: NextConfig = { transpilePackages: [ "@minecraft-account-manager/contracts", "@minecraft-account-manager/database", + "@minecraft-account-manager/logging", ], }; diff --git a/apps/web/package.json b/apps/web/package.json index 87d10d1..a89c24b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -14,6 +14,7 @@ "@minecraft-account-manager/auth": "*", "@minecraft-account-manager/contracts": "*", "@minecraft-account-manager/database": "*", + "@minecraft-account-manager/logging": "*", "@minecraft-account-manager/minecraft": "*", "@minecraft-account-manager/network": "*", "drizzle-orm": "^0.45.1", diff --git a/apps/web/src/app/api/velocity/access/route.ts b/apps/web/src/app/api/velocity/access/route.ts index bd4d187..8c0fd6c 100644 --- a/apps/web/src/app/api/velocity/access/route.ts +++ b/apps/web/src/app/api/velocity/access/route.ts @@ -14,6 +14,7 @@ import { NextResponse } from "next/server"; import { db } from "@/lib/database"; import { isUniqueConstraintViolation } from "@/lib/database-errors"; import { getIpIntelligence, toAuditIpData } from "@/lib/ip-intelligence"; +import { logger } from "@/lib/logger"; import { problemInstance, problemResponse } from "@/lib/problem-response"; const MAX_CLOCK_SKEW_MS = 45_000; @@ -284,7 +285,10 @@ export async function POST(request: Request) { )); } - console.error("Velocity access request failed"); + logger.error( + { err: error, event: "velocity.access_failed", instance }, + "Velocity access request failed", + ); return problemResponse(problemDetails( "urn:error:service-unavailable", "Service unavailable", diff --git a/apps/web/src/lib/ip-intelligence.ts b/apps/web/src/lib/ip-intelligence.ts index 3e9ca67..984a0bf 100644 --- a/apps/web/src/lib/ip-intelligence.ts +++ b/apps/web/src/lib/ip-intelligence.ts @@ -12,6 +12,7 @@ import { ipIntelligence } from "@minecraft-account-manager/database"; import { and, eq, gt } from "drizzle-orm"; import { headers } from "next/headers"; import { db } from "@/lib/database"; +import { logger } from "@/lib/logger"; const classifications = new Set([ "unknown", @@ -77,6 +78,13 @@ export async function getIpIntelligence( options: { now?: Date; forceRefresh?: boolean } = {}, ): Promise { if (!isPublicIp(ipAddress)) { + logger.warn( + { + event: "ip_intelligence.skipped", + reason: "non_public_address", + }, + "IP intelligence lookup skipped for a non-public client address", + ); return { classification: "unknown", provider: null }; } @@ -96,14 +104,23 @@ export async function getIpIntelligence( const result = await provider.classify(ipAddress); await cacheResult(ipAddress, result, now, cacheHours() * 60 * 60_000); return result; - } catch { + } catch (error) { + const providerName = process.env.IP_INTELLIGENCE_PROVIDER?.trim().toLowerCase() || null; const result: IpIntelligenceResult = { classification: "unknown", - provider: process.env.IP_INTELLIGENCE_PROVIDER?.trim().toLowerCase() || null, + provider: providerName, lookupError: true, }; - await cacheResult(ipAddress, result, now, 5 * 60_000).catch(() => undefined); - console.error("IP intelligence lookup failed"); + await cacheResult(ipAddress, result, now, 5 * 60_000).catch((cacheError) => { + logger.error( + { err: cacheError, event: "ip_intelligence.cache_failed", provider: providerName }, + "Failed to cache an IP intelligence lookup error", + ); + }); + logger.error( + { err: error, event: "ip_intelligence.lookup_failed", provider: providerName }, + "IP intelligence lookup failed", + ); return result; } } @@ -134,6 +151,15 @@ export async function checkAccountAdditionNetwork() { const requestHeaders = await headers(); const ipAddress = getClientIp(requestHeaders, process.env.TRUST_PROXY === "true"); if (!ipAddress) { + logger.warn( + { + event: "client_ip.unavailable", + trustProxy: process.env.TRUST_PROXY === "true", + forwardedForPresent: requestHeaders.has("x-forwarded-for"), + realIpPresent: requestHeaders.has("x-real-ip"), + }, + "Client IP address was unavailable for account addition", + ); return { allowed: false as const, reason: "unavailable" as const, diff --git a/apps/web/src/lib/logger.ts b/apps/web/src/lib/logger.ts new file mode 100644 index 0000000..cbfe05c --- /dev/null +++ b/apps/web/src/lib/logger.ts @@ -0,0 +1,3 @@ +import { createLogger } from "@minecraft-account-manager/logging"; + +export const logger = createLogger("minecraft-account-manager-web"); diff --git a/design/log.md b/design/log.md index b0d23f3..904af9c 100644 --- a/design/log.md +++ b/design/log.md @@ -2,6 +2,7 @@ ## 2026-08-01 +* **Extend**: Add shared Pino logging with credential redaction and actionable web and Discord runtime diagnostics. * **Fix**: Build magic-link redirects from the configured public portal URL instead of the reverse proxy's internal request origin. * **Verify**: Confirmed `v1.1.1` left all pre-existing `latest` digests unchanged while publishing versioned artifacts. * **Refine**: Removed mutable `latest` publication so all deployable artifacts use explicit semantic versions. diff --git a/design/us-015-platform-operations.md b/design/us-015-platform-operations.md index 7d03be1..f80aa19 100644 --- a/design/us-015-platform-operations.md +++ b/design/us-015-platform-operations.md @@ -3,7 +3,7 @@ type: User Story title: Deploy and operate the platform securely description: Operators have repeatable builds, migrations, credential provisioning, configuration, and security checks. tags: [operations, security, database, deployment] -timestamp: 2026-08-01T19:46:09Z +timestamp: 2026-08-01T21:37:26Z story_id: US-015 status: verified --- @@ -22,6 +22,7 @@ As a platform operator, I want reproducible deployment and security controls, so - [x] Environment examples document database, Keycloak, Discord, trusted proxy, and ProxyCheck settings without secrets. - [x] The web application sets CSP, framing, MIME, referrer, and permissions headers. - [x] The web runtime provides a dependency-free health endpoint for orchestration probes. +- [x] Web and Discord bot runtimes emit structured Pino logs with credential-field redaction and safe operational context. - [x] npm dependency audit and Semgrep security review complete without findings at the last verified change. - [x] Architecture, Keycloak, API error, security, bot, and Velocity operating documentation is available. @@ -33,10 +34,11 @@ As a platform operator, I want reproducible deployment and security controls, so - [`packages/database/scripts/create-plugin-credential.ts`](../packages/database/scripts/create-plugin-credential.ts) - [`plugins/velocity/build.gradle.kts`](../plugins/velocity/build.gradle.kts) - [`apps/web/next.config.ts`](../apps/web/next.config.ts) +- [`packages/logging/src/index.ts`](../packages/logging/src/index.ts) # Validation -Use `npm test`, `npm run typecheck`, `npm run lint`, `npm run build`, `npm run velocity:build`, `npm audit`, and `npm run design:validate`. +Use `npm test`, `npm run typecheck`, `npm run lint`, `npm run build`, `npm run velocity:build`, `npm audit`, and `npm run design:validate`. Structured logging redaction is covered by [`packages/logging/test/logger.test.ts`](../packages/logging/test/logger.test.ts). # Related Stories diff --git a/package-lock.json b/package-lock.json index c33eccc..d360431 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "@minecraft-account-manager/auth": "*", "@minecraft-account-manager/contracts": "*", "@minecraft-account-manager/database": "*", + "@minecraft-account-manager/logging": "*", "discord.js": "^14.25.1", "dotenv": "^17.2.3", "drizzle-orm": "^0.45.1" @@ -38,6 +39,7 @@ "@minecraft-account-manager/auth": "*", "@minecraft-account-manager/contracts": "*", "@minecraft-account-manager/database": "*", + "@minecraft-account-manager/logging": "*", "@minecraft-account-manager/minecraft": "*", "@minecraft-account-manager/network": "*", "drizzle-orm": "^0.45.1", @@ -1781,6 +1783,10 @@ "resolved": "apps/discord-bot", "link": true }, + "node_modules/@minecraft-account-manager/logging": { + "resolved": "packages/logging", + "link": true + }, "node_modules/@minecraft-account-manager/minecraft": { "resolved": "packages/minecraft", "link": true @@ -2038,6 +2044,12 @@ "url": "https://github.com/sponsors/panva" } }, + "node_modules/@pinojs/redact": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz", + "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", + "license": "MIT" + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.1.tgz", @@ -3810,6 +3822,15 @@ "node": ">= 0.4" } }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -7052,6 +7073,15 @@ "node": "^10.13.0 || >=12.0.0" } }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/openid-client": { "version": "5.7.1", "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-5.7.1.tgz", @@ -7220,6 +7250,43 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pino": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz", + "integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==", + "license": "MIT", + "dependencies": { + "@pinojs/redact": "^0.4.0", + "atomic-sleep": "^1.0.0", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^3.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^4.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz", + "integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==", + "license": "MIT", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz", + "integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==", + "license": "MIT" + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -7317,6 +7384,22 @@ "integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==", "license": "MIT" }, + "node_modules/process-warning": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.1.0.tgz", + "integrity": "sha512-jQSaVHsPgtyw60e1rQ/A+/ArPEj/S8pS/vFnyGa/gYFXrKk/6RuDkoqVDQ5NI5MmS01698ltlAk0NoDBNLujRw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -7360,6 +7443,12 @@ ], "license": "MIT" }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", + "license": "MIT" + }, "node_modules/react": { "version": "19.2.8", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", @@ -7388,6 +7477,15 @@ "dev": true, "license": "MIT" }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", @@ -7600,6 +7698,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -7834,6 +7941,15 @@ "dev": true, "license": "ISC" }, + "node_modules/sonic-boom": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", + "integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==", + "license": "MIT", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -7864,6 +7980,15 @@ "source-map": "^0.6.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/stable-hash": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", @@ -8106,6 +8231,24 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/thread-stream": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", + "integrity": "sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==", + "license": "MIT", + "dependencies": { + "real-require": "^1.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/thread-stream/node_modules/real-require": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz", + "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -9215,6 +9358,18 @@ "typescript": "^5.9.3" } }, + "packages/logging": { + "name": "@minecraft-account-manager/logging", + "version": "0.1.0", + "dependencies": { + "pino": "^10.3.1" + }, + "devDependencies": { + "@types/node": "^25.0.3", + "typescript": "^5.9.3", + "vitest": "^4.1.0" + } + }, "packages/minecraft": { "name": "@minecraft-account-manager/minecraft", "version": "0.1.0", diff --git a/packages/logging/package.json b/packages/logging/package.json new file mode 100644 index 0000000..0d02bc1 --- /dev/null +++ b/packages/logging/package.json @@ -0,0 +1,19 @@ +{ + "name": "@minecraft-account-manager/logging", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { ".": "./src/index.ts" }, + "scripts": { + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "pino": "^10.3.1" + }, + "devDependencies": { + "@types/node": "^25.0.3", + "typescript": "^5.9.3", + "vitest": "^4.1.0" + } +} diff --git a/packages/logging/src/index.ts b/packages/logging/src/index.ts new file mode 100644 index 0000000..6c2aa02 --- /dev/null +++ b/packages/logging/src/index.ts @@ -0,0 +1,35 @@ +import pino, { type DestinationStream, type Logger } from "pino"; + +const redactedPaths = [ + "apiKey", + "authorization", + "password", + "token", + "*.apiKey", + "*.authorization", + "*.password", + "*.token", + "headers.authorization", + "req.headers.authorization", +]; + +export function createLogger( + service: string, + options: { destination?: DestinationStream } = {}, +): Logger { + return pino( + { + level: process.env.LOG_LEVEL?.trim() || "info", + base: { + service, + environment: process.env.NODE_ENV ?? "development", + version: process.env.APP_VERSION ?? "development", + }, + redact: { + paths: redactedPaths, + censor: "[Redacted]", + }, + }, + options.destination, + ); +} diff --git a/packages/logging/test/logger.test.ts b/packages/logging/test/logger.test.ts new file mode 100644 index 0000000..cbc3463 --- /dev/null +++ b/packages/logging/test/logger.test.ts @@ -0,0 +1,31 @@ +import { Writable } from "node:stream"; +import { describe, expect, it } from "vitest"; +import { createLogger } from "../src/index"; + +function captureLog() { + let output = ""; + const destination = new Writable({ + write(chunk, _encoding, callback) { + output += chunk.toString(); + callback(); + }, + }); + return { destination, read: () => JSON.parse(output.trim()) as Record }; +} + +describe("structured application logging", () => { + it("emits service metadata and redacts credential fields", () => { + const capture = captureLog(); + const logger = createLogger("account-manager-test", { destination: capture.destination }); + + logger.info({ token: "secret-token", apiKey: "secret-key", operation: "test" }, "Test event"); + + expect(capture.read()).toMatchObject({ + service: "account-manager-test", + token: "[Redacted]", + apiKey: "[Redacted]", + operation: "test", + msg: "Test event", + }); + }); +}); diff --git a/packages/logging/tsconfig.json b/packages/logging/tsconfig.json new file mode 100644 index 0000000..921c968 --- /dev/null +++ b/packages/logging/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "types": ["node", "vitest/globals"] }, + "include": ["src/**/*.ts", "test/**/*.ts"] +}