Skip to content

Commit 44d9215

Browse files
Haiderclaude
andcommitted
fix(dbt-tools): bubble real dbt show error correctly + redact SQL on failure
Final fix-up addressing reviewer feedback on this PR. Production fixes (packages/dbt-tools/src/dbt-cli.ts): - run(): capture stdout/stderr on execFile rejection. Node delivers them as separate callback args, not as properties on the error; the previous bare reject(err) discarded them so the catch-block's e.stdout / e.stderr were always undefined and the "bubble real dbt error" path was inert in production. - execDbtShow: split the error throw by failure mode. Distinguish "primary dbt show crashed" (surface the real dbt error) from "primary succeeded but unparseable AND plain-text retry failed" (parser-regression message). Throwing "dbt show failed: <plain-mode error>" when primary succeeded would misattribute a parser regression as a dbt execution failure. - execDbtShow: do not feed crashed-run stdout into Tier 1/2 heuristics. Crash logs can contain incidental arrays that the heuristic scan would happily return as "rows" (silent wrong data). Tier 1/2 are now gated behind !primaryRunError; the crashed stdout is only consulted by extractDbtError for structured level:"error" events. - extractDbtError: pick the LAST level:"error" event (dbt emits a generic header before the actionable message), strip ANSI from surfaced text, and redact inline SQL from err.message via a new fallbackExitMessage helper covering exit-code rejections AND signal/timeout kills. Spawn- time failures (ENOENT etc.) keep their original message since it does not embed the command line. - execDbtShow: suppress the "dbt show failed:" prefix when the underlying error already starts with a dbt category prefix (Compilation/Database/ Runtime/Parsing/Validation/Dependency Error). Avoids "dbt show failed: Database Error: ..." doubling. Test fixes (packages/dbt-tools/test/dbt-cli.test.ts): - Mocks now pass stdout/stderr only via callback args, matching Node's actual behaviour. Previous mocks pre-attached err.stdout / err.stderr, which made the inert production code path appear to work — a false- positive class the new mocks specifically guard against. - New tests cover: parser-vs-dbt-failure attribution, last-event preference, ANSI strip, SQL-redaction canaries (numeric exit code + signal kill), category-prefix dedup, Buffer-typed stdout/stderr, Tier 3 recovery, anti-spurious-rows on crash. - Cleanup: TS type-clean catch pattern, drop redundant negative assertions and a vacuous ^Error: regex. 33 pass / 0 fail. Out of scope, tracked as follow-up issues: - #943 — execDbtCompile / execDbtCompileInline mask real dbt errors - #944 — execDbtShow returns malformed result on non-array data.preview - #945 — execDbtCompileInline embeds full inline SQL in error message Refs #932 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2ea03d7 commit 44d9215

2 files changed

Lines changed: 404 additions & 88 deletions

File tree

packages/dbt-tools/src/dbt-cli.ts

Lines changed: 175 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -51,15 +51,39 @@ function getDbt(): ResolvedDbt {
5151
return resolvedDbt
5252
}
5353

54+
/** Shape of an execFile rejection — carries stdout/stderr alongside message. */
55+
interface ExecFileError extends Error {
56+
stdout?: string | Buffer
57+
stderr?: string | Buffer
58+
code?: number | string
59+
signal?: string
60+
}
61+
62+
/** Coerce an unknown rejection into something the catch blocks can read safely. */
63+
function toExecFileError(e: unknown): ExecFileError {
64+
if (e instanceof Error) return e as ExecFileError
65+
return new Error(String(e)) as ExecFileError
66+
}
67+
5468
function run(args: string[]): Promise<{ stdout: string; stderr: string }> {
5569
const dbt = getDbt()
5670
const env = buildDbtEnv(dbt)
5771
const cwd = globalOptions.projectRoot ?? process.cwd()
5872

5973
return new Promise((resolve, reject) => {
6074
execFile(dbt.path, args, { timeout: 120_000, maxBuffer: 10 * 1024 * 1024, env, cwd }, (err, stdout, stderr) => {
61-
if (err) reject(err)
62-
else resolve({ stdout, stderr })
75+
if (err) {
76+
// Node's execFile passes stdout/stderr as separate callback arguments,
77+
// not as properties on the error. Attach them here so callers can
78+
// surface the real dbt failure text instead of Node's generic
79+
// "Command failed: ..." message.
80+
const execErr = err as ExecFileError
81+
execErr.stdout = stdout
82+
execErr.stderr = stderr
83+
reject(execErr)
84+
} else {
85+
resolve({ stdout, stderr })
86+
}
6387
})
6488
})
6589
}
@@ -221,71 +245,82 @@ export async function execDbtShow(sql: string, limit?: number) {
221245
const args = ["show", "--inline", sql, "--output", "json", "--log-format", "json"]
222246
if (limit !== undefined) args.push("--limit", String(limit))
223247

224-
let lines: Record<string, unknown>[]
225-
// Capture the run() error so we can bubble the real dbt failure up if all
248+
// Capture the run() errors so we can bubble the real dbt failure up if all
226249
// parse tiers fail; the generic "Could not parse" alone misleads callers
227250
// into treating structural project errors as transient.
228251
let primaryRunError: ExecFileError | undefined
252+
let lines: Record<string, unknown>[] = []
229253
try {
230254
const { stdout } = await run(args)
231255
lines = parseJsonLines(stdout)
232256
} catch (e) {
233-
primaryRunError = e as ExecFileError
234-
lines = parseJsonLines(primaryRunError.stdout ?? "")
257+
primaryRunError = toExecFileError(e)
258+
// Deliberately do NOT feed crashed-run stdout into `lines` for the
259+
// heuristic tiers below. Crash logs can contain incidental arrays that
260+
// `looksLikeRowData` would happily return as "rows" (silent wrong data).
261+
// The crashed stdout is still consulted by extractDbtError below for the
262+
// structured `level: "error"` event.
235263
}
236264

237-
// --- Tier 1: known field paths ---
238-
const previewLine =
239-
lines.find((l: any) => l.data?.preview) ??
240-
lines.find((l: any) => l.data?.rows) ??
241-
lines.find((l: any) => l.result?.preview) ??
242-
lines.find((l: any) => l.result?.rows)
243-
244-
const sqlLine =
245-
lines.find((l: any) => l.data?.sql) ??
246-
lines.find((l: any) => l.data?.compiled_sql) ??
247-
lines.find((l: any) => l.result?.sql)
248-
249-
if (previewLine) {
250-
const preview =
251-
(previewLine as any).data?.preview ??
252-
(previewLine as any).data?.rows ??
253-
(previewLine as any).result?.preview ??
254-
(previewLine as any).result?.rows
255-
256-
// Guard JSON.parse — fall through to Tier 2 on malformed strings
257-
let rows: Record<string, unknown>[]
258-
if (typeof preview === "string") {
259-
const parsed = safeJsonParse(preview)
260-
if (Array.isArray(parsed)) {
261-
rows = parsed
265+
// Skip the success-only tiers when the primary run failed — see comment
266+
// above. We still try Tier 3 (a separate plain-text run) because that can
267+
// recover from JSON-mode-specific failures.
268+
if (!primaryRunError) {
269+
// --- Tier 1: known field paths ---
270+
const previewLine =
271+
lines.find((l: any) => l.data?.preview) ??
272+
lines.find((l: any) => l.data?.rows) ??
273+
lines.find((l: any) => l.result?.preview) ??
274+
lines.find((l: any) => l.result?.rows)
275+
276+
const sqlLine =
277+
lines.find((l: any) => l.data?.sql) ??
278+
lines.find((l: any) => l.data?.compiled_sql) ??
279+
lines.find((l: any) => l.result?.sql)
280+
281+
if (previewLine) {
282+
const preview =
283+
(previewLine as any).data?.preview ??
284+
(previewLine as any).data?.rows ??
285+
(previewLine as any).result?.preview ??
286+
(previewLine as any).result?.rows
287+
288+
// Guard JSON.parse — fall through to Tier 2 on malformed strings
289+
let rows: Record<string, unknown>[]
290+
if (typeof preview === "string") {
291+
const parsed = safeJsonParse(preview)
292+
if (Array.isArray(parsed)) {
293+
rows = parsed
294+
} else {
295+
rows = [] // Malformed — will fall through below
296+
}
262297
} else {
263-
rows = [] // Malformed — will fall through below
298+
rows = preview
264299
}
265-
} else {
266-
rows = preview
267-
}
268300

269-
// Return the result — even if empty. An empty preview means the query returned
270-
// zero rows, which is a valid result. Do NOT fall through to Tier 2, which could
271-
// match spurious log metadata as row data.
272-
const columnNames = rows.length > 0 && rows[0] ? Object.keys(rows[0]) : []
273-
const compiledSql = (sqlLine as any)?.data?.sql ?? (sqlLine as any)?.data?.compiled_sql ?? (sqlLine as any)?.result?.sql ?? sql
274-
return { columnNames, columnTypes: columnNames.map(() => "string"), data: rows, rawSql: sql, compiledSql }
275-
}
276-
277-
// --- Tier 2: heuristic deep scan ---
278-
for (const line of lines) {
279-
const found = deepFind(line, (val) => looksLikeRowData(val))
280-
if (found) {
281-
const rows: Record<string, unknown>[] = typeof found === "string" ? JSON.parse(found as string) : (found as Record<string, unknown>[])
301+
// Return the result — even if empty. An empty preview means the query returned
302+
// zero rows, which is a valid result. Do NOT fall through to Tier 2, which could
303+
// match spurious log metadata as row data.
282304
const columnNames = rows.length > 0 && rows[0] ? Object.keys(rows[0]) : []
283-
const compiledSql = (deepFind(line, (val) => looksLikeSql(val)) as string) ?? sql
305+
const compiledSql = (sqlLine as any)?.data?.sql ?? (sqlLine as any)?.data?.compiled_sql ?? (sqlLine as any)?.result?.sql ?? sql
284306
return { columnNames, columnTypes: columnNames.map(() => "string"), data: rows, rawSql: sql, compiledSql }
285307
}
308+
309+
// --- Tier 2: heuristic deep scan ---
310+
for (const line of lines) {
311+
const found = deepFind(line, (val) => looksLikeRowData(val))
312+
if (found) {
313+
const rows: Record<string, unknown>[] = typeof found === "string" ? JSON.parse(found as string) : (found as Record<string, unknown>[])
314+
const columnNames = rows.length > 0 && rows[0] ? Object.keys(rows[0]) : []
315+
const compiledSql = (deepFind(line, (val) => looksLikeSql(val)) as string) ?? sql
316+
return { columnNames, columnTypes: columnNames.map(() => "string"), data: rows, rawSql: sql, compiledSql }
317+
}
318+
}
286319
}
287320

288321
// --- Tier 3: plain text fallback (ASCII table) ---
322+
// Tried unconditionally — even if JSON-mode crashed, the plain-text mode
323+
// sometimes succeeds and gives us a usable table.
289324
let plainRunError: ExecFileError | undefined
290325
try {
291326
const plainArgs = ["show", "--inline", sql]
@@ -302,14 +337,43 @@ export async function execDbtShow(sql: string, limit?: number) {
302337
}
303338
}
304339
} catch (e) {
305-
plainRunError = e as ExecFileError
340+
plainRunError = toExecFileError(e)
341+
}
342+
343+
// Two distinct failure modes; don't conflate them:
344+
//
345+
// (a) JSON-mode `dbt show` actually crashed → surface the real dbt error.
346+
// This is the original motivation for the PR.
347+
//
348+
// (b) JSON-mode succeeded (exit 0) but emitted output we can't decode,
349+
// AND the plain-text retry then failed for some other reason. The
350+
// `dbt show` command itself didn't fail; our parser did. Throwing
351+
// "dbt show failed: <plain-mode error>" here would misattribute a
352+
// parser regression as a dbt execution failure.
353+
if (primaryRunError) {
354+
const errorLogLines = parseJsonLines(primaryRunError.stdout?.toString() ?? "")
355+
const realError = extractDbtError(errorLogLines, primaryRunError, plainRunError)
356+
if (realError) {
357+
// Avoid doubling the "failed:" prefix when dbt's own category prefix
358+
// is already in the message (e.g. "Database Error: ...",
359+
// "Compilation Error: ...").
360+
const hasDbtCategoryPrefix = /^(Compilation|Database|Runtime|Parsing|Validation|Dependency)\s+Error\b/.test(
361+
realError,
362+
)
363+
throw new Error(hasDbtCategoryPrefix ? realError : `dbt show failed: ${realError}`)
364+
}
306365
}
307366

308-
// If either run() rejected, dbt actually crashed — surface the real error
309-
// instead of the generic "Could not parse" message.
310-
const realError = extractDbtError(lines, primaryRunError, plainRunError)
311-
if (realError) {
312-
throw new Error(`dbt show failed: ${realError}`)
367+
if (plainRunError) {
368+
// Both branches stay SQL-safe: extractDbtError already strips ANSI and
369+
// redacts via fallbackExitMessage; the fallback here uses the same helper
370+
// explicitly so this code path can't regress to a raw err.message even if
371+
// extractDbtError is refactored.
372+
const fallback =
373+
extractDbtError([], undefined, plainRunError) ??
374+
fallbackExitMessage(undefined, plainRunError) ??
375+
"unknown error"
376+
throw new Error(`Could not parse dbt show JSON output, and plain-text fallback failed: ${fallback}`)
313377
}
314378

315379
throw new Error(
@@ -318,48 +382,86 @@ export async function execDbtShow(sql: string, limit?: number) {
318382
)
319383
}
320384

321-
/** Shape of an execFile rejection — carries stdout/stderr alongside message. */
322-
interface ExecFileError extends Error {
323-
stdout?: string
324-
stderr?: string
325-
code?: number | string
326-
}
327-
328385
/**
329386
* Pick the best human-readable error from a failed `dbt show` invocation.
330387
*
331388
* Preference order:
332-
* 1. A structured `level: "error"` event in the JSON log (dbt's own error msg).
389+
* 1. The LAST structured `level: "error"` event in the JSON log. dbt often
390+
* emits a generic header (e.g. "Encountered an error:") before the
391+
* actionable message; we want the actionable one.
333392
* 2. Stderr from the JSON-mode run.
334393
* 3. Stderr from the plain-text-mode run.
335-
* 4. The exception message itself.
394+
* 4. A concise "dbt exited with status N" fallback. We deliberately do NOT
395+
* surface `err.message` directly when it's an execFile rejection — Node
396+
* embeds the full command line (including the inline SQL) in that
397+
* message, which would leak the user's query into logs and UI.
336398
*
337399
* Returns undefined if neither run rejected — caller falls back to the generic
338400
* "Could not parse" message, which is correct when dbt exited 0 but emitted
339401
* something we can't decode.
402+
*
403+
* ANSI escape codes are stripped from the returned message so logs and UI
404+
* bubbles stay clean (dbt may colour-code stderr and structured events).
340405
*/
406+
interface DbtLogLine {
407+
info?: { level?: string; msg?: string }
408+
level?: string
409+
msg?: string
410+
}
411+
341412
function extractDbtError(
342413
lines: Record<string, unknown>[],
343414
primary?: ExecFileError,
344415
plain?: ExecFileError,
345416
): string | undefined {
346417
if (!primary && !plain) return undefined
347418

348-
const errorEvent = lines.find(
349-
(l: any) => l.info?.level === "error" || l.level === "error",
350-
) as any
351-
const structuredMsg = errorEvent?.info?.msg ?? errorEvent?.msg
419+
const errorMessages = lines
420+
.map((l) => {
421+
const line = l as DbtLogLine
422+
const isError = line.info?.level === "error" || line.level === "error"
423+
if (!isError) return undefined
424+
return line.info?.msg ?? line.msg
425+
})
426+
.filter((m): m is string => typeof m === "string" && m.trim().length > 0)
427+
const structuredMsg = errorMessages.at(-1)
352428

353429
const primaryStderr = primary?.stderr?.toString().trim()
354430
const plainStderr = plain?.stderr?.toString().trim()
355431

356-
return (
357-
(typeof structuredMsg === "string" && structuredMsg.length > 0 ? structuredMsg : undefined) ??
432+
const chosen =
433+
(structuredMsg && structuredMsg.length > 0 ? structuredMsg : undefined) ??
358434
(primaryStderr && primaryStderr.length > 0 ? primaryStderr : undefined) ??
359435
(plainStderr && plainStderr.length > 0 ? plainStderr : undefined) ??
360-
primary?.message ??
361-
plain?.message
362-
)
436+
fallbackExitMessage(primary, plain)
437+
438+
return chosen ? stripAnsi(chosen) : undefined
439+
}
440+
441+
/**
442+
* Build a concise exit-status message that does NOT leak the dbt command line.
443+
*
444+
* Node's `execFile` rejection has `err.message` = `"Command failed: <dbt-path>
445+
* show --inline '<entire SQL>' ..."` whenever the spawned process actually
446+
* ran — exit-non-zero AND timeout/signal kills both produce this message,
447+
* embedding the user's full query (potentially with secrets, PII, multi-KB
448+
* literals) into any log/UI surface that displays the error.
449+
*
450+
* Spawn-time failures (ENOENT etc.) have a different message shape that does
451+
* NOT embed args, so they're safe to surface directly.
452+
*/
453+
function fallbackExitMessage(primary?: ExecFileError, plain?: ExecFileError): string | undefined {
454+
const err = primary ?? plain
455+
if (!err) return undefined
456+
457+
const looksLikeCommandFailed = typeof err.message === "string" && err.message.startsWith("Command failed:")
458+
if (!looksLikeCommandFailed) return err.message
459+
460+
// The process ran; redact the embedded command line.
461+
if (typeof err.code === "number") return `dbt exited with status ${err.code}`
462+
if (err.signal) return `dbt killed by signal ${err.signal}`
463+
if (typeof err.code === "string") return `dbt failed: ${err.code}`
464+
return "dbt failed (no exit code reported)"
363465
}
364466

365467
/**

0 commit comments

Comments
 (0)