🌳
pt0/deployF/ptDeployActions/exceptionsActionAI.mts
1// @ts-ignore - imapflow lacks type declarations
2import { ImapFlow } from 'imapflow'
17import * as _ from 'lodash-es'
19const getImapSecret = () => {
20 const { secretVal } = getOptJsonSecretH(tsSecs.exceptionsImapSecretName)
21 return secretVal
24export type ExceptionEmail = {
25 uid: number
26 date: Date
27 subject: string
28 errHash: string
29 bodyRaw: string
33export const fetchExceptions = async ({ limit = 50, includeBody = false, deployName, isTest = false, imapSecret }: { limit?: number, includeBody?: boolean, deployName?: string, isTest?: boolean, imapSecret?: { host: string, auth: { user: string, pass: string } } } = {}): Promise<ExceptionEmail[]> => {
34 const { host, auth } = imapSecret || getImapSecret()
36 const client = new ImapFlow({
37 host,
38 auth,
39 port: 993,
40 secure: true,
41 logger: false,
42 })
44 await client.connect()
46 const allFolders = await client.list() as { path: string, specialUse?: string }[]
47 const excludeSpecialUse = ['\\Sent', '\\Trash', '\\Junk', '\\Drafts']
48 const foldersToSearch = allFolders.filter(f => !f.specialUse || !excludeSpecialUse.includes(f.specialUse))
50 const eeToken = getPlainSecOpt(tsSec('exceptionemail-token'))
51 if (!eeToken) {
52 console.log(chalkYellow('No exceptionemail-token secret configured'))
53 await client.logout()
54 return []
55 }
56 const eeTokenSuffix = isTest ? '-test' : '-prod'
57 const eeTokenFull = eeToken + eeTokenSuffix
58 const msgIdSearchPrefix = `${exceptionMsgIdPrefix}.${eeTokenFull}`
60 const msgs: ExceptionEmail[] = []
61 const needBody = includeBody || !!deployName
62 const fetchOpts = needBody ? { envelope: true, source: true } : { envelope: true }
64 for (const folder of foldersToSearch) {
65 const lock = await client.getMailboxLock(folder.path)
66 try {
67 const searchResults = await client.search({ header: { 'message-id': msgIdSearchPrefix } })
68 if (!searchResults || searchResults.length === 0) continue
70 for await (const msg of client.fetch(searchResults, fetchOpts)) {
71 const subject = msg.envelope?.subject || ''
72 const date = msg.envelope?.date ? new Date(msg.envelope.date) : new Date(0)
73 const errHash = subject.split(' ')[0] || ''
74 const bodyRaw = msg.source?.toString() || ''
76 const pterrJson = needBody && bodyRaw ? parsePterrJson(bodyRaw) : undefined
78 if (deployName && pterrJson?.envCtx?.deploymentName !== deployName) continue
80 msgs.push({
81 uid: msg.uid,
82 date,
83 subject,
84 errHash,
85 bodyRaw: includeBody ? bodyRaw : '',
86 pterrJson,
87 })
88 }
89 } catch (err: any) {
90 if (!err.responseText?.includes('No matching messages')) throw err
91 } finally {
92 lock.release()
93 }
94 }
96 await client.logout()
98 const seen = new Set<string>()
99 const deduped = msgs.filter(m => {
100 const key = `${m.subject}|${m.date.getTime()}`
101 if (seen.has(key)) return false
102 seen.add(key)
103 return true
104 })
106 return deduped
107 .sort((a, b) => b.date.getTime() - a.date.getTime())
108 .slice(0, limit)
111export const printExceptionDetail = (msg: ExceptionEmail) => {
112 console.log(chalkCyan(`\nException ${msg.errHash}:`))
113 console.log(chalkGray(`Subject: ${msg.subject}`))
114 console.log(chalkGray(`Date: ${msg.date.toISOString()}`))
115 const pterrJson = msg.pterrJson
116 if (pterrJson) {
117 const {clientStackA, componentStackA, smResolutionFailed, ...ctx} = pterrJson
118 console.log(ctx)
119 }
120 const silencedSamples = pterrJson?.errCtx?.silencedSamples as unknown[] | undefined
121 if (silencedSamples?.length) {
122 console.log(chalkYellow(`\nAlso affected ${silencedSamples.length} silenced occurrence(s):`))
123 for (const sample of silencedSamples) {
124 console.log(chalkGray(` ${JSON.stringify(sample)}`))
125 }
126 }
127 if (pterrJson?.componentStackA?.length) {
128 console.log(chalkCyan('\nComponent stack:'))
129 for (const line of pterrJson.componentStackA) {
130 console.log(` ${line}`)
131 }
132 }
133 if (pterrJson?.clientStackA?.length) {
134 console.log(chalkCyan('\nClient stack:'))
135 for (const line of pterrJson.clientStackA) {
136 console.log(` ${line}`)
137 }
138 }
139 if (pterrJson?.smResolutionFailed) {
140 console.log(chalkYellow('\nâš  Sourcemap resolution partially failed for this error'))
141 }
142 const decodedBody = msg.bodyRaw
143 .replace(/=\r?\n/g, '')
144 .replace(/=([0-9A-F]{2})/gi, (_, hex: string) => String.fromCharCode(parseInt(hex, 16)))
145 const stackMatch = decodedBody.match(/err\.stack: ([\s\S]*?)(?=\n\{|\n---PTERR|\n\n|$)/)
146 if (stackMatch) {
147 console.log(chalkCyan('\nServer stack:'))
148 console.log(stackMatch[1].trim())
149 }
152export const exceptions = async () => {
153 const showHash = cliArg('--show')
154 const all = cliFlag('--all')
155 const isTest = cliFlag('--test')
157 const appCfg = getAppCfg()
158 const deployName = (all || showHash) ? undefined : appCfg?.name as string | undefined
160 console.log(chalkCyan(`Fetching recent exceptions from IMAP${deployName ? ` for ${deployName}` : ''}...`))
162 const secret = getImapSecret()
163 if (!secret) {
164 console.log(chalkYellow(`No exceptionsImapSecretName configured in secretsMapping for this app`))
165 return
166 }
167 console.log(chalkGray(`Connecting to ${secret.host} as ${secret.auth.user}...`))
169 const exceptionMsgs = await fetchExceptions({ limit: 50, includeBody: true, deployName, isTest })
171 if (showHash) {
172 const matches = exceptionMsgs.filter((m: ExceptionEmail) => m.errHash === showHash)
173 const msg = matches[0]
174 if (!msg) {
175 console.log(chalkGray(`No exception with errHash ${showHash}`))
176 return
177 }
178 printExceptionDetail(msg)
179 return
180 }
182 const dedupGroups = new Map<string, { msg: ExceptionEmail, count: number, totalOccurrences: number }>()
183 for (const msg of exceptionMsgs) {
184 const sha = (msg.pterrJson?.envCtx?.git_sha as string)?.slice(0, 7) || ''
185 const key = `${msg.errHash}|${sha}`
186 const numSilenced = (msg.pterrJson?.errCtx?.numSilenced as number) || 0
187 const existing = dedupGroups.get(key)
188 if (existing) {
189 existing.count++
190 existing.totalOccurrences += numSilenced + 1
191 } else {
192 dedupGroups.set(key, { msg, count: 1, totalOccurrences: numSilenced + 1 })
193 }
194 }
195 const deduped = [...dedupGroups.values()]
197 let deployedGitSha = '', deployedBuildSha = ''
198 if (appCfg?.cluster_name && appCfg?.resourceTmpl) {
199 try {
200 const res = await read2Resource({resource: appCfg.resourceTmpl as any, cluster_name: appCfg.cluster_name})
201 deployedGitSha = (_.get(res, kubeResGitShaPath) as unknown as string)?.slice(0, 7) || ''
202 deployedBuildSha = (_.get(res, kubeResBuildShaPath) as unknown as string)?.slice(0, 7) || ''
203 } catch (err: any) {
204 console.log(chalkGray(`Could not resolve deployed sha from k8s: ${err.message}`))
205 }
206 }
207 if (!deployedGitSha) {
208 const shaFromExceptions = deduped.find(d => (d.msg.pterrJson?.envCtx?.git_sha as string)?.slice(0, 7))
209 deployedGitSha = (shaFromExceptions?.msg.pterrJson?.envCtx?.git_sha as string)?.slice(0, 7) || ''
210 }
211 const isCurrentDeploy = (sha: string) => sha === deployedGitSha || sha === deployedBuildSha
213 console.log(chalkCyan(`\nRecent exceptions (${deduped.length} groups, ${exceptionMsgs.length} total):`))
215 for (let i = 0; i < deduped.length; i++) {
216 const { msg, totalOccurrences } = deduped[i]
217 const ago = dur2Human(LuxDt.fromJSDate(msg.date).diff(luxNow()))
218 const sha = (msg.pterrJson?.envCtx?.git_sha as string)?.slice(0, 7) || ''
219 const shaCol = sha ? (isCurrentDeploy(sha) ? chalkGreen(` ${sha}`) : chalkGray(` ${sha}`)) : ''
220 const countCol = totalOccurrences > 1 ? chalkYellow(` (${totalOccurrences})`) : ''
221 console.log(` ${chalkGray(`${i + 1}.`)} ${chalkGray(ago)}${shaCol}${countCol} ${msg.subject.slice(0, 60)}`)
222 }
224 const unparseableFromCurrentDeploy = deduped.filter(d => {
225 const sha = (d.msg.pterrJson?.envCtx?.git_sha as string)?.slice(0, 7)
226 if (!isCurrentDeploy(sha)) return false
227 return !d.msg.pterrJson
228 })
229 for (const { msg } of unparseableFromCurrentDeploy) {
230 console.log(chalkYellow(` âš  ${msg.errHash} from current deploy has unparseable body`))
231 }
233 if (deduped.length === 0) {
234 console.log(chalkGray(' (none found)'))
235 } else {
236 console.log(chalkGray(`\nUse --show=<errHash> to see full details`))
237 }
238 if (deployedGitSha) {
239 const buildPart = deployedBuildSha && deployedBuildSha !== deployedGitSha ? ` build:${chalkGreen(deployedBuildSha)}` : ''
240 console.log(chalkGray(`deployed: git:${chalkGreen(deployedGitSha)}${buildPart}`))
241 }
244exceptions.cliSchema = {
245 show: { type: 'string' as const, hint: 'errHash', desc: 'show full details for exception by errHash' },
246 all: { flag: true as const, desc: 'show exceptions for all apps' },
247 test: { flag: true as const, desc: 'search for test exceptions (testdeploy) instead of prod' },
249exceptions.cliDescript = 'exceptions - fetch recent exception emails from IMAP'
250exceptions.needsEnvConf = true