Skip to content

Commit da7d07c

Browse files
authored
Add structured logging to aid debugging of future outages (#60346)
1 parent 36ea230 commit da7d07c

File tree

6 files changed

+101
-29
lines changed

6 files changed

+101
-29
lines changed

src/archives/middleware/archived-enterprise-versions-assets.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ import { isArchivedVersion } from '@/archives/lib/is-archived-version'
66
import { setFastlySurrogateKey, SURROGATE_ENUMS } from '@/frame/middleware/set-fastly-surrogate-key'
77
import { archivedCacheControl, defaultCacheControl } from '@/frame/middleware/cache-control'
88
import type { ExtendedRequest } from '@/types'
9+
import { createLogger } from '@/observability/logger'
10+
11+
const logger = createLogger(import.meta.url)
912

1013
// This module handles requests for the CSS and JS assets for
1114
// deprecated GitHub Enterprise versions by routing them to static content in
@@ -127,6 +130,11 @@ export default async function archivedEnterpriseVersionsAssets(
127130
throw err
128131
}
129132

133+
logger.warn('Failed to proxy archived enterprise asset', {
134+
url: proxyPath,
135+
error: err instanceof Error ? err : new Error(String(err)),
136+
})
137+
130138
// It's important that we don't give up on this by returning a 404
131139
// here. It's better to let this through in case the asset exists
132140
// beyond the realm of archived enterprise versions.

src/archives/middleware/archived-enterprise-versions.ts

Lines changed: 44 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -120,15 +120,24 @@ export default async function archivedEnterpriseVersions(
120120
return res.safeRedirect(redirectCode, redirectTo)
121121
}
122122

123-
const redirectJson = (await getRemoteJSON(getProxyPath('redirects.json', requestedVersion), {
124-
retry: retryConfiguration,
125-
// This is allowed to be different compared to the other requests
126-
// we make because downloading the `redirects.json` once is very
127-
// useful because it caches so well.
128-
// And, as of 2021 that `redirects.json` is 10MB so it's more likely
129-
// to time out.
130-
timeout: { response: 1000 },
131-
})) as Record<string, string>
123+
let redirectJson: Record<string, string>
124+
try {
125+
redirectJson = (await getRemoteJSON(getProxyPath('redirects.json', requestedVersion), {
126+
retry: retryConfiguration,
127+
// This is allowed to be different compared to the other requests
128+
// we make because downloading the `redirects.json` once is very
129+
// useful because it caches so well.
130+
// And, as of 2021 that `redirects.json` is 10MB so it's more likely
131+
// to time out.
132+
timeout: { response: 1000 },
133+
})) as Record<string, string>
134+
} catch (err) {
135+
logger.error('Failed to fetch archived redirects.json', {
136+
version: requestedVersion,
137+
error: err instanceof Error ? err : new Error(String(err)),
138+
})
139+
throw err
140+
}
132141
if (!req.context) throw new Error('No context on request')
133142
const [language, withoutLanguage] = splitPathByLanguage(req.path, req.context.userLanguage)
134143
const newRedirectTo = redirectJson[withoutLanguage]
@@ -179,15 +188,24 @@ export default async function archivedEnterpriseVersions(
179188
versionSatisfiesRange(requestedVersion, `>${lastVersionWithoutArchivedRedirectsFile}`) &&
180189
!deprecatedWithFunctionalRedirects.includes(requestedVersion)
181190
) {
182-
const redirectJson = (await getRemoteJSON(getProxyPath('redirects.json', requestedVersion), {
183-
retry: retryConfiguration,
184-
// This is allowed to be different compared to the other requests
185-
// we make because downloading the `redirects.json` once is very
186-
// useful because it caches so well.
187-
// And, as of 2021 that `redirects.json` is 10MB so it's more likely
188-
// to time out.
189-
timeout: { response: 1000 },
190-
})) as Record<string, string>
191+
let redirectJson: Record<string, string>
192+
try {
193+
redirectJson = (await getRemoteJSON(getProxyPath('redirects.json', requestedVersion), {
194+
retry: retryConfiguration,
195+
// This is allowed to be different compared to the other requests
196+
// we make because downloading the `redirects.json` once is very
197+
// useful because it caches so well.
198+
// And, as of 2021 that `redirects.json` is 10MB so it's more likely
199+
// to time out.
200+
timeout: { response: 1000 },
201+
})) as Record<string, string>
202+
} catch (err) {
203+
logger.error('Failed to fetch archived redirects.json', {
204+
version: requestedVersion,
205+
error: err instanceof Error ? err : new Error(String(err)),
206+
})
207+
throw err
208+
}
191209

192210
// make redirects found via redirects.json redirect with a 301
193211
if (redirectJson[req.path]) {
@@ -230,12 +248,20 @@ export default async function archivedEnterpriseVersions(
230248

231249
// Log errors for non-200 responses to help identify issues with archived content
232250
if (r.status !== 200) {
251+
let upstreamBody: string | undefined
252+
try {
253+
upstreamBody = await r.text()
254+
} catch {
255+
// ignore — body reading failure shouldn't affect error handling
256+
}
233257
logger.error('Failed to fetch archived enterprise content', {
234258
version: requestedVersion,
235259
path: req.path,
236260
status: r.status,
261+
statusText: r.statusText,
237262
responseTime: `${responseTime}ms`,
238263
url: getProxyPath(req.path, requestedVersion),
264+
upstreamBody: upstreamBody?.slice(0, 500),
239265
})
240266
}
241267

src/frame/start-server.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,14 @@ import { checkNodeVersion } from './lib/check-node-version'
77
import '../observability/lib/handle-exceptions'
88
import createApp from './lib/app'
99
import warmServer from './lib/warm-server'
10+
import { createLogger } from '@/observability/logger'
1011

1112
dotenv.config()
1213

1314
checkNodeVersion()
1415

16+
const logger = createLogger(import.meta.url)
17+
1518
const { PORT, NODE_ENV } = process.env
1619
const port = Number(PORT) || 4000
1720

@@ -52,7 +55,16 @@ async function startServer() {
5255
// Workaround for https://github.com/expressjs/express/issues/1101
5356
const server = http.createServer(app)
5457

58+
process.once('SIGTERM', () => {
59+
logger.info('Received SIGTERM, beginning graceful shutdown', { pid: process.pid, port })
60+
server.close(() => {
61+
logger.info('HTTP server closed')
62+
})
63+
})
64+
5565
return server
56-
.listen(port, () => console.log(`app running on http://localhost:${port}`))
66+
.listen(port, () =>
67+
logger.info('Server started', { port, pid: process.pid, nodeEnv: process.env.NODE_ENV }),
68+
)
5769
.on('error', () => server.close())
5870
}
Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,37 @@
11
import FailBot from './failbot'
2+
import { createLogger } from '@/observability/logger'
3+
4+
const logger = createLogger(import.meta.url)
5+
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+
}
216

317
process.on('uncaughtException', async (err: Error | unknown) => {
4-
console.error(err)
18+
const error = toError(err)
19+
logger.error('uncaughtException', { error })
520
try {
6-
// Type guard to ensure we have an Error object for FailBot
7-
const error = err instanceof Error ? err : new Error(JSON.stringify(err))
821
FailBot.report(error)
922
} catch (failBotError) {
10-
console.warn('Even sending the uncaughtException error to FailBot failed!')
11-
console.error(failBotError)
23+
logger.warn('Even sending the uncaughtException error to FailBot failed!')
24+
logger.error('Failed to report uncaughtException to FailBot', { error: toError(failBotError) })
1225
}
1326
})
1427

1528
process.on('unhandledRejection', async (err: Error | unknown) => {
16-
console.error(err)
29+
const error = toError(err)
30+
logger.error('unhandledRejection', { error })
1731
try {
18-
// Type guard to ensure we have an Error object for FailBot
19-
const error = err instanceof Error ? err : new Error(JSON.stringify(err))
2032
FailBot.report(error)
2133
} catch (failBotError) {
22-
console.warn('Even sending the unhandledRejection error to FailBot failed!')
23-
console.error(failBotError)
34+
logger.warn('Even sending the unhandledRejection error to FailBot failed!')
35+
logger.error('Failed to report unhandledRejection to FailBot', { error: toError(failBotError) })
2436
}
2537
})

src/observability/logger/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ import { POD_IDENTITY } from '@/observability/logger/lib/pod-identity'
1010

1111
type IncludeContext = { [key: string]: unknown }
1212

13+
// Read once at module startup so every log line carries the deployed version.
14+
// BUILD_SHA is baked into each Docker image via ARG/ENV in the Dockerfile.
15+
const BUILD_SHA = process.env.BUILD_SHA || undefined
16+
1317
// Type definitions for logger methods with overloads
1418
interface LoggerMethod {
1519
// Pattern 1: Just a message e.g. `logger.info('Hello world')`
@@ -136,6 +140,7 @@ export function createLogger(filePath: string) {
136140
...loggerContext, // requestUuid, path, method, headers, etc. (per-request)
137141
timestamp,
138142
level,
143+
...(BUILD_SHA !== undefined ? { build_sha: BUILD_SHA } : {}),
139144
file: path.relative(process.cwd(), new URL(filePath).pathname),
140145
message: finalMessage,
141146
}
@@ -146,6 +151,8 @@ export function createLogger(filePath: string) {
146151
if (typeof value === 'object' && value instanceof Error) {
147152
// Errors don't serialize well to JSON, so just log the message + stack trace
148153
includedContextWithFormattedError[key] = value.message
154+
includedContextWithFormattedError[`${key}_code`] = (value as NodeJS.ErrnoException).code
155+
includedContextWithFormattedError[`${key}_name`] = value.name
149156
includedContextWithFormattedError[`${key}_stack`] = value.stack
150157
} else {
151158
includedContextWithFormattedError[key] = value

src/observability/middleware/handle-errors.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ import { setFastlySurrogateKey, SURROGATE_ENUMS } from '@/frame/middleware/set-f
77
import { errorCacheControl } from '@/frame/middleware/cache-control'
88
import statsd from '@/observability/lib/statsd'
99
import { ExtendedRequest } from '@/types'
10+
import { createLogger } from '@/observability/logger'
11+
12+
const logger = createLogger(import.meta.url)
1013

1114
const DEBUG_MIDDLEWARE_TESTS = Boolean(JSON.parse(process.env.DEBUG_MIDDLEWARE_TESTS || 'false'))
1215

@@ -29,6 +32,10 @@ function timedOut(req: ExtendedRequest) {
2932
incrementTags.push(`product:${req.context.currentCategory}`)
3033
}
3134
statsd.increment('middleware.timeout', 1, incrementTags)
35+
logger.warn('Request timed out', {
36+
path: req.pagePath || req.path,
37+
method: req.method,
38+
})
3239
}
3340

3441
async function handleError(

0 commit comments

Comments
 (0)