Skip to content

Commit 0359655

Browse files
authored
Enhance structured logger for script usage (#60860)
1 parent a0e73c6 commit 0359655

4 files changed

Lines changed: 210 additions & 70 deletions

File tree

src/assets/scripts/list-image-sizes.ts

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,24 +8,41 @@ import { fileURLToPath } from 'url'
88
import path from 'path'
99
import walk from 'walk-sync'
1010
import sharp from 'sharp'
11+
import { createLogger } from '@/observability/logger'
12+
import { toError } from '@/observability/lib/to-error'
13+
14+
const logger = createLogger(import.meta.url)
15+
1116
const __dirname = path.dirname(fileURLToPath(import.meta.url))
1217

13-
const imagesPath = path.join(__dirname, '../assets/images')
18+
const imagesPath = path.join(__dirname, '../../../assets/images')
1419
const imagesExtensions = ['.jpg', '.jpeg', '.png', '.gif']
1520

1621
const files = walk(imagesPath, { directories: false }).filter((relativePath) => {
1722
return imagesExtensions.includes(path.extname(relativePath.toLowerCase()))
1823
})
24+
25+
logger.info('Starting image scan', { path: imagesPath, totalFiles: files.length })
26+
1927
const images = await Promise.all(
2028
files.map(async (relativePath) => {
2129
const fullPath = path.join(imagesPath, relativePath)
22-
const image = sharp(fullPath)
23-
const { width, height } = await image.metadata()
24-
const size = (width || 0) * (height || 0)
25-
return { relativePath, width, height, size }
30+
try {
31+
const image = sharp(fullPath)
32+
const { width, height } = await image.metadata()
33+
const size = (width || 0) * (height || 0)
34+
return { relativePath, width, height, size }
35+
} catch (error) {
36+
logger.warn('Failed to read image metadata', toError(error), { relativePath })
37+
return { relativePath, width: 0, height: 0, size: 0 }
38+
}
2639
}),
2740
)
28-
for (const image of images.sort((a, b) => b.size - a.size)) {
41+
42+
const sorted = images.sort((a, b) => b.size - a.size)
43+
for (const image of sorted) {
2944
const { relativePath, width, height } = image
30-
console.log(`${width} x ${height} - ${relativePath}`)
45+
logger.info(`${width} x ${height} - ${relativePath}`)
3146
}
47+
48+
logger.info('Image scan complete', { totalImages: sorted.length })

src/observability/logger/index.ts

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import path from 'path'
2+
import chalk from 'chalk'
23
import { getLoggerContext } from '@/observability/logger/lib/logger-context'
34
import {
45
getLogLevelNumber,
@@ -8,6 +9,54 @@ import {
89
import { toLogfmt } from '@/observability/logger/lib/to-logfmt'
910
import { POD_IDENTITY } from '@/observability/logger/lib/pod-identity'
1011

12+
const LEVEL_COLORS: Record<keyof typeof LOG_LEVELS, (s: string) => string> = {
13+
error: chalk.red,
14+
warn: chalk.yellow,
15+
info: chalk.cyan,
16+
debug: chalk.gray,
17+
}
18+
19+
function formatTimestamp(): string {
20+
const now = new Date()
21+
const h = String(now.getHours()).padStart(2, '0')
22+
const m = String(now.getMinutes()).padStart(2, '0')
23+
const s = String(now.getSeconds()).padStart(2, '0')
24+
const ms = String(now.getMilliseconds()).padStart(3, '0')
25+
return `${h}:${m}:${s}.${ms}`
26+
}
27+
28+
// Format non-error included context as compact key=value pairs
29+
function formatContext(ctx: Record<string, unknown>): string {
30+
const parts: string[] = []
31+
for (const [key, value] of Object.entries(ctx)) {
32+
if (value instanceof Error) continue // errors handled separately
33+
if (value === undefined || value === null || value === '') continue
34+
let v: string
35+
if (typeof value === 'object') {
36+
try {
37+
v = JSON.stringify(value)
38+
} catch {
39+
v = String(value)
40+
}
41+
} else {
42+
v = String(value)
43+
}
44+
parts.push(`${chalk.dim(`${key}=`)}${v}`)
45+
}
46+
return parts.length > 0 ? ` ${parts.join(' ')}` : ''
47+
}
48+
49+
// Safely resolve filePath to a relative path.
50+
// Handles file:// URLs (from import.meta.url) and plain string labels.
51+
function resolveFilePath(filePath: string): string {
52+
try {
53+
const parsed = new URL(filePath)
54+
return path.relative(process.cwd(), parsed.pathname)
55+
} catch {
56+
return filePath
57+
}
58+
}
59+
1160
type IncludeContext = { [key: string]: unknown }
1261

1362
// Read once at module startup so every log line carries the deployed version.
@@ -141,7 +190,7 @@ export function createLogger(filePath: string) {
141190
timestamp,
142191
level,
143192
...(BUILD_SHA !== undefined ? { build_sha: BUILD_SHA } : {}),
144-
file: path.relative(process.cwd(), new URL(filePath).pathname),
193+
file: resolveFilePath(filePath),
145194
message: finalMessage,
146195
}
147196

@@ -164,17 +213,25 @@ export function createLogger(filePath: string) {
164213

165214
console.log(toLogfmt(logObject))
166215
} else {
167-
// If the log includes an error, log to console.error in local dev
216+
// Human-readable dev/script logging
217+
const relFile = resolveFilePath(filePath)
218+
const ts = formatTimestamp()
219+
const colorFn = LEVEL_COLORS[level]
220+
const lvl = colorFn(level.toUpperCase().padEnd(5))
221+
const fileTag = chalk.dim(`(${relFile})`)
222+
const contextStr = formatContext(includeContext)
223+
224+
// If the log includes an error, print the Error object to console.error in local dev
168225
let wasErrorLog = false
169226
for (const [, value] of Object.entries(includeContext)) {
170227
if (typeof value === 'object' && value instanceof Error) {
171228
wasErrorLog = true
172-
console.log(`[${level.toUpperCase()}] ${finalMessage}`)
229+
console.log(`${chalk.dim(ts)} ${lvl} ${fileTag} ${finalMessage}${contextStr}`)
173230
console.error(value)
174231
}
175232
}
176233
if (!wasErrorLog) {
177-
console.log(`[${level.toUpperCase()}] ${finalMessage}`)
234+
console.log(`${chalk.dim(ts)} ${lvl} ${fileTag} ${finalMessage}${contextStr}`)
178235
}
179236
}
180237
}

src/observability/tests/logger-integration.ts

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,21 @@ import type { Request, Response } from 'express'
33
import { createLogger } from '@/observability/logger'
44
import { initLoggerContext, updateLoggerContext } from '@/observability/logger/lib/logger-context'
55

6+
// Strip ANSI escape codes for easier assertion matching
7+
function stripAnsi(s: string): string {
8+
// eslint-disable-next-line no-control-regex
9+
return s.replace(/\[\d+m/g, '')
10+
}
11+
12+
// Check that a dev-mode log line contains the expected level and message
13+
function expectDevLog(logs: string[], level: string, message: string): void {
14+
const match = logs.find((log) => {
15+
const clean = stripAnsi(log)
16+
return clean.includes(level) && clean.includes(message)
17+
})
18+
expect(match, `Expected a log containing "${level}" and "${message}"`).toBeDefined()
19+
}
20+
621
// Integration tests that use real dependencies without mocks
722
describe('logger integration tests', () => {
823
let originalConsoleLog: typeof console.log
@@ -147,10 +162,11 @@ describe('logger integration tests', () => {
147162
logger.error('Error message')
148163

149164
// With 'info' level, debug should be filtered out (debug=3, info=2, so debug > info)
150-
expect(consoleLogs).not.toContain('[DEBUG] Debug message')
151-
expect(consoleLogs).toContain('[INFO] Info message')
152-
expect(consoleLogs).toContain('[WARN] Warn message')
153-
expect(consoleLogs).toContain('[ERROR] Error message')
165+
const allClean = consoleLogs.map(stripAnsi).join('\n')
166+
expect(allClean).not.toContain('Debug message')
167+
expectDevLog(consoleLogs, 'INFO', 'Info message')
168+
expectDevLog(consoleLogs, 'WARN', 'Warn message')
169+
expectDevLog(consoleLogs, 'ERROR', 'Error message')
154170
})
155171

156172
it('should use real log level filtering with explicit LOG_LEVEL=error', () => {
@@ -171,10 +187,11 @@ describe('logger integration tests', () => {
171187
logger.error('Error message')
172188

173189
// With 'error' level (0), only error should be logged
174-
expect(consoleLogs).not.toContain('[DEBUG] Debug message')
175-
expect(consoleLogs).not.toContain('[INFO] Info message')
176-
expect(consoleLogs).not.toContain('[WARN] Warn message')
177-
expect(consoleLogs).toContain('[ERROR] Error message')
190+
const allClean = consoleLogs.map(stripAnsi).join('\n')
191+
expect(allClean).not.toContain('Debug message')
192+
expect(allClean).not.toContain('Info message')
193+
expect(allClean).not.toContain('Warn message')
194+
expectDevLog(consoleLogs, 'ERROR', 'Error message')
178195
})
179196

180197
it('should use real production logging detection with LOG_LIKE_PRODUCTION=true', () => {
@@ -234,10 +251,10 @@ describe('logger integration tests', () => {
234251
logger.info('Development logging test')
235252

236253
expect(consoleLogs).toHaveLength(1)
237-
const logOutput = consoleLogs[0]
238254

239255
// Should be in development format (not logfmt)
240-
expect(logOutput).toBe('[INFO] Development logging test')
256+
expectDevLog(consoleLogs, 'INFO', 'Development logging test')
257+
const logOutput = stripAnsi(consoleLogs[0])
241258
expect(logOutput).not.toContain('level=info')
242259
expect(logOutput).not.toContain('timestamp=')
243260
})

0 commit comments

Comments
 (0)