Software Development with AI 13 min read Updated Jul 16, 2026

How to Analyze a Stack Trace With AI Without Trusting the Top Line

A stack trace is precise about where execution stopped and nearly silent about why it started going wrong. Here is how to analyze a stack trace with AI: read the exception chain inside out, find the frames you own, and separate what the trace proves from what it merely suggests.

Build a Prompt Around Your Stack Trace

Forty lines, and the only one with your name on it is thirty down

The trace arrives in a ticket, or a log tail, or pasted into a channel by someone who has already gone home. The first line names an exception type you have not seen, raised by a class you did not write, inside a file you do not own. Below it are thirty lines of framework — parsers, schedulers, task machinery — and somewhere down there, eventually, a file with your name on it.

And by the time you reach that line you have already formed a theory. It came from the top line, because the top line was first and it was in English. That is the ordinary way to read a document, and a stack trace is not an ordinary document: the thing it puts at the top is the outermost wrapper — the last thing to happen — and the thing you want is usually further in.

This is not the general problem of debugging, where you have a symptom and go looking for evidence. Here the evidence is already in your hands, in one dense artifact, and the question is narrower and more annoying: what does this actually say? Most of what goes wrong next comes from one habit — reading a precise document as though it were a complete one.

What a trace is precise about, and what it is silent on

A stack trace is a record of how execution unwound. It names, exactly, the call path that was on the stack when the exception was constructed. That precision is real and it is worth trusting — nothing else in your investigation will be this exact about where.

It is nearly silent about why. The state that caused the failure is not in it. The value that was null, the bytes that were not what the code expected, the config that differed, the call three frames back that returned something odd and was never checked — none of that is recorded. The trace tells you where the wheels came off, not what loosened them, and the gap between those is where every wrong fix lives.

Which sets the limit on the help. Everything downstream works from the same forty lines you have, plus whatever snippets and context you paste beside them — that is the whole input. No code is executed and the exception is not reproduced; no runtime values, memory or threads are inspected; no symbols or source maps are loaded, which is exactly why the build questions in Step 4 stay yours to answer. NewPrompt has no reach into your application, your debugger, your logs or anything in production, and a frame a model points at is a hypothesis with its reasoning attached rather than a diagnosis. That is worth a great deal. It is not the same thing as an answer, and the engineer who closes the gap between them is you, against code, logs and a reproduction.

Step 1: Keep the whole thing, and redact it before it leaves the machine

The first loss happens before any analysis: somebody pastes the top three lines, because those looked like the important ones. Everything this guide does afterwards depends on the parts that got dropped — the inner exceptions, the frame ownership, the async markers, the absent line numbers.

So keep the whole chain. Every exception, every frame, the markers between them, and whatever context sits alongside it in the log: timestamp, job or request id, machine, runtime version. If the trace was truncated before it reached you — a log line limit, a UI that clips — that truncation is itself a finding, and worth chasing, because what got cut is disproportionately the innermost part, which is the part that had the complaint.

Then redact, and know what you are redacting. Traces carry arguments, and arguments carry tokens, connection strings, signed URLs and personal data. Cut the credentials, keep the correlation ids — the identifier tying this trace to one failing run is usually the most useful non-code thing in it, and it is rarely the sensitive part. Redaction is subtraction with judgement, not a search-and-replace over anything that looks like a key.

  • Paste the full chain, not the headline. The inner exception is usually the one with the complaint.
  • Keep the context beside it: runtime version, timestamp, correlation or job id, host.
  • A truncated trace is a finding, not an inconvenience — the cut end is the informative one.
  • Redact credentials and personal data; keep the ids that tie the trace to the run.

Step 2: Read the chain from the inside out

A wrapped exception is several exceptions printed as one block, and they are not equal. The outer one is the last to be created and usually the least informative: it tells you which machinery noticed, not what went wrong. "One or more errors occurred" is a scheduler saying a task faulted. It is true and it is nearly empty.

The inner exception — the innermost one, at the end of the caused-by chain — is where the actual complaint is, and reading the chain from the inside out is the single habit that changes most people's hit rate on traces. Ask what each layer added: a job id, a phase name, a retry count, or nothing at all. Wrappers that add nothing are common and are worth noticing, because a wrapper adding nothing means someone caught an exception, decided to rethrow it with a friendlier name, and discarded the context you now need.

But do not overcorrect. The innermost exception is the most informative layer; it is not automatically the root cause. It is the deepest thing that noticed, which is a different claim. A parser saying "this is not valid here" is telling you precisely what it received and nothing whatsoever about who handed it over — and the answer to a why is almost always in the layer that has no exception of its own.

  • Outer to inner: which machinery noticed → which layer wrapped it → what actually complained.
  • Ask what each wrapper added. A wrapper that adds nothing usually threw away something.
  • The innermost exception is the best evidence in the chain, not the verdict.
  • Read its message literally. It is a statement about what that code received, not about your system.

Step 3: Find the first frame you own, and tell a throw from a rethrow

Now find the first application-owned frame — reading outward from where the innermost exception was raised, the first frame that belongs to code your team writes. That frame is the handover point: above it your dependency failed, at it your code called that dependency, and below it are the callers who supplied whatever it was given.

It is the most useful frame in the trace and it is not the bug. It is where your code met the failure. What made the failure inevitable is usually below, in whichever caller produced the argument nobody checked.

Then separate throw from rethrow. A frame appearing twice is not recursion — it is two different exceptions' stacks printed one after the other. Say a method awaits an operation on line 57, catches what comes back on line 60, and wraps it on line 61: the inner exception's stack ends at 57 and the wrapper's stack starts at 61. Same method, same trace, two different events, four lines apart in the file and adjacent in the output. Read those as one call path and you will invent a bug that does not exist.

And do not write off the framework frames while you are at it. You did not write them, but they are informative: they say which subsystem was running and, often, exactly which operation. Five parser frames deep in an XML reader tell you the failure is in parsing rather than in fetching — a fact you would otherwise spend an hour establishing. Not yours does not mean not evidence.

  • First frame you own = where your code met the failure. The cause is often in its caller.
  • A method appearing twice is usually a throw and a rethrow, not recursion.
  • The await line and the catch line are different events. The gap between them is not a call path.
  • Framework frames name the subsystem and the operation. Skim them; do not delete them.

Step 4: Decide whether the frame can be trusted

A file and a line number look like the most solid thing in the trace, which is why they are worth one minute of suspicion before you build on them. They describe the build that ran, and you are looking at the branch on your desk. Those agree far less often than people assume: the deployment lags, the hotfix went out from somewhere else, a shared library ships from its own pipeline. When line 88 in your editor is a null guard rather than the call the trace says threw, the trace is not lying — it is talking about a different build. Resolve it against the deployed commit before quoting either number in an argument.

Missing line numbers are a signal of their own. Framework frames have none because you do not have their symbols, which is expected. A frame with your company's name and no line number is different — that assembly went out without its symbols, and no amount of reading will get a line out of it until that changes.

Async is the other thing to get straight, and it is a rendering fact rather than a mystery. When an exception crosses an await, the runtime stitches the captured stack to the stack of whatever resumed it and prints a marker between them. The frames above and below that line were never on one physical stack at one moment. The order is still meaningful; the continuity is not — and the exact marker text varies by runtime version, which is one of several reasons the version belongs in what you paste.

The rest of the trust checklist is quick, and each item is a real trap: repeated frames that look like recursion and are not; several concurrent traces interleaved in one log, which is where phantom call paths come from; generated or reflection frames that name a compiler artifact rather than anything you can open; middleware and proxy layers that add depth without adding meaning.

  • File and line describe the deployed build. Verify against the deployed commit, not the working copy.
  • Your own frame with no line number = symbols not shipped with that assembly.
  • An async marker means two stacks were stitched. Order survives; continuity does not.
  • Two traces interleaved in one log will hand you a call path that never happened.

Step 5: Ask for an analysis that separates proof from suggestion

Now the analysis, and the shape of the request decides the value of the answer. Ask "what is wrong with this trace" and you get one confident story starting at whichever frame looked most interesting. The useful request forces the split the trace itself refuses to make: what does this prove, what does it merely suggest, what is missing, and what would settle it.

The Debugging Prompt Generator builds that request. Its field is labelled for exactly this — logs, errors, stack trace — and it carries what you paste into the prompt verbatim, fenced, unsummarized, which is what you want for an artifact where a single character of position matters. Pick the runtime-error type and the prompt it assembles says, in its own words, that the throw site is rarely the cause site, and requires a most-likely cause plus at least two alternatives, each with the evidence for it, the evidence against it, and the observation that would eliminate it. It labels every statement fact, assumption or hypothesis, and it asks what information is missing. All of that is browser-side prompt text — it runs nothing, and you paste it into your own assistant.

Two honest limits on it, because they change how you use it. It has no field for code, so the snippets go into your assistant alongside the prompt, not into the tool. And none of its stages mention frames, ownership, or exception chains — it gives you the evidence discipline, and the reading of the trace itself is what you bring from Steps 2 through 4. That division is fine as long as you know about it: it stops the model asserting, and you stop it misreading.

What you want back is an analysis you can argue with: the observed failure, the chain, the first frame you own, the call path in a sentence, the facts, the inference someone is making, the alternatives, the gaps, and the next checks in the order a person should actually run them — cheapest and most discriminating first, not most interesting first.

  • Force the split: proved / suggested / missing / what would settle it.
  • Paste the trace whole and fenced. Position and whitespace are evidence in this artifact.
  • Demand alternatives with contradicting evidence, not a story.
  • Order the next checks by what they eliminate per minute, not by what sounds most likely.

Step 6: Hand off what the trace cannot settle

The analysis ends with an inference, and an inference is not a diagnosis. Two handoffs close the gap, and naming them is the point of finishing here rather than pushing on.

The first is reproduction. The trace is unusually good at saying what a reproduction has to recreate — it names the conditions the failing path went through — and it is no help at all in building one. So write down what has to be true for this to happen again, and hand that over. That work is its own job with its own difficulties, and a hypothesis you cannot switch on and off is a hypothesis you have not tested.

The second is validating the fix, which is worth deciding now, while the reasoning is fresh and before anyone is invested in a change. What returns to normal? What must still fail, and loudly? That second question is the one that catches the fix that works by hiding the evidence — the try/catch that makes the trace stop being printed and calls that a repair.

  • Write down what a reproduction has to recreate. The trace names the conditions; it does not build one.
  • A hypothesis you cannot turn on and off has not been tested, only argued.
  • Decide the fix's validation before the fix exists, while nobody is attached to it.
  • Name what must still fail. A change that only silences the trace is not a repair.

Common mistakes

The trace-reading habits that reliably cost an afternoon:

  • Pasting the top three lines. The outer exception is the machinery that noticed. You dropped the one with the complaint.
  • Treating the innermost exception as the answer. It is the deepest thing that noticed — still a where, just a better one.
  • Reading NullReferenceException as "add a null check". Something was null that the code was written to assume never would be; the check at the throw site converts a loud failure into a quiet wrong answer, and the thing that produced the null is still doing it.
  • Taking a timeout as proof the dependency is down. A timeout says the call did not come back in time — which is also what a lock, a pool with no connections left, an oversized query and a retry storm look like from the caller's seat.
  • Quoting a line number from the working copy. The trace describes the deployed build; the two agree until the day it matters.
  • Believing the trace explains itself. It records the unwinding and nothing about the state that caused it — that part is not missing, it was never captured.

A worked example: a conversion worker that fails on some jobs

A background worker converts uploaded documents. Most jobs are fine. Some fail, and this is what the log has. Nobody can reproduce it, which is normal for background work and is the reason the trace is the whole of the evidence rather than one input among many.

Watch the message do the discriminating. "Data at the root level is invalid" is not the same complaint as "Root element is missing" — one of those means the parser got bytes it could not use, the other means it got none. The trace has already ruled something out for you, in a detail most readers skim, and the three hypotheses that survive all end at the same place: nobody has ever checked what that stream contained before handing it to a parser.

Three exceptions on one line, a message precise enough to rule out an empty stream, a bracket list that counts the casualties — and none of it proving which of the three hypotheses is the one
THE TRACE (.NET 8, straight out of the worker log):

System.AggregateException: One or more errors occurred. (Conversion failed for job 8f2c1d.) ---> DocumentConversion.ConversionFailedException: Conversion failed for job 8f2c1d. ---> System.Xml.XmlException: Data at the root level is invalid. Line 1, position 1.
   at System.Xml.XmlTextReaderImpl.Throw(Exception e)
   at System.Xml.XmlTextReaderImpl.ParseRootLevelWhitespace()
   at System.Xml.XmlTextReaderImpl.ParseDocumentContent()
   at System.Xml.XmlTextReaderImpl.Read()
   at System.Xml.Linq.XDocument.Load(XmlReader reader, LoadOptions options)
   at DocumentConversion.Converters.OfficeXmlConverter.ReadDocumentPart(Stream part) in C:\src\worker\Converters\OfficeXmlConverter.cs:line 141
   at DocumentConversion.Converters.OfficeXmlConverter.ConvertAsync(Stream input, CancellationToken ct) in C:\src\worker\Converters\OfficeXmlConverter.cs:line 88
   --- End of stack trace from previous location ---
   at DocumentConversion.Workers.ConversionWorker.ProcessJobAsync(ConversionJob job, CancellationToken ct) in C:\src\worker\Workers\ConversionWorker.cs:line 57
   --- End of inner exception stack trace ---
   at DocumentConversion.Workers.ConversionWorker.ProcessJobAsync(ConversionJob job, CancellationToken ct) in C:\src\worker\Workers\ConversionWorker.cs:line 61
   --- End of inner exception stack trace ---
   at System.Threading.Tasks.Task.WaitAll(Task[] tasks)
   at DocumentConversion.Batching.BatchRunner.RunBatch(ConversionJob[] jobs)
   at DocumentConversion.Workers.ConversionWorker.ExecuteAsync(CancellationToken stoppingToken) in C:\src\worker\Workers\ConversionWorker.cs:line 31

READ INSIDE OUT -- three exceptions, not one:
  That first line is not one exception. It is three, printed end to end,
  separated by two "--->" arrows sitting mid-sentence where nothing draws
  the eye to them. That is why the chain gets missed: it does not look
  like a list, it looks like a long sentence.

  outer    AggregateException            a batch faulted. Adds nothing of
                                         its own -- the job id in its
                                         message is quoted from the inner.
  middle   ConversionFailedException     names the job. Adds no diagnosis.
  inner    XmlException                  the only one with a complaint.

THE FRAMES THAT MATTER:
  ReadDocumentPart:141   first frame we own, reading out from the throw site
  ConvertAsync:88        its caller
  ProcessJobAsync:57     where the inner exception surfaced (the await)
  ProcessJobAsync:61     where the wrapper was thrown (inside the catch)
  -> 57 and 61 are the same method twice. That is not recursion. They are
     two different exceptions' stacks, printed one after the other.

WHAT THE TRACE PROVES:
  - An XML parser was handed bytes whose first character was not valid
    at root level.
  - Those bytes were NOT zero. An empty stream raises "Root element is
    missing." This says something else, so something was there.
  - "Line 1, position 1" is position 1 of what the parser was handed --
    not of the file in storage. The parser cannot see storage.
  - The failure is inside ReadDocumentPart, under ConvertAsync, in a job.
  - Exactly one job faulted. That outer message is assembled by appending
    every inner exception's message in brackets, so the bracket list is
    exhaustive: one clause, one casualty. Two would read "(Conversion
    failed for job 8f2c1d.) (Conversion failed for job a91b3e.)" and
    carry a second inner-exception block below. The line you were ready
    to skip as boilerplate is the only one here that counts anything.

WHAT IT DOES NOT PROVE:
  - That the document is corrupt. The parser is complaining about what
    arrived, not about what is in storage.
  - That line 141 in your editor is the line that ran (see below).
  - Which of the three hypotheses below is the right one.

TWO REASONS THE FILE AND LINE INFO WILL NOT CARRY YOU:
  - BatchRunner.RunBatch has our company name and no "in ...:line".
    Its symbols are not in this deployment -- it ships from a separate
    internal package. No line will appear for it until that changes.
  - OfficeXmlConverter.cs:line 88 in the working copy is a null guard,
    not the ReadDocumentPart call. Either the trace is from a different
    commit than the branch open on the desk, or the deployed build is.
    Resolve against the deployed SHA before quoting either number.

THREE HYPOTHESES (all produce this exact message):
  H1  The stream is not the document. The storage fetch returned a JSON
      error body -- {"error":"..."} -- and the code parses whatever came
      back without checking the status code or the length first.
  H2  The document part really is malformed. A genuine bad upload.
  H3  The stream was already partly read. The batch hands converters a
      pooled buffer, so when two jobs overlap one can advance the other's
      read position -- the parser then starts mid-document, and its
      position 1 is our position 400. It only bites when jobs overlap,
      which is why most of them are fine.

NEXT CHECKS, IN THIS ORDER:
  1. Log byte length and the first 200 bytes, redacted, immediately
     before the Load call at 141. Cheapest, and separates all three at
     once: an error payload reads as an error payload, a mid-document
     start reads as XML with its head cut off, a bad upload reads as a
     bad upload.
  2. Check the storage fetch status code for the failing job ids. (H1)
  3. Fetch one failing source document and parse it on its own. (kills H2)
  4. Check whether the failures track batch concurrency. (H3 -- a shared
     or pooled stream only misbehaves when something else is reading.)

CODE AND CONTEXT TO ASK FOR:
  ReadDocumentPart, roughly 130-145 -- what it hands to Load
  the await path in ConvertAsync near 88
  the catch at ConversionWorker.cs:61 -- what it swallows on the way out
  the deployed commit SHA, and the runtime version

BEFORE ANY OF IT LEAVES THE MACHINE:
  the log line beside the trace reads
    source=https://acct.blob.core.example.net/docs/8f2c1d.docx?sv=...&sig=REDACTED
  the sig is a live credential. Redact it. The job id is fine -- it is
  the only thing tying the trace to the failing run.

THE FIX THAT IS WRONG:
  "The file is corrupt. Wrap the Load in a try/catch and skip bad
   documents."
  It fits every symptom and it is only correct under H2. Under H1 and H3
  it hides a plumbing bug permanently AND silently discards documents
  that were never damaged -- and it does it quietly, because the whole
  point of the change is to stop the trace being printed.

LEFT FOR THE REPRODUCTION:
  the trace names what a repro has to recreate -- a job whose stream is
  not a rewound, complete document. Building that is the next job.

AFTER THE FIX:
  the failing job ids convert; the batch error rate returns to baseline;
  and a genuinely corrupt document still fails loudly rather than being
  skipped in silence.

Where this fits in NewPrompt

The Debugging Prompt Generator is the practical entry point: paste the trace into the field that names it, pick the runtime-error type, and it builds the investigation prompt in your browser. What comes out is text. You run it in your own assistant, with your code snippets beside it, and you judge what comes back.

Three resources are the same prompt already filled in, and they are worth reading for their shapes rather than their subjects. The Stack Trace Analysis Prompt works a traceback down to the entry frame and asks the contract question — fix the caller or fix the callee. The Error Analysis Prompt is the throw-site-to-root-trigger version, and it is where the null-check point is made in one line: a null check at the throw site is a symptom fix unless the trigger is addressed. The Investigate Runtime Errors Prompt is the closest to the worked example above — it is built for the failure that only happens on some inputs, and its job is to name the implicit contract those inputs violate, which is exactly what the conversion worker never checked.

The AI Debugging Workflow is where this sits in the longer process, and it puts reproduction first — reproduce, then trace it to a cause, then understand the code before changing it. That order is right when you can reproduce. Traces from background work usually arrive before any reproduction exists, which inverts the first two steps: the trace is what you have, and reading it is what tells the reproduction what to build. Same sequence, entered from the other end.

It is worth being clear about what this does not cover, because a trace tempts you to answer questions it has no standing on. It will not tell you whether to declare an incident, what the customer impact is, or whether this is the same failure as last Tuesday. It is one artifact from one run. The value of reading it properly is not that it names the bug — it is that a wall of confident-looking evidence turns into three hypotheses and one cheap check that kills two of them before lunch.

Tools for this guide

Each generates the prompt described above — you run it in your own AI assistant.

Ready-made resources

Reusable prompts and templates for the exact steps in this guide.

Take it further

When this task is one step inside a larger workflow or build.

FAQ

Does the AI run my code or attach to a debugger to analyze the trace?

No. It reads the trace, the snippets and the context you paste, and nothing else. There is no connection to your application, your logs, your debugger or anything in production; no code is executed, the exception is not reproduced, and no runtime values, memory or threads get inspected. Symbols and source maps are not loaded either, which is why questions about whether a line number matches the deployed build stay on your side of the line.

Is the top frame the bug?

Usually not, and with a wrapped exception the top frame is not even where anything was thrown — it is where the outermost wrapper was created, which is often a scheduler or a task runner noticing that something else failed. Read the chain from the inside out. Even then the innermost exception is the deepest thing that noticed, which is a better where and still not a why: a parser rejecting its input is being precise about what it received and says nothing about who handed it over.

The trace points at a line that looks fine. What now?

Three possibilities worth separating before you read that line again. The build may differ — the trace describes what was deployed, and your working copy may be several commits from it, so resolve against the deployed SHA first. The frame may be a rethrow rather than a throw, in which case that line is where an exception was wrapped, not where it arose. Or the line is genuinely fine and is simply where your code met a failure that was already inevitable, in which case the thing worth reading is its caller and whatever it passed in unchecked.

How is this different from asking AI to debug the problem?

Scope, and it matters because they run in different orders. Debugging starts from a symptom and gathers evidence from wherever it can be found — the environment, recent changes, where it works versus where it does not — carrying competing causes until a check settles one. Analyzing a trace starts with the evidence already in hand, in one artifact, and asks what it supports. It is a step inside debugging rather than a smaller version of it, and its honest output is not a cause but a shortlist plus the cheapest check that shortens it.