Skip to content

Commit 39eae15

Browse files
heiskrCopilot
andauthored
Add tests for structured logging: BUILD_SHA, error serialization, toError() (#60538)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 61357b1 commit 39eae15

File tree

4 files changed

+164
-11
lines changed

4 files changed

+164
-11
lines changed

src/observability/lib/handle-exceptions.ts

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,9 @@
11
import FailBot from './failbot'
2+
import { toError } from '@/observability/lib/to-error'
23
import { createLogger } from '@/observability/logger'
34

45
const logger = createLogger(import.meta.url)
56

6-
// Safely convert an unknown thrown value to an Error, avoiding JSON.stringify
7-
// which can throw on circular references.
8-
function toError(value: Error | unknown): Error {
9-
if (value instanceof Error) return value
10-
try {
11-
return new Error(JSON.stringify(value))
12-
} catch {
13-
return new Error(String(value))
14-
}
15-
}
16-
177
process.on('uncaughtException', async (err: Error | unknown) => {
188
const error = toError(err)
199
logger.error('uncaughtException', { error })

src/observability/lib/to-error.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
// Safely convert an unknown thrown value to an Error, avoiding JSON.stringify
2+
// which can throw on circular references.
3+
export function toError(value: Error | unknown): Error {
4+
if (value instanceof Error) return value
5+
try {
6+
return new Error(JSON.stringify(value))
7+
} catch {
8+
return new Error(String(value))
9+
}
10+
}

src/observability/tests/logger.ts

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -431,4 +431,97 @@ describe('createLogger', () => {
431431
expect(logOutput).not.toContain('nodeHostname=')
432432
})
433433
})
434+
435+
describe('BUILD_SHA in production logs', () => {
436+
it('should include build_sha in logfmt output when BUILD_SHA env var is set', async () => {
437+
vi.stubEnv('BUILD_SHA', 'abc123def456')
438+
vi.stubEnv('LOG_LIKE_PRODUCTION', 'true')
439+
440+
vi.resetModules()
441+
const { createLogger: freshCreateLogger } = await import('@/observability/logger')
442+
443+
const logger = freshCreateLogger('file:///path/to/test.js')
444+
logger.info('Build SHA test')
445+
446+
expect(consoleLogs).toHaveLength(1)
447+
const logOutput = consoleLogs[0]
448+
expect(logOutput).toContain('build_sha=abc123def456')
449+
})
450+
451+
it('should not include build_sha in logfmt output when BUILD_SHA env var is absent', async () => {
452+
vi.stubEnv('LOG_LIKE_PRODUCTION', 'true')
453+
delete process.env.BUILD_SHA
454+
455+
vi.resetModules()
456+
const { createLogger: freshCreateLogger } = await import('@/observability/logger')
457+
458+
const logger = freshCreateLogger('file:///path/to/test.js')
459+
logger.info('No build SHA test')
460+
461+
expect(consoleLogs).toHaveLength(1)
462+
const logOutput = consoleLogs[0]
463+
expect(logOutput).not.toContain('build_sha=')
464+
})
465+
})
466+
467+
describe('error serialization in production logs', () => {
468+
it('should include error_code and error_name when Error has a .code property', async () => {
469+
vi.stubEnv('LOG_LIKE_PRODUCTION', 'true')
470+
471+
vi.resetModules()
472+
const { createLogger: freshCreateLogger } = await import('@/observability/logger')
473+
474+
const logger = freshCreateLogger('file:///path/to/test.js')
475+
const error = new Error('Connection reset') as NodeJS.ErrnoException
476+
error.code = 'ECONNRESET'
477+
logger.error('Network failure', error)
478+
479+
expect(consoleLogs).toHaveLength(1)
480+
const logOutput = consoleLogs[0]
481+
expect(logOutput).toContain('included.error="Connection reset"')
482+
expect(logOutput).toContain('included.error_code=ECONNRESET')
483+
expect(logOutput).toContain('included.error_name=Error')
484+
expect(logOutput).toContain('included.error_stack=')
485+
})
486+
487+
it('should include error_name even when .code is undefined', async () => {
488+
vi.stubEnv('LOG_LIKE_PRODUCTION', 'true')
489+
490+
vi.resetModules()
491+
const { createLogger: freshCreateLogger } = await import('@/observability/logger')
492+
493+
const logger = freshCreateLogger('file:///path/to/test.js')
494+
const error = new TypeError('Cannot read property')
495+
logger.error('Type error occurred', error)
496+
497+
expect(consoleLogs).toHaveLength(1)
498+
const logOutput = consoleLogs[0]
499+
expect(logOutput).toContain('included.error="Cannot read property"')
500+
expect(logOutput).toContain('included.error_name=TypeError')
501+
// When .code is undefined, error_code is present but empty
502+
expect(logOutput).toMatch(/included\.error_code= /)
503+
expect(logOutput).toContain('included.error_stack=')
504+
})
505+
506+
it('should serialize multiple errors with indexed keys', async () => {
507+
vi.stubEnv('LOG_LIKE_PRODUCTION', 'true')
508+
509+
vi.resetModules()
510+
const { createLogger: freshCreateLogger } = await import('@/observability/logger')
511+
512+
const logger = freshCreateLogger('file:///path/to/test.js')
513+
const error1 = new Error('First') as NodeJS.ErrnoException
514+
error1.code = 'ERR_FIRST'
515+
const error2 = new Error('Second')
516+
logger.error('Multiple errors', error1, error2)
517+
518+
expect(consoleLogs).toHaveLength(1)
519+
const logOutput = consoleLogs[0]
520+
expect(logOutput).toContain('included.error_1=First')
521+
expect(logOutput).toContain('included.error_1_code=ERR_FIRST')
522+
expect(logOutput).toContain('included.error_1_name=Error')
523+
expect(logOutput).toContain('included.error_2=Second')
524+
expect(logOutput).toContain('included.error_2_name=Error')
525+
})
526+
})
434527
})
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { describe, expect, it } from 'vitest'
2+
3+
import { toError } from '@/observability/lib/to-error'
4+
5+
describe('toError', () => {
6+
it('should return Error instances as-is', () => {
7+
const error = new Error('test error')
8+
const result = toError(error)
9+
expect(result).toBe(error)
10+
expect(result.message).toBe('test error')
11+
})
12+
13+
it('should return subclassed Error instances as-is', () => {
14+
const error = new TypeError('type error')
15+
const result = toError(error)
16+
expect(result).toBe(error)
17+
expect(result).toBeInstanceOf(TypeError)
18+
})
19+
20+
it('should convert a plain string to an Error via JSON.stringify', () => {
21+
const result = toError('something went wrong')
22+
expect(result).toBeInstanceOf(Error)
23+
expect(result.message).toBe('"something went wrong"')
24+
})
25+
26+
it('should convert a number to an Error', () => {
27+
const result = toError(42)
28+
expect(result).toBeInstanceOf(Error)
29+
expect(result.message).toBe('42')
30+
})
31+
32+
it('should convert null to an Error', () => {
33+
const result = toError(null)
34+
expect(result).toBeInstanceOf(Error)
35+
expect(result.message).toBe('null')
36+
})
37+
38+
it('should convert undefined to an Error via JSON.stringify', () => {
39+
const result = toError(undefined)
40+
expect(result).toBeInstanceOf(Error)
41+
// JSON.stringify(undefined) returns undefined (not a string),
42+
// so new Error(undefined) has an empty message
43+
expect(result.message).toBe('')
44+
})
45+
46+
it('should convert a plain object to an Error via JSON.stringify', () => {
47+
const result = toError({ code: 'ERR_TIMEOUT', detail: 'took too long' })
48+
expect(result).toBeInstanceOf(Error)
49+
expect(result.message).toBe('{"code":"ERR_TIMEOUT","detail":"took too long"}')
50+
})
51+
52+
it('should fall back to String() for circular references', () => {
53+
const circular: Record<string, unknown> = { name: 'loop' }
54+
circular.self = circular
55+
const result = toError(circular)
56+
expect(result).toBeInstanceOf(Error)
57+
// String() on an object returns '[object Object]'
58+
expect(result.message).toBe('[object Object]')
59+
})
60+
})

0 commit comments

Comments
 (0)