Edit

Debugger visualizers

Debugger visualizers are a Visual Studio feature that provides a custom visualization for variables or objects of a specific .NET type during a debug session. Debugger visualizers are accessible from the DataTip that appears when hovering over a variable, or from the Autos, Locals, and Watch windows.

Note

This article uses the VisualStudio.Extensibility SDK. For help choosing between VisualStudio.Extensibility and the legacy VSSDK, see Choose the right extensibility model.

Screenshot showing debugger visualizers in the Watch window.

Full example

The complete implementation is in the sample:

// Copyright (c) Microsoft. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

namespace RegexMatchVisualizer;

using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.VisualStudio.Extensibility;
using Microsoft.VisualStudio.Extensibility.DebuggerVisualizers;
using Microsoft.VisualStudio.RpcContracts.RemoteUI;
using RegexMatchVisualizer.ObjectSource;

/// <summary>
/// Debugger visualizer provider class for <see cref="Match"/>.
/// </summary>
[VisualStudioContribution]
internal class RegexMatchDebuggerVisualizerProvider : DebuggerVisualizerProvider
{
    /// <inheritdoc/>
    public override DebuggerVisualizerProviderConfiguration DebuggerVisualizerProviderConfiguration => new("%RegexMatchVisualizer.RegexMatchDebuggerVisualizerProvider.DisplayName%", typeof(Match))
    {
        VisualizerObjectSourceType = new("RegexMatchVisualizer.ObjectSource.RegexMatchObjectSource, RegexMatchObjectSource"),
    };

    /// <inheritdoc/>
    public override async Task<IRemoteUserControl> CreateVisualizerAsync(VisualizerTarget visualizerTarget, CancellationToken cancellationToken)
    {
        var regexMatch = await visualizerTarget.ObjectSource.RequestDataAsync<RegexMatch>(jsonSerializer: null, cancellationToken);

        return new RegexMatchVisualizerUserControl(regexMatch);
    }
}

Tip

See the full working sample: RegexMatchDebugVisualizer ↗

Related concepts: Remote UI, Contributions and configurations, and Create your first extension

Understand the example

Get started

To get started, create a VisualStudio.Extensibility extension project by following Create your first extension.

Every extension starts with an Extension entry point that defines ExtensionConfiguration metadata:

[VisualStudioContribution]
internal class MyExtension : Extension
{
    public override ExtensionConfiguration ExtensionConfiguration => new()
    {
        Metadata = new(
            id: "MyExtension.01234567",
            version: this.ExtensionAssemblyVersion,
            publisherName: "Contoso",
            displayName: "My debugger visualizer extension"),
    };
}

Create the debugger visualizer provider

Add a class that extends DebuggerVisualizerProvider ↗ and apply the VisualStudioContribution ↗ attribute to it:

[VisualStudioContribution]
internal class StringDebuggerVisualizerProvider : DebuggerVisualizerProvider
{
    public override DebuggerVisualizerProviderConfiguration DebuggerVisualizerProviderConfiguration => new(
        "My string visualizer",
        typeof(string));

    public override async Task<IRemoteUserControl> CreateVisualizerAsync(
        VisualizerTarget visualizerTarget,
        CancellationToken cancellationToken)
    {
        string targetObjectValue = await visualizerTarget.ObjectSource.RequestDataAsync<string>(
            jsonSerializer: null,
            cancellationToken);

        return new MyStringVisualizerControl(targetObjectValue);
    }
}

This code defines a new debugger visualizer for string values:

  • DebuggerVisualizerProviderConfiguration defines the visualizer display name and supported .NET type.
  • CreateVisualizerAsync is invoked when the user opens the visualizer. It uses VisualizerTarget ↗ to retrieve data and returns a Remote UI control that Visual Studio shows in a popup window.

The same configuration object is where you declare details such as the target types, visualizer display names, VisualizerObjectSourceType, and whether the visualizer opens as a modal dialog or as a non-modal tool window.

Target multiple types

The configuration property can target multiple types when it makes sense to share the same UI, view models, and visualizer object source. A common example is a tabular visualizer that supports DataSet, DataTable, DataView, and DataViewManager.

public override DebuggerVisualizerProviderConfiguration DebuggerVisualizerProviderConfiguration => new(
    new VisualizerTargetType("DataSet Visualizer", typeof(System.Data.DataSet)),
    new VisualizerTargetType("DataTable Visualizer", typeof(System.Data.DataTable)),
    new VisualizerTargetType("DataView Visualizer", typeof(System.Data.DataView)),
    new VisualizerTargetType("DataViewManager Visualizer", typeof(System.Data.DataViewManager)));

Work with the visualizer object source

The visualizer object source is a .NET class that the debugger loads into the process being debugged. Your debugger visualizer retrieves data from it through VisualizerTarget.ObjectSource ↗.

The default visualizer object source supports:

The default implementation uses Newtonsoft.Json ↗ for serialization. If the target type is already serializable, or if you can make your own type serializable, this default flow is often enough.

Use a custom visualizer object source

If the type to be visualized can't be automatically serialized by Newtonsoft.Json, create a custom visualizer object source.

  1. Create a new class library targeting netstandard2.0. If required, you can target a more specific framework such as net472 or net6.0.
  2. Add a package reference to Microsoft.VisualStudio.DebuggerVisualizers ↗ version 17.6 or later.
  3. Create a class that extends VisualizerObjectSource ↗ and override GetData.
public class MyObjectSource : VisualizerObjectSource
{
    public override void GetData(object target, Stream outgoingData)
    {
        MySerializableType result = Convert(target);
        SerializeAsJson(outgoingData, result);
    }

    private static MySerializableType Convert(object target)
    {
        // Convert the debuggee object into a serializable type.
        throw new NotImplementedException();
    }
}

If you're following the Regex sample, RegexMatchObjectSource converts Match into a serializable RegexMatch shape before the provider requests it.

Use custom serialization

Use VisualizerObjectSource.SerializeAsJson to serialize objects with Newtonsoft.Json without adding a direct Newtonsoft.Json dependency to the object-source library. The method loads the Newtonsoft.Json assembly in the debuggee process via reflection.

If you decide to reference Newtonsoft.Json directly, use the same version referenced by Microsoft.VisualStudio.Extensibility.Sdk. In most cases, it's better to use DataContract and DataMember attributes for shared types instead of relying on Newtonsoft.Json-specific types.

You can also implement your own serialization format and write directly to outgoingData.

Add the visualizer object source DLL to the extension

Add a ProjectReference so the object-source project builds before the extension is packaged, and add a Content item so the DLL is copied into the extension output.

<ItemGroup>
  <Content Include="pathToTheObjectSourceDllBinPath\$(Configuration)\netstandard2.0\MyObjectSourceLibrary.dll"
           Link="netstandard2.0\MyObjectSourceLibrary.dll">
    <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
  </Content>
</ItemGroup>

<ItemGroup>
  <ProjectReference Include="..\MyObjectSourceLibrary\MyObjectSourceLibrary.csproj" />
</ItemGroup>

You can also use net462 or netcoreapp subfolders if the object-source library targets those frameworks, but netstandard2.0 is preferred when possible.

Keep the object-source library's dependency graph as small as possible. If it depends on other DLLs that the debuggee doesn't already load, include those DLLs in the same extension subfolder.

Configure the provider to use the custom object source

After the object-source DLL is packaged, update DebuggerVisualizerProviderConfiguration to point to it:

public override DebuggerVisualizerProviderConfiguration DebuggerVisualizerProviderConfiguration => new(
    "My visualizer",
    typeof(TypeToVisualize))
{
    VisualizerObjectSourceType = new(typeof(MyObjectSource)),
};
public override async Task<IRemoteUserControl> CreateVisualizerAsync(
    VisualizerTarget visualizerTarget,
    CancellationToken cancellationToken)
{
    MySerializableType result = await visualizerTarget.ObjectSource.RequestDataAsync<MySerializableType>(
        jsonSerializer: null,
        cancellationToken);

    return new MyVisualizerUserControl(result);
}

If you intentionally avoid an assembly reference from the extension to the object-source project, configure VisualizerObjectSourceType with the assembly-qualified name instead of typeof(...). The Regex sample uses that pattern.

Work with large and complex objects

If retrieving all data in one parameterless RequestDataAsync call isn't practical, use RequestDataAsync<TMessage, TResponse>(TMessage, JsonSerializer?, CancellationToken) ↗ to exchange messages with the object source.

This pattern is useful for large collections because each RequestDataAsync call has a five-second timeout. A common approach is to request entries one at a time:

for (int i = 0; ; i++)
{
    MySerializableType? collectionEntry =
        await visualizerTarget.ObjectSource.RequestDataAsync<int, MySerializableType?>(
            i,
            jsonSerializer: null,
            cancellationToken);

    if (collectionEntry is null)
    {
        break;
    }

    observableCollection.Add(collectionEntry);
}

On the object-source side, override TransferData instead of GetData:

public class MyCollectionTypeObjectSource : VisualizerObjectSource
{
    public override void TransferData(object target, Stream incomingData, Stream outgoingData)
    {
        var index = (int)DeserializeFromJson(incomingData, typeof(int))!;

        if (target is MyCollectionType collection && index < collection.Count)
        {
            var result = Convert(collection[index]);
            SerializeAsJson(outgoingData, result);
        }
        else
        {
            SerializeAsJson(outgoingData, null);
        }
    }
}

DeserializeFromJson reads the message that the provider sent through incomingData.

When a visualizer performs more complex message exchanges, it's often better to pass the VisualizerTarget ↗ to the remote user control so it can request data asynchronously while the control is loading, or later in response to user interaction:

public override Task<IRemoteUserControl> CreateVisualizerAsync(
    VisualizerTarget visualizerTarget,
    CancellationToken cancellationToken)
{
    return Task.FromResult<IRemoteUserControl>(new MyVisualizerUserControl(visualizerTarget));
}

Open visualizers as tool windows

By default, debugger visualizers open as modal dialogs in the foreground of Visual Studio. If the user wants to continue interacting with the IDE, they must close the modal visualizer first.

If you set Style = VisualizerStyle.ToolWindow, the visualizer opens as a non-modal tool window and can remain open for the rest of the debug session. If you don't set Style, the default is ModalDialog.

public override DebuggerVisualizerProviderConfiguration DebuggerVisualizerProviderConfiguration => new(
    "My visualizer",
    typeof(TypeToVisualize))
{
    Style = VisualizerStyle.ToolWindow
};

When a visualizer opens as a tool window, it must subscribe to the VisualizerTarget.Changed event. Tool windows don't block the user from resuming execution, so the debugger raises notifications whenever the debug target changes state. When the target isn't available, calls to ObjectSource fail with VisualizerTargetUnavailableException.

internal class MyVisualizerUserControl : RemoteUserControl
{
    private readonly VisualizerDataContext dataContext;

#pragma warning disable CA2000 // Dispose objects before losing scope
    public MyVisualizerUserControl(VisualizerTarget visualizerTarget)
        : base(dataContext: new VisualizerDataContext(visualizerTarget))
#pragma warning restore CA2000 // Dispose objects before losing scope
    {
        this.dataContext = (VisualizerDataContext)this.DataContext!;
    }

    protected override void Dispose(bool disposing)
    {
        if (disposing)
        {
            this.dataContext.Dispose();
        }
    }

    [DataContract]
    private class VisualizerDataContext : NotifyPropertyChangedObject, IDisposable
    {
        private readonly VisualizerTarget visualizerTarget;
        private MySerializableType? _value;

        public VisualizerDataContext(VisualizerTarget visualizerTarget)
        {
            this.visualizerTarget = visualizerTarget;
            this.visualizerTarget.Changed += this.OnStateChangedAsync;
        }

        [DataMember]
        public MySerializableType? Value
        {
            get => this._value;
            set => this.SetProperty(ref this._value, value);
        }

        public void Dispose()
        {
            this.visualizerTarget.Dispose();
        }

        private async Task OnStateChangedAsync(object? sender, VisualizerTargetStateNotification args)
        {
            switch (args)
            {
                case VisualizerTargetStateNotification.Available:
                case VisualizerTargetStateNotification.ValueUpdated:
                    Value = await this.visualizerTarget.ObjectSource.RequestDataAsync<MySerializableType>(
                        jsonSerializer: null,
                        CancellationToken.None);
                    break;
                case VisualizerTargetStateNotification.Unavailable:
                    Value = null;
                    break;
                default:
                    throw new NotSupportedException("Unexpected visualizer target state notification");
            }
        }
    }
}

Available is raised after the RemoteUserControl is created and just before Visual Studio shows the tool window. ValueUpdated indicates that the expression was successfully re-evaluated at the current break state and the UI should refresh. Unavailable indicates that execution resumed or that the expression couldn't be re-evaluated after execution stopped.

Update the visualized object value

If VisualizerTarget.IsTargetReplaceable ↗ is true, the visualizer can update the debuggee value by calling ReplaceTargetObjectAsync ↗.

await visualizerTarget.ObjectSource.ReplaceTargetObjectAsync(
    newValue,
    jsonSerializer: null,
    cancellationToken);

The visualizer object source must override CreateReplacementObject:

public override object CreateReplacementObject(object target, Stream incomingData)
{
    // Read the replacement value from incomingData.
    return newValue;
}

Tips

✅ DO

  • Put extension metadata in ExtensionConfiguration and contribution settings in DebuggerVisualizerProviderConfiguration.
  • Use netstandard2.0 for the object-source project unless you have a specific reason to target another framework.
  • Use DataContract and DataMember on shared data types.
  • Package the object-source DLL and any required dependencies with the extension.
  • Use paged RequestDataAsync calls for large or expensive objects.
  • Handle VisualizerTarget.Changed when opening as a tool window.

❌ DON'T

  • Don't reference VisualStudio.Extensibility packages from the object-source project.
  • Don't assume every type can be serialized by the default object source.
  • Don't leave a tool-window visualizer unaware of Unavailable notifications.
  • Don't forget that RequestDataAsync calls can time out after five seconds.

🔧 Troubleshooting

Symptom What to check
Visualizer doesn't appear Verify the provider class has [VisualStudioContribution] and that DebuggerVisualizerProviderConfiguration targets the correct .NET type.
RequestDataAsync fails or times out Move large data retrieval to TransferData and page the data across multiple calls.
Custom object source isn't found Confirm the DLL is copied into the extension's netstandard2.0 folder and VisualizerObjectSourceType points to the correct type or assembly-qualified name.
Tool-window visualizer throws when debugging continues Handle VisualizerTargetStateNotification.Unavailable and avoid ObjectSource calls until the target is available again.

Next steps