Debugging and Diagnostics

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Visual Basic 17.13 on .NET 10 (SDK 10.0.402) and was checked on 2026-10-19. The examples that start with Option Strict On are complete programs that were built with warnings treated as errors (except where the text says otherwise) and run in the .NET 10 SDK container (Linux, arm64); the output shown is real output. Every console example has the root namespace ShelfDocs, because exception messages, stack traces and logger categories include it. Everything that needs the Visual Studio debugger (breakpoints, the Immediate window, Edit and Continue) needs Windows and was not run here: those sections say what the Microsoft documentation states and are labelled "not run". Tools that run from the command line (dotnet watch, dotnet-counters, dotnet-trace, dotnet-stack, dotnet-dump, BenchmarkDotNet) were run against Visual Basic programs in the same container, and the results include two places where Visual Basic differs from C#.

Debugging has two halves. The first is the interactive half, where you stop a program and look at it: a debugger, with the breakpoints, windows and edit commands of Visual Studio. The second is the evidence half, where the program or a tool outside it records what happens so that you can read it later or on another machine: Debug and Trace, logging, counters, traces, dumps and benchmarks. Visual Basic uses the same .NET diagnostics stack as C#, because the compiler produces ordinary IL with ordinary debug information. The differences are few but they matter, and this page names each one where it occurs. The projects and the build properties are on Projects, MSBuild and the dotnet CLI; exception syntax is on Structured Exception Handling; test projects, which are the best debugging tool for logic errors, are on Testing.

Five questions and the tool that answers each one: why does this line do that (debugger), what did it do before it failed (Debug, Trace and ILogger), how is the running app doing (counters and traces), where is it stuck or dead (stack and dump tools), is this version faster (BenchmarkDotNet)

Debug and Release builds

Every debugging technique depends on what the compiler emitted. The SDK has two standard configurations, Debug and Release, and the differences that matter for debugging are these (the values come from dotnet msbuild -getProperty on the sample project):

Property Debug Release Meaning

Optimize

false

true

Release lets the compiler and the JIT inline, reorder and remove code. A debugger sees fewer locals, and the "current line" jumps.

DebugType

portable

portable

The kind of symbol file. Portable PDBs work on every platform and the SDK writes one next to the assembly in both configurations.

DebugSymbols

true

false

The classic switch for -debug. In both configurations a .pdb is written because DebugType is portable.

DefineDebug

true

(empty)

Defines the conditional-compilation constant DEBUG, which is what Debug.WriteLine and #If DEBUG Then depend on.

DefineTrace

true

true

Defines TRACE in both configurations, so Trace calls stay in Release builds.

Deterministic

true

true

The same sources always produce the same bytes, which is what Source Link and symbol servers need.

PublishRelease

true

true

dotnet publish uses the Release configuration unless you say otherwise (the default since .NET 8).

The stack trace is the first diagnostic you meet. A Release build with the PDB beside it still prints file names and line numbers, because the PDB is what turns an IL offset into a source line. Remove the PDB and the frames keep their method names but lose their locations. The program below throws an exception that nobody handles; the same build is run twice, the second time with DebugType set to none:

Option Strict On

Module UnhandledDemo
    Public Sub Run()
        AddHandler AppDomain.CurrentDomain.UnhandledException,
            Sub(sender As Object, e As UnhandledExceptionEventArgs)
                Console.WriteLine("UnhandledException: " & CType(e.ExceptionObject, Exception).Message & ", terminating=" & e.IsTerminating.ToString())
            End Sub
        Throw New InvalidOperationException("nobody caught this")
    End Sub
End Module
UnhandledException: nobody caught this, terminating=True
Unhandled exception. System.InvalidOperationException: nobody caught this
   at ShelfDocs.UnhandledDemo.Run() in /w/UnhandledDemo.vb:line 9
   at ShelfDocs.Program.Main(String[] args) in /w/Program.vb:line 14

With dotnet build -c Release -p:DebugType=none the output file list has no Core.pdb and the same program prints:

UnhandledException: nobody caught this, terminating=True
Unhandled exception. System.InvalidOperationException: nobody caught this
   at ShelfDocs.UnhandledDemo.Run()
   at ShelfDocs.Program.Main(String[] args)

Both runs end with exit code 134 (the process aborts after printing the exception on Linux; on Windows the code is different). Three facts follow. The AppDomain.UnhandledException handler runs before the runtime prints the message, and it cannot stop the process (IsTerminating is True); it is the place to flush a log. Keep the PDB with every build you ship, or publish it to a symbol server, because a production stack trace without line numbers costs hours. And EmbedAllSources, PathMap and Source Link let a debugger find the right source for a deployed assembly; they are described for libraries on Libraries, Packaging and Deployment.

The programs on this page share one console project and a dispatcher, so that one build produces every example. The project file is the usual one, with the SDK defaults overridden by the properties that the rest of the site recommends:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <RootNamespace>ShelfDocs</RootNamespace>
    <OptionStrict>On</OptionStrict>
    <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
    <NuGetAudit>false</NuGetAudit>
  </PropertyGroup>
</Project>

and the dispatcher selects the demo by the first command-line argument:

Option Strict On

Module Program
    Sub Main(args As String())
        Dim which As String = If(args.Length > 0, args(0), "")
        Select Case which
            Case "trace" : TraceDemo.Run()
            Case "assert" : AssertDemo.Run()
            Case "assertdefault" : DefaultAssertDemo.Run()
            Case "cond" : ConditionalDemo.Run()
            Case "stop" : StopDemo.Run()
            Case "attrs" : AttrDemo.Run()
            Case "first" : FirstChanceDemo.Run()
            Case "unhandled" : UnhandledDemo.Run()
            Case "metrics" : MetricsDemo.Run()
            Case Else : Console.WriteLine("unknown example")
        End Select
    End Sub
End Module

The Visual Studio debugger for Visual Basic (not run)

Visual Studio 2026 is the complete debugger for Visual Basic. The Microsoft documentation describes the debugger as language neutral, with a few places where Visual Basic is named explicitly. What follows is a checklist drawn from those pages, not output of this page’s container.

Breakpoints, stepping and the windows

  • Breakpoints. A breakpoint can carry a conditional expression, a hit count or a filter, and it can log a message instead of stopping (a "tracepoint"). A condition is an expression, so write it with the Visual Basic operators (AndAlso, Is Nothing, = for equality). A condition is evaluated on every pass and slows a hot loop; a hit count is cheaper.

  • Stepping. Step Into, Step Over, Step Out and Run to Cursor behave as in any .NET language. Code that carries the debugger attributes described below is stepped over when Just My Code is on.

  • Locals, Autos and Watch. Watch accepts any expression the expression evaluator understands. The Microsoft page on debugger expressions lists what Visual Basic cannot evaluate: for dynamic objects, the compound operators +=, -=, %=, /= and *=, many casts, method calls with more than two arguments, property setters with arguments, assignment to an indexer, and the short-circuit operators on dynamic operands.

  • The Immediate window. It evaluates a Visual Basic expression in the current frame, and while debugging is stopped it can also change state. The documentation’s own examples are j = 2 (prints that the expression was evaluated and has no value) and ? j (prints the value), and ?MyFunction(2) calls a function of a Visual Basic console project at design time.

  • Exception Settings. The debugger can break when an exception is thrown (first chance), not only when it is unhandled. The Microsoft page notes that in a Visual Basic application the debugger treats all errors as exceptions, even when the program uses the legacy unstructured handlers, and that the Exception Settings window supports generic exception types in C# but not in Visual Basic: to break on MyNamespace.GenericException(Of T) you add the exception by its metadata name, with a backtick and the arity.

  • Just My Code. With Just My Code on, the debugger steps over and does not stop in code that is not yours. Code is "not yours" when it has no symbols loaded, or when it carries attributes such as DebuggerNonUserCode, DebuggerHidden or DebuggerStepThrough.

  • Attach to Process. A running process can be debugged by attaching the debugger to it; Edit and Continue is not available after attaching.

Edit and Continue and Hot Reload

Visual Studio’s Hot Reload, which the Microsoft documentation says was previously called Edit and Continue, lets you change code while the program is stopped at a breakpoint (and, for supported .NET 6+ apps, while it runs without a debugger) and apply the change without restarting. The documentation states that the debugger-attached experience works for both C# and Visual Basic on .NET Framework and on .NET 5 and later, and gives a separate page, "Hot Reload (Visual Basic)", with the limits that matter here:

  • It is not available when you started debugging with Attach to Process, and not for optimized code or mixed managed and native code.

  • The list of unsupported changes is shared with C#, plus Visual Basic-specific ones: you cannot modify a member that uses a legacy unstructured error statement, or one that contains an Aggregate, Group By, simple Join or Group Join query clause.

  • Changing the signature of a method, adding a destructor or changing the current statement or any other active statement (anything on the call stack) requires a restart.

  • If you make a change that is not allowed, Visual Studio underlines it with a purple wavy line and adds a task to the Task List; you cannot continue until you undo the illegal edit.

The command-line Hot Reload is a different story, and it is the one result on this page that contradicts what you might expect. The dotnet watch command applies Hot Reload for C# (the documentation: "Starting in .NET 6 SDK, dotnet watch includes support for hot reload"). The program below is a loop that prints the result of a function every half second; a second process edits the function six seconds into the run, while dotnet watch run --non-interactive is running:

Option Strict On

Imports System.Threading

Module Program
    Function Message() As String
        Return "version one"
    End Function

    Sub Main()
        For i As Integer = 1 To 12
            Console.WriteLine(i.ToString() & ": " & Message())
            Thread.Sleep(500)
        Next
    End Sub
End Module
dotnet watch : Hot reload enabled. For a list of supported edits, see https://aka.ms/dotnet/hot-reload.
dotnet watch : Waiting for changes
1: version one
2: version one
3: version one
4: version one
5: version one
6: version one
7: version one
8: version one
9: version one
dotnet watch : File updated: ./Program.vb
dotnet watch : Restart is needed to apply the changes.
dotnet watch : [Hot (net10.0)] Exited
dotnet watch : Waiting for changes
1: version two
2: version two
3: version two

The loop restarted from 1 with the new text. With --verbose the reason is printed: error ENC1009: Changing source file 'Program.vb' in a project 'Hot' that does not support Hot Reload requires restarting the application; Visual Basic does not support Hot Reload. The same experiment with a C# project (an equivalent top-level program with the same function and the same edit) printed dotnet watch : C# and Razor changes applied in 320ms. and the loop continued from where it was, now printing version two.

So on SDK 10.0.402 dotnet watch is a rebuild-and-restart loop for Visual Basic, which is still useful (save, and the program restarts), but it is not Hot Reload; and the claim that Hot Reload "supports Visual Basic" is true of the Visual Studio debugger, as the Microsoft pages say, and was not true of the dotnet watch host in this test. Do not plan a Visual Basic workflow around dotnet watch preserving state. (The dotnet watch command exists for Visual Basic projects: only the delta application is missing.)

Source: dotnet watch

Debug and Trace

The System.Diagnostics.Debug and Trace classes write messages and check assumptions. They share one list of listeners, the objects that receive the text. They differ in one way: every method of Debug is marked <Conditional("DEBUG")> and every method of Trace <Conditional("TRACE")>, so the compiler removes the call, including the evaluation of its arguments, when the constant is not defined. The next program shows the effect on a call with a side effect. Note is a procedure of the program that is conditional on a constant the project does not define:

Option Strict On

Imports System.Diagnostics

Module ConditionalDemo
    Private _calls As Integer

    Private Function Expensive() As String
        _calls += 1
        Return "computed"
    End Function

    <Conditional("VERBOSE")>
    Private Sub Note(message As String)
        Console.WriteLine("note: " & message)
    End Sub

    Public Sub Run()
        Debug.WriteLine(Expensive())
        Trace.WriteLine(Expensive())
        Note(Expensive())
        Console.WriteLine("Expensive() ran " & _calls.ToString() & " time(s)")
    End Sub
End Module

Output of the Debug build, of the Release build, and of the Release build with -p:DefineConstants=VERBOSE:

== Debug
Expensive() ran 2 time(s)
== Release
Expensive() ran 1 time(s)
== Release with DefineConstants=VERBOSE
note: computed
Expensive() ran 2 time(s)

In Debug, Debug.WriteLine and Trace.WriteLine each evaluate their argument (two calls); in Release the Debug call and its argument disappear and only the Trace call remains (one call). Note is not called at all unless VERBOSE is defined, and when it is, its argument is evaluated too. In Visual Basic a Conditional attribute is allowed only on a Sub; on a Function the compiler reports BC41007 (see Conditional Compilation and Directives). The practical rule: never put work that the program needs inside an argument of a Debug call.

Listeners, indentation and switches

On .NET a new process has one listener, DefaultTraceListener, which writes to an attached debugger and, on Windows, to the debug output stream; on a console with no debugger attached the messages go nowhere. To see them, add a listener such as ConsoleTraceListener. The program also shows Indent, conditional writes, a TraceSwitch (a named verbosity level, which can be set from configuration code) and a TraceSource (a named channel with its own listeners and a minimum level, which is the better choice for a library):

Option Strict On

Imports System.Diagnostics

Module TraceDemo
    Public Sub Run()
        Trace.Listeners.Clear()
        Trace.Listeners.Add(New ConsoleTraceListener())

        Debug.WriteLine("Debug.WriteLine: only in Debug builds")
        Trace.WriteLine("Trace.WriteLine: in every build")

        Debug.Indent()
        Debug.WriteLine("indented once")
        Debug.Unindent()

        Dim verbose As Boolean = False
        Debug.WriteLineIf(verbose, "never written")
        Trace.WriteLineIf(Not verbose, "written because the condition is true")

        Dim sw As New TraceSwitch("Shelf", "Shelf tracing") With {.Level = TraceLevel.Warning}
        Trace.WriteLineIf(sw.TraceInfo, "info message (suppressed)")
        Trace.WriteLineIf(sw.TraceWarning, "warning message (shown)")

        Dim source As New TraceSource("Shelf.Loans", SourceLevels.Information)
        source.Listeners.Clear()
        source.Listeners.Add(New ConsoleTraceListener() With {.TraceOutputOptions = TraceOptions.None})
        source.TraceEvent(TraceEventType.Information, 10, "loan {0} created", 42)
        source.TraceEvent(TraceEventType.Verbose, 11, "this verbose event is filtered out")
        source.TraceEvent(TraceEventType.Warning, 12, "loan {0} is overdue", 42)
        source.Flush()

#If DEBUG Then
        Console.WriteLine("DEBUG is defined")
#Else
        Console.WriteLine("DEBUG is not defined")
#End If
#If TRACE Then
        Console.WriteLine("TRACE is defined")
#End If
    End Sub
End Module

Output in a Debug build (dotnet run):

Debug.WriteLine: only in Debug builds
Trace.WriteLine: in every build
    indented once
written because the condition is true
warning message (shown)
Shelf.Loans Information: 10 : loan 42 created
Shelf.Loans Warning: 12 : loan 42 is overdue
DEBUG is defined
TRACE is defined

Output of the same program built with -c Release:

Trace.WriteLine: in every build
written because the condition is true
warning message (shown)
Shelf.Loans Information: 10 : loan 42 created
Shelf.Loans Warning: 12 : loan 42 is overdue
DEBUG is not defined
TRACE is defined

The Verbose event was filtered by the SourceLevels.Information level of the source. The TraceSwitch suppressed info message, because its level was Warning. Debug.Indent shifts only the Debug and Trace output that follows, in steps of IndentSize (four spaces).

Debug.Assert and what a failed assertion does

Debug.Assert(condition, message) documents an assumption: a bug in your code, not a condition the user can cause. When the condition is False, the Fail method of the listeners runs. The two programs below show both outcomes. The first replaces the listeners with one whose Fail throws an exception, which is also how a test project turns assertions into failures; it also shows that Trace.Assert survives into Release while Debug.Assert does not:

Option Strict On

Imports System.Diagnostics

Public NotInheritable Class ThrowingListener
    Inherits TraceListener

    Public Overrides Sub Write(message As String)
        Console.Write(message)
    End Sub

    Public Overrides Sub WriteLine(message As String)
        Console.WriteLine(message)
    End Sub

    Public Overrides Sub Fail(message As String, detailMessage As String)
        Throw New InvalidOperationException("Assertion failed: " & message)
    End Sub
End Class

Module AssertDemo
    Public Sub Run()
        Trace.Listeners.Clear()
        Trace.Listeners.Add(New ThrowingListener())

        Dim quantity As Integer = 0
        Try
            Debug.Assert(quantity > 0, "quantity must be positive")
            Console.WriteLine("Debug.Assert passed or was removed")
        Catch ex As InvalidOperationException
            Console.WriteLine(ex.Message)
        End Try

        Try
            Trace.Assert(quantity > 0, "quantity must be positive")
            Console.WriteLine("Trace.Assert passed")
        Catch ex As InvalidOperationException
            Console.WriteLine(ex.Message)
        End Try
    End Sub
End Module

Module DefaultAssertDemo
    Public Sub Run()
        Console.WriteLine("Asserting with the default listener")
        Debug.Assert(1 + 1 = 3, "arithmetic is broken")
        Console.WriteLine("not reached in a Debug build")
    End Sub
End Module

Output in a Debug build, then the output of the Release build (the first line differs because the Debug.Assert call was removed):

Assertion failed: quantity must be positive
Assertion failed: quantity must be positive
Debug.Assert passed or was removed
Assertion failed: quantity must be positive

The last demo in the file (assertdefault) leaves the default listener in place. On .NET with no debugger attached, a failed assertion does not show a dialog: the default listener writes the message and the stack and terminates the process, with exit code 134:

Asserting with the default listener
Process terminated.
Assertion failed.
arithmetic is broken
   at ShelfDocs.DefaultAssertDemo.Run() in /w/AssertDemo.vb:line 46
   at ShelfDocs.Program.Main(String[] args) in /w/Program.vb:line 9

Books written for .NET Framework describe the older behaviour, a dialog box with Abort, Retry and Ignore buttons; on .NET Core and later that dialog is gone and a failed assertion in an unattended process is fatal. If your tests exercise code with Debug.Assert, install a listener like the one above.

The Stop statement and the Debugger class

Stop is the Visual Basic statement that suspends execution as if a breakpoint were set; Debugger.Break() is the .NET method with the same intent, and Debugger.Launch() asks the operating system to start a debugger. The Learn page for Stop says that when it is reached outside the IDE "the debugger is invoked", which is the Windows just-in-time debugging mechanism. The program shows what a process observes on Linux, where there is no such mechanism:

Option Strict On

Imports System.Diagnostics

Module StopDemo
    Public Sub Run()
        Console.WriteLine("Debugger.IsAttached = " & Debugger.IsAttached.ToString())
        Console.WriteLine("before Stop")
        Stop
        Console.WriteLine("after Stop")
        Debugger.Break()
        Console.WriteLine("after Debugger.Break")
    End Sub
End Module
Debugger.IsAttached = False
before Stop
after Stop
after Debugger.Break

On Linux, with no debugger, both calls returned and the program carried on. What happens on Windows without a debugger depends on the system’s just-in-time debugging configuration, which is why you should not leave Stop or Debugger.Break() in code you ship; guard them with If Debugger.IsAttached Then when you want a "break if someone is watching" statement. Debugger.Launch() is useful to start debugging a Windows service or a short-lived child process from its first line.

Controlling what the debugger shows

The debugger shows what the objects contain, which is rarely what you want to read. Attributes in System.Diagnostics change the views, and the compiler for Visual Basic applies them like any other attribute:

Attribute Effect in the debugger

<DebuggerDisplay("...")>

The text shown in the value column. Text in braces is evaluated as an expression, so the syntax of the expression is that of the language being debugged; keep it to member names to stay portable. It takes precedence over an overridden ToString, and it is ignored if the "show raw structure of objects" option is on.

<DebuggerBrowsable(DebuggerBrowsableState.Never)>

Hides a field or property from the expanded view (also Collapsed and RootHidden).

<DebuggerTypeProxy(GetType(...))>

Shows an instance of another class in place of the real members when the object is expanded.

<DebuggerStepThrough>

Steps over the method as if it were not yours; breakpoints inside are still hit, and the code still appears on stacks.

<DebuggerHidden>

The debugger does not stop in the method at all (breakpoints inside are ignored).

<DebuggerNonUserCode>

Marks code as not yours for Just My Code: the debugger steps over it and shows it as external code. Designers and code generators use it.

The program applies all of them to a small reference-scenario class and reads the metadata back by reflection, which is the only part that can be demonstrated without a debugger:

Option Strict On

Imports System.Diagnostics
Imports System.Reflection

<DebuggerDisplay("{Title} ({Copies} copies)")>
Public Class BookStock
    Public Property Title As String = "Dune"
    Public Property Copies As Integer = 3

    <DebuggerBrowsable(DebuggerBrowsableState.Never)>
    Private ReadOnly _cache As New Dictionary(Of String, String)()

    <DebuggerStepThrough>
    Public Function Total() As Integer
        Return Copies * 2
    End Function

    <DebuggerHidden>
    Public Sub Plumbing()
    End Sub
End Class

<DebuggerTypeProxy(GetType(LoanProxy))>
Public Class Loan
    Public ReadOnly Property Due As Date = New Date(2026, 10, 19)
    Friend ReadOnly Property Note As String = "internal detail"
End Class

Public Class LoanProxy
    Private ReadOnly _loan As Loan

    Public Sub New(loan As Loan)
        _loan = loan
    End Sub

    Public ReadOnly Property DueText As String
        Get
            Return _loan.Due.ToString("yyyy-MM-dd")
        End Get
    End Property
End Class

Module AttrDemo
    Public Sub Run()
        Dim display As DebuggerDisplayAttribute = GetType(BookStock).GetCustomAttribute(Of DebuggerDisplayAttribute)()
        Console.WriteLine("DebuggerDisplay: " & display.Value)

        Dim cache As FieldInfo = GetType(BookStock).GetField("_cache", BindingFlags.Instance Or BindingFlags.NonPublic)
        Console.WriteLine("Browsable state of _cache: " & cache.GetCustomAttribute(Of DebuggerBrowsableAttribute)().State.ToString())

        Dim total As MethodInfo = GetType(BookStock).GetMethod("Total")
        Console.WriteLine("Total has DebuggerStepThrough: " & (total.GetCustomAttribute(Of DebuggerStepThroughAttribute)() IsNot Nothing).ToString())
        Dim plumbing As MethodInfo = GetType(BookStock).GetMethod("Plumbing")
        Console.WriteLine("Plumbing has DebuggerHidden: " & (plumbing.GetCustomAttribute(Of DebuggerHiddenAttribute)() IsNot Nothing).ToString())

        Dim proxy As DebuggerTypeProxyAttribute = GetType(Loan).GetCustomAttribute(Of DebuggerTypeProxyAttribute)()
        Console.WriteLine("Proxy type: " & proxy.ProxyTypeName)
    End Sub
End Module
DebuggerDisplay: {Title} ({Copies} copies)
Browsable state of _cache: Never
Total has DebuggerStepThrough: True
Plumbing has DebuggerHidden: True
Proxy type: ShelfDocs.LoanProxy, Core, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null

The DebuggerDisplay string is not checked by the compiler; the expression inside the braces is evaluated by the debugger when the object is shown, and the Microsoft documentation warns that the syntax of method calls and parameters can vary between languages, so use it with care and prefer member names. Because the compiler does not check the string, a typo shows up only as an error text in the debugger window. See Attributes, Caller Info and Reflection for the attribute syntax.

Diagnosing exceptions: first chance, filters and unhandled

An exception has two lives. The runtime first looks for a handler (the first pass); Catch ... When filters run during that pass, before the stack unwinds; then the stack unwinds and the Finally blocks run (the second pass). A first-chance exception is the notification that a Throw happened, before anyone knows whether a handler exists. AppDomain.FirstChanceException raises it for every exception, including ones that are caught and swallowed, which makes it the right hook for finding exceptions that code hides:

Option Strict On

Imports System.Runtime.ExceptionServices

Module FirstChanceDemo
    Private _firstChance As Integer

    Private Function LogAndDecline(ex As Exception) As Boolean
        Console.WriteLine("  filter sees: " & ex.GetType().Name & " from " & ex.TargetSite.Name)
        Return False
    End Function

    Private Sub Inner()
        Throw New FormatException("bad ISBN")
    End Sub

    Private Sub Outer()
        Try
            Inner()
        Catch ex As FormatException When LogAndDecline(ex)
            Console.WriteLine("never reached")
        End Try
    End Sub

    Private Sub Swallowed()
        Try
            Integer.Parse("x")
        Catch ex As FormatException
            ' swallowed on purpose: still a first-chance exception
        End Try
    End Sub

    Public Sub Run()
        AddHandler AppDomain.CurrentDomain.FirstChanceException,
            Sub(sender As Object, e As FirstChanceExceptionEventArgs)
                _firstChance += 1
                Console.WriteLine("first chance #" & _firstChance.ToString() & ": " & e.Exception.GetType().Name)
            End Sub

        Swallowed()
        Swallowed()
        Console.WriteLine("swallowed twice, counted " & _firstChance.ToString())

        Try
            Outer()
        Catch ex As FormatException
            Console.WriteLine("caught outside: " & ex.Message)
            Dim trace As New System.Diagnostics.StackTrace(ex, True)
            Console.WriteLine("top frame: " & trace.GetFrame(0).GetMethod().Name & " line " & trace.GetFrame(0).GetFileLineNumber().ToString())
        End Try
    End Sub
End Module
first chance #1: FormatException
first chance #2: FormatException
swallowed twice, counted 2
first chance #3: FormatException
  filter sees: FormatException from Inner
caught outside: bad ISBN
top frame: Inner line 14

Reading the output: the two swallowed exceptions were counted, so a program that "never fails" may be throwing and catching thousands of them (each is slow, because stack capture is expensive). The third exception shows the order: first chance #3 appears before the filter message, and the filter ran with Inner still on the stack (ex.TargetSite.Name is Inner), which is why a When LogAndDecline(ex) filter is a standard trick for logging an exception at the point of the throw without catching it, and why it returns False. New System.Diagnostics.StackTrace(ex, True) read the line number from the PDB. Keep a FirstChanceException handler short and make sure it cannot throw: it runs on the thread that is throwing, for every exception in the process.

The hook for the opposite case, an exception that nobody handles, is AppDomain.UnhandledException, shown in the first program of this page. For crashes in production the evidence you need is a dump, written by the runtime when the process fails. The documentation describes the environment variables (DOTNET_DbgEnableMiniDump, DOTNET_DbgMiniDumpType, DOTNET_DbgMiniDumpName), and the dump can then be analyzed with dotnet-dump (see below). Structured exception syntax, filters and Finally are on Structured Exception Handling; the legacy unstructured handlers are on Unstructured Error Handling and On Error.

Logging with ILogger

Debug and Trace are fine for development, but a service needs structured logging with levels, categories and scopes, and .NET has one abstraction for it, Microsoft.Extensions.Logging. It is a NuGet package (the example uses Microsoft.Extensions.Logging.Console 10.0.0), works from Visual Basic exactly as from C#, and the providers (console, debug, event log, OpenTelemetry, Serilog and others) plug in behind ILogger. Two Visual Basic points: the source-generated LoggerMessage attribute is documented on partial methods and was not tested from Visual Basic here, so the example uses the delegate form LoggerMessage.Define, which is the high-performance form anyway; and Imports Microsoft.Extensions.Logging makes the name Console refer to the namespace Microsoft.Extensions.Logging.Console in a project that references it, so write System.Console when you print.

Option Strict On

Imports Microsoft.Extensions.Logging

Public Class LoanService
    Private Shared ReadOnly LoanCreated As Action(Of ILogger, String, String, Exception) =
        LoggerMessage.Define(Of String, String)(LogLevel.Information, New EventId(1001, "LoanCreated"),
                                                "Loan created for {Member} of {Isbn}")

    Private Shared ReadOnly LoanFailed As Action(Of ILogger, String, Exception) =
        LoggerMessage.Define(Of String)(LogLevel.Error, New EventId(1002, "LoanFailed"), "Could not lend {Isbn}")

    Private ReadOnly _logger As ILogger(Of LoanService)

    Public Sub New(logger As ILogger(Of LoanService))
        _logger = logger
    End Sub

    Public Sub Lend(member As String, isbn As String)
        Using _logger.BeginScope("request {RequestId}", "r-7")
            _logger.LogDebug("Checking availability of {Isbn}", isbn)
            If isbn.EndsWith("0", StringComparison.Ordinal) Then
                Try
                    Throw New InvalidOperationException("no copies left")
                Catch ex As InvalidOperationException
                    LoanFailed(_logger, isbn, ex)
                End Try
            Else
                LoanCreated(_logger, member, isbn, Nothing)
            End If
        End Using
    End Sub
End Class

Module Program
    Sub Main()
        Using factory As ILoggerFactory = LoggerFactory.Create(
            Sub(builder As ILoggingBuilder)
                builder.SetMinimumLevel(LogLevel.Debug)
                builder.AddSimpleConsole(Sub(o) o.IncludeScopes = True)
            End Sub)
            Dim service As New LoanService(factory.CreateLogger(Of LoanService)())
            service.Lend("ada", "9780441013593")
            service.Lend("ada", "9780441172710")
        End Using
    End Sub
End Module
dbug: ShelfDocs.LoanService[0]
      => request r-7
      Checking availability of 9780441013593
info: ShelfDocs.LoanService[1001]
      => request r-7
      Loan created for ada of 9780441013593
dbug: ShelfDocs.LoanService[0]
      => request r-7
      Checking availability of 9780441172710
fail: ShelfDocs.LoanService[1002]
      => request r-7
      Could not lend 9780441172710
      System.InvalidOperationException: no copies left
         at ShelfDocs.LoanService.Lend(String member, String isbn) in /w/Program.vb:line 24

Each entry has a level (dbug, info, fail), a category (the type name, taken from ILogger(Of LoanService)), an event id in brackets, the active scope (=> request r-7) and the message, and an exception is printed after it. The placeholders {Member} and {Isbn} are named so that structured providers can store them as fields; the arguments are positional. The project file used TreatWarningsAsErrors and NuGetAudit false (the scratch project reads from nuget.org), and the build finished with 0 warnings. Never build the message with & before passing it (the template is parsed once and cached, and a built string defeats both structure and caching), and never log secrets: the message and stack of an exception are written in full.

Counters, metrics and activities

A running service is observed by numbers (how many requests per second, how much memory) and by traces (which call produced this slow request). .NET’s current APIs for both are System.Diagnostics.Metrics (a Meter with counters and histograms) and System.Diagnostics.Activity (an ActivitySource). They are in the base library, so a Visual Basic program needs no package. The program below creates both and reads them back inside the process, which is what a test does. Note the way the metrics are read: the usual MeterListener callback takes a ReadOnlySpan(Of KeyValuePair(Of String, Object)), and Visual Basic cannot write it.

Option Strict On

Imports System.Diagnostics.Metrics

Module Program
    Sub Main()
        Dim meter As New Meter("ShelfDocs.Loans")
        Dim created As Counter(Of Integer) = meter.CreateCounter(Of Integer)("shelf.loans.created")

        Using listener As New MeterListener()
            listener.InstrumentPublished =
                Sub(instrument As Instrument, l As MeterListener)
                    l.EnableMeasurementEvents(instrument)
                End Sub
            listener.SetMeasurementEventCallback(Of Integer)(
                Sub(instrument As Instrument, value As Integer, tags As ReadOnlySpan(Of KeyValuePair(Of String, Object)), state As Object)
                    Console.WriteLine(instrument.Name & " += " & value.ToString())
                End Sub)
            listener.Start()
            created.Add(1)
        End Using
    End Sub
End Module
Program.vb(16,73): error BC30668: 'ReadOnlySpan(Of KeyValuePair(Of String, Object))' is obsolete: 'Types with embedded references are not supported in this version of your compiler.'.

The compiler treats every Span(Of T) and ReadOnlySpan(Of T) as unusable (BC30668); the same limit is described on Structures and Value Types and Interop with C# and Other .NET Languages. Instruments can still be created and recorded from Visual Basic; what is closed is the in-process callback. Two ways round it: put the MeterListener in a small C# library, or read the metric through the EventListener that the runtime already exposes for System.Diagnostics.Metrics, as the next program does. It also shows an ActivityListener, whose callbacks use no spans:

Option Strict On

Imports System.Diagnostics
Imports System.Diagnostics.Metrics
Imports System.Diagnostics.Tracing
Imports System.Threading

Public NotInheritable Class MetricsPrinter
    Inherits EventListener

    Protected Overrides Sub OnEventSourceCreated(eventSource As EventSource)
        If eventSource.Name = "System.Diagnostics.Metrics" Then
            Dim args As New Dictionary(Of String, String)() From {
                {"SessionId", "shelf-session"},
                {"Metrics", "ShelfDocs.Loans"},
                {"RefreshInterval", "1"}
            }
            EnableEvents(eventSource, EventLevel.Informational, EventKeywords.All, args)
        End If
    End Sub

    Protected Overrides Sub OnEventWritten(eventData As EventWrittenEventArgs)
        If eventData.EventName = "CounterRateValuePublished" Then
            Dim names As IList(Of String) = eventData.PayloadNames
            Dim values As IList(Of Object) = eventData.Payload
            Console.WriteLine(Convert.ToString(values(names.IndexOf("instrumentName"))) &
                              ": rate=" & Convert.ToString(values(names.IndexOf("rate"))) &
                              " value=" & Convert.ToString(values(names.IndexOf("value"))))
        End If
    End Sub
End Class

Module MetricsDemo
    Public Sub Run()
        Dim meter As New Meter("ShelfDocs.Loans")
        Dim created As Counter(Of Integer) = meter.CreateCounter(Of Integer)("shelf.loans.created", "loans", "Loans created")
        Using printer As New MetricsPrinter()
            created.Add(1)
            created.Add(2)
            Thread.Sleep(2500)
        End Using

        Dim source As New ActivitySource("ShelfDocs.Loans")
        Using al As New ActivityListener()
            al.ShouldListenTo = Function(s As ActivitySource) s.Name = "ShelfDocs.Loans"
            al.Sample = Function(ByRef o As ActivityCreationOptions(Of ActivityContext)) ActivitySamplingResult.AllData
            al.ActivityStopped = Sub(a As Activity) Console.WriteLine("activity " & a.OperationName & " tag loan=" & CStr(a.GetTagItem("loan")))
            ActivitySource.AddActivityListener(al)
            Using a As Activity = source.StartActivity("CreateLoan")
                a.SetTag("loan", "L-42")
            End Using
        End Using
    End Sub
End Module
shelf.loans.created: rate=3 value=3
shelf.loans.created: rate=0 value=3
activity CreateLoan tag loan=L-42

The EventListener received a report once per RefreshInterval (one second here): the first interval saw a rate of 3 (the two calls added three loans) and the second a rate of 0, while value stays at the running total. The same event source is what the command-line tools read, as the next section shows.

The command-line diagnostic tools

The dotnet-counters, dotnet-trace, dotnet-stack and dotnet-dump tools attach to a running process by id through the runtime’s diagnostic port, with no change to the program, and they work on a Visual Basic application because the data comes from the runtime. They are .NET tools (dotnet tool install --tool-path /tools dotnet-counters installed version 10.0.750501 of each tool in the test container) and run on Linux, macOS and Windows. The program that they observed is a Visual Basic console loop that adds to the same counter ten times a second:

Option Strict On

Imports System.Diagnostics.Metrics
Imports System.Threading

Module Program
    Sub Main()
        Dim meter As New Meter("ShelfDocs.Loans")
        Dim created As Counter(Of Integer) = meter.CreateCounter(Of Integer)("shelf.loans.created", "loans")
        Console.WriteLine("pid " & Environment.ProcessId.ToString())
        For i As Integer = 1 To 120
            created.Add(1)
            Thread.Sleep(100)
        Next
    End Sub
End Module

dotnet-counters collect read the ShelfDocs.Loans meter and two System.Runtime counters for five seconds and wrote a CSV file (the first rows):

dotnet-counters collect -p $PID --counters ShelfDocs.Loans,System.Runtime[cpu-time,alloc-rate] --format csv -o /tmp/counters --refresh-interval 1 --duration 00:00:05
Timestamp,Provider,Counter Name,Counter Type,Mean/Increment
10/19/2026 22:29:07,ShelfDocs.Loans,shelf.loans.created (loans / 1 sec),Rate,9
10/19/2026 22:29:07,System.Runtime,Allocation Rate (B / 1 sec),Rate,343016
10/19/2026 22:29:08,System.Runtime,Allocation Rate (B / 1 sec),Rate,40488
10/19/2026 22:29:08,ShelfDocs.Loans,shelf.loans.created (loans / 1 sec),Rate,10
10/19/2026 22:29:09,ShelfDocs.Loans,shelf.loans.created (loans / 1 sec),Rate,10

The rate is ten per second, as the loop’s Thread.Sleep(100) implies. dotnet-counters monitor shows the same numbers live in a terminal table. dotnet-stack report -p $PID prints the managed stack of every thread, which is the fastest way to see where a hung process is waiting; the frames carry the Visual Basic names of the module and the method:

Thread (0xC5):
  [Native Frames]
  System.Private.CoreLib!System.Threading.Thread.Sleep(int32)
  Ctr!ShelfDocs.Program.Main()

dotnet-trace collect -p $PID --providers Microsoft-Windows-DotNETRuntime:0x1:4 --duration 00:00:03 -o /tmp/t.nettrace wrote a 293 KB .nettrace file, which Visual Studio, PerfView or dotnet-trace convert (to Speedscope) can open. dotnet-dump collect -p $PID wrote a full dump, and dotnet-dump analyze ran SOS commands against it; clrthreads listed the four threads of the process (ThreadCount: 4, one of them the finalizer thread). A dump is a snapshot of a whole process, with the memory of every object, so treat it as sensitive data: it can contain secrets.

Measuring: Stopwatch, profilers and BenchmarkDotNet

A diagnosis often ends with "this is slow", and the next question is what is slow. Do not guess: measure.

  • Visual Studio’s profiling tools (CPU Usage, .NET Object Allocation, Instrumentation, Database and the Performance Profiler launched with Alt+F2) work on any .NET assembly with symbols, including one written in Visual Basic. Profile a Release build; a Debug build measures the wrong code.

  • dotnet-trace (above) collects the same CPU sampling evidence without Visual Studio.

  • Stopwatch is fine for a quick "about how long" in a console program, but one run proves nothing: the first call includes JIT time, and the measurement includes noise.

  • BenchmarkDotNet is the library that .NET itself uses: it runs each method many times, discards warm-up, detects the platform, measures allocations and reports the statistics. It generates and builds its own host project for each benchmark, and that works for a Visual Basic benchmark project (BenchmarkDotNet 0.15.8, as run here). The attributes are ordinary Visual Basic attributes:

Option Strict On

Imports System.Text
Imports BenchmarkDotNet.Attributes
Imports BenchmarkDotNet.Running

<MemoryDiagnoser>
<ShortRunJob>
Public Class JoinBenchmarks
    Private ReadOnly _titles As String() = Enumerable.Range(1, 100).Select(Function(i) "Title " & i.ToString()).ToArray()

    <Benchmark(Baseline:=True)>
    Public Function Concatenate() As String
        Dim result As String = ""
        For Each t As String In _titles
            result = result & t & ";"
        Next
        Return result
    End Function

    <Benchmark>
    Public Function StringBuilderAppend() As String
        Dim sb As New StringBuilder()
        For Each t As String In _titles
            sb.Append(t).Append(";"c)
        Next
        Return sb.ToString()
    End Function

    <Benchmark>
    Public Function StringJoin() As String
        Return String.Join(";", _titles) & ";"
    End Function
End Class

Module Program
    Sub Main(args As String())
        BenchmarkRunner.Run(Of JoinBenchmarks)()
    End Sub
End Module

It is run as a Release build (dotnet run -c Release; BenchmarkDotNet warns, and gives unreliable numbers, for a Debug build). The summary block of the real run (the machine is an arm64 Linux container; only the ratios are meaningful, the absolute times are not):

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.5 LTS (Noble Numbat) (container)
.NET SDK 10.0.402
  [Host]   : .NET 10.0.13 (10.0.13, 10.0.1326.50504), Arm64 RyuJIT armv8.0-a
  ShortRun : .NET 10.0.13 (10.0.13, 10.0.1326.50504), Arm64 RyuJIT armv8.0-a

Job=ShortRun  IterationCount=3  LaunchCount=1
WarmupCount=3

| Method              | Mean       | Error     | StdDev   | Ratio | RatioSD | Gen0    | Allocated | Alloc Ratio |
|-------------------- |-----------:|----------:|---------:|------:|--------:|--------:|----------:|------------:|
| Concatenate         | 3,749.6 ns | 906.20 ns | 49.67 ns |  1.00 |    0.02 | 21.9154 |  89.52 KB |        1.00 |
| StringBuilderAppend |   388.5 ns |  66.67 ns |  3.65 ns |  0.10 |    0.00 |  1.0424 |   4.26 KB |        0.05 |
| StringJoin          |   333.7 ns |  47.94 ns |  2.63 ns |  0.09 |    0.00 |  0.8640 |   3.53 KB |        0.04 |

The loop that appends with & allocates 89.52 KB per call against 3.5 to 4.3 KB for the two alternatives, and is about ten times slower for 100 titles: that is the numerical meaning of the rule "build strings in a loop with StringBuilder or String.Join`". The `ShortRun job is for demonstration; use the default job for a decision. Benchmarks belong in their own project, not in the test project, and not in the application. Do not benchmark a Visual Basic program with strict typing switched off: the late-bound calls it permits cost more than any change you will be comparing (see Early and Late Binding).

Debugging in VS Code

Visual Studio Code is not a Visual Basic debugging environment, and the Microsoft documentation for .NET in VS Code covers C# and F# only. VS Code recognizes .vb files with its built-in syntax grammar, which gives colouring but no language server: no IntelliSense, no rename, no diagnostics inside the editor (the C# Dev Kit extension does not add Visual Basic). The dotnet command line works in the integrated terminal, so building, running, testing, dotnet watch and the tools on this page work. For the debugger, the C# extension’s coreclr debug adapter attaches to the runtime, and the runtime does not care what language produced the assembly; a launch configuration for a built Visual Basic program has the usual shape:

{
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Launch VB program",
      "type": "coreclr",
      "request": "launch",
      "preLaunchTask": "build",
      "program": "${workspaceFolder}/bin/Debug/net10.0/Core.dll",
      "args": [ "first" ],
      "cwd": "${workspaceFolder}",
      "console": "internalConsole",
      "stopAtEntry": false
    }
  ]
}

The limits are in the editor, not in the runtime. VS Code decides whether a breakpoint can be placed in a file from the languages for which a debugger has registered itself, and .vb is not among those of the C# extension; there is a general VS Code setting for allowing breakpoints in any file (debug.allowBreakpointsEverywhere), and an editor that will not accept the breakpoint is the first symptom to look for. Neither the breakpoint placement nor the variable views were tested for this page, and Microsoft does not document the combination, so treat the configuration above as a starting point to verify on your own project. If it does not work, use what does: Debugger.Launch/Debugger.Break in a Debug build, the Visual Studio debugger on Windows, or the evidence tools of the earlier sections. A Visual Basic developer who needs a real editor on Linux or macOS is better served by the command line and logging than by fighting the editor.

Choosing the tool

Symptom First tool Why

A value is wrong at a known line

Breakpoint, Locals, Immediate window; a unit test that reproduces it

You need to see the state at that moment.

An exception is thrown somewhere and swallowed

Exception Settings (break when thrown), FirstChanceException, a When filter that logs

The handler hides the throw site.

The program works in Debug and fails in Release

Release build with the PDB, Trace or ILogger output, then the debugger on the Release build

Optimization changes timing, inlining and the visible locals.

Slow under load

dotnet-counters, dotnet-trace, then BenchmarkDotNet on the suspect method

Find the hot path first, then measure the fix.

A process is hung or consumes memory

dotnet-stack report, dotnet-dump collect and analyze

No code change, no restart.

It fails only on a customer machine

Structured logs with levels, a crash dump setting, the PDB kept with the release

You cannot attach a debugger there.

You want edits without restarting

Visual Studio Hot Reload (with the debugger); for dotnet watch, expect a restart in Visual Basic

Command-line Hot Reload did not apply Visual Basic edits (ENC1009).

The consolidated rules, the wrong and the right way of writing each, and the Learn page that states each rule are on Best Practices and Common Pitfalls. Guidance written for .NET Framework is outdated in four places: the assertion dialog (gone, see above), Debug and Trace as the only logging (replaced by ILogger, Meter and the diagnostic tools above), Edit and Continue as the only way to change code in a running program (Hot Reload exists for Visual Basic only in the Visual Studio debugger), and diagnostics examples that need Windows (the tools here run on Linux and macOS as well).