How to Debug a Dynamics 365 Plugin: Trace Logs, the Profiler and the Usual Suspects

A plugin that throws a clear error is easy. The ones that cost you an afternoon are the quiet ones: the step that never fires, the update that silently does nothing, the async job that fails at two in the morning and leaves no trace anyone thinks to look for.

This is the order I work through when a Dynamics 365 plugin misbehaves, from the cheapest check to the most involved. It follows on from the plugin development guide and from the older post on enabling tracing on-premises, and it assumes you are working against a sandboxed plugin in Dynamics 365 online, where you cannot attach a debugger to the server.

Step 1: establish whether the plugin is running at all

Before you write a single line of diagnostic code, answer one question: is your code even being invoked? A surprising share of “my plugin does not work” turns out to be “my plugin never ran.” Open the step in the Plugin Registration Tool and check five things.

  • Message. Create and Update are not the only ones that matter. Setting a record inactive fires SetState or SetStateDynamicEntity on older versions, and an Update step will not catch it. Assigning fires Assign. Merging fires Merge. If the user action you are reacting to is not a plain field edit, confirm which message it actually raises.
  • Primary entity. Registered against the right table, and spelled with the logical name rather than the display name.
  • Stage. Pre-validation (10), pre-operation (20) and post-operation (40) behave differently. If you are modifying the Target so the change is saved with the record, you must be in a pre stage — changing Target in post-operation does nothing at all, and this is one of the most common silent failures there is.
  • Filtering attributes. On an Update step, if you have selected specific attributes, the plugin only fires when one of those attributes is part of the update. Leaving this empty means the plugin fires on every update, which is usually worse. Getting it wrong in either direction produces “it works sometimes.”
  • Execution mode and user context. Synchronous or asynchronous, and running as the calling user or as a specific service account. A plugin that works for you and not for a colleague is almost always a security-role problem in disguise.

The quickest way to prove the step runs is to throw deliberately. Put this at the top of Execute, register, and reproduce:

throw new InvalidPluginExecutionException("PLUGIN REACHED - remove me");

If you see that message on screen, the registration is fine and the problem is in your logic. If you do not, the problem is the registration and no amount of tracing will help.

Step 2: turn on the plugin trace log and actually write to it

The trace log is the single most useful diagnostic tool in Dataverse, and it is switched off by default.

Enable it in Advanced Settings › Administration › System Settings › Customization, under Plug-in and custom workflow activity tracing. The three options are Off, Exception and All. Use All while you are actively debugging and put it back to Exception afterwards — All writes a record for every execution of every plugin in the environment, which gets expensive on a busy system.

Traces are then visible under Advanced Settings › Plug-In Trace Log. Two things to know before you rely on it: entries are removed automatically after about 24 hours, so a failure from last week is already gone; and with the setting on Exception, a plugin that fails silently without throwing will write nothing.

Tracing only helps if your plugin says something worth reading. This is the shape I use:

public class AccountPreCreate : IPlugin
{
    public void Execute(IServiceProvider serviceProvider)
    {
        var tracing = (ITracingService)serviceProvider.GetService(typeof(ITracingService));
        var context = (IPluginExecutionContext)serviceProvider.GetService(typeof(IPluginExecutionContext));
        var factory = (IOrganizationServiceFactory)serviceProvider.GetService(typeof(IOrganizationServiceFactory));
        var service = factory.CreateOrganizationService(context.UserId);

        tracing.Trace("Entered Execute. Message={0} Stage={1} Depth={2} Mode={3}",
            context.MessageName, context.Stage, context.Depth, context.Mode);

        try
        {
            if (!context.InputParameters.Contains("Target") ||
                !(context.InputParameters["Target"] is Entity))
            {
                tracing.Trace("No Target entity in InputParameters - exiting.");
                return;
            }

            var target = (Entity)context.InputParameters["Target"];
            tracing.Trace("Target={0} attributeCount={1}", target.LogicalName, target.Attributes.Count);

            foreach (var a in target.Attributes)
            {
                tracing.Trace("  {0} = {1}", a.Key, a.Value ?? "(null)");
            }

            // ... your logic ...

            tracing.Trace("Completed successfully.");
        }
        catch (FaultException<OrganizationServiceFault> ex)
        {
            tracing.Trace("OrganizationServiceFault: {0}", ex.ToString());
            throw new InvalidPluginExecutionException("Could not save the account. Please contact support.", ex);
        }
        catch (Exception ex)
        {
            tracing.Trace("Unexpected exception: {0}", ex.ToString());
            throw;
        }
    }
}

Four habits make traces worth having.

  • Log the context first. Message, stage, depth and mode in the very first line. Half the time the answer is right there — you are in the wrong stage, or the depth is 3 because you are in a loop you did not know about.
  • Log what you received, not what you expect. Dumping the attributes on the Target takes four lines and answers “why is that field null” immediately: on an Update, the Target contains only the attributes that changed. Everything else has to come from an image or a retrieve.
  • Trace ex.ToString(), not ex.Message. The message alone loses the stack trace and the inner exception, which is usually where the real cause is.
  • Throw InvalidPluginExecutionException for anything the user should see, with a sentence they can act on. Everything else should be rethrown so the platform rolls the transaction back properly.

One detail that catches people: whatever you write with ITracingService is also appended to the error dialog when the plugin throws. That is helpful while debugging and unhelpful when a user sees a wall of internal detail, so keep the user-facing message in the InvalidPluginExecutionException and the detail in the trace.

Step 3: where the error actually went

This depends entirely on execution mode, and it is worth being precise about it.

Synchronous plugins run inside the user's transaction. If they throw, the user sees a business process error dialog immediately, the whole operation is rolled back, and nothing is saved. Whatever you traced appears in the dialog under Download Log File.

Asynchronous plugins do not surface to the user at all. If one fails, the record still saves and the failure lands in Advanced Settings › System Jobs with a status of Failed and the exception in the message column. This is where quiet production failures hide — nobody looks at System Jobs until someone notices data is missing. If you have async plugins in production, put a view or an alert on failed system jobs.

Custom API and custom action failures come back to whatever called them, which for a JavaScript caller means the error object of your Web API request. If you are calling actions from a form, the guidance in the Web API reference on handling both 200 and 204 applies here too.

Step 4: the profiler, when tracing is not enough

When you need to step through the code line by line, the Plugin Registration Tool can capture a live execution and replay it locally against your Visual Studio debugger. The plugin itself still runs in the cloud sandbox; what you get is a faithful replay of that exact invocation.

  1. In the Plugin Registration Tool, click Install Profiler.
  2. Select your step and click Start Profiling. Choose to persist to an entity so the profile survives, rather than only showing an exception dialog.
  3. Reproduce the problem in the application.
  4. Click Stop Profiling, then Debug, and load the saved profile.
  5. Point it at your plugin assembly and the class name, attach Visual Studio to the Plugin Registration Tool process, set your breakpoints and replay.
  6. When you are finished, Uninstall Profiler. Leaving it installed in a shared environment causes confusing behaviour for everyone else.

Two limitations worth knowing before you invest the time. The replay re-executes your code against the captured context, so anything that reads live data will see whatever is in the environment now, not what was there when the profile was captured. And it profiles one step at a time, so a chain of plugins triggering each other has to be unpicked step by step.

The eight things that are usually wrong

  1. Modifying Target in post-operation. It has no effect. Move to pre-operation, or issue an explicit Update and accept the extra database write and the recursion it can cause.
  2. Expecting unchanged fields to be on the Target. On an Update the Target holds only what changed. Register a pre-image with the attributes you need and read from that.
  3. Forgetting to register the image you are reading. context.PreEntityImages["PreImage"] throws if no image with that alias was registered on the step. Check Contains first, and make sure the alias in code matches the alias in the registration exactly, including case.
  4. Infinite recursion. A plugin on Update that updates the same record re-triggers itself. The platform stops it at depth 8 with “This workflow job was cancelled because the workflow that started it included an infinite loop.” Guard with if (context.Depth > 1) return; and be deliberate about when you want the deeper call to proceed.
  5. The two-minute timeout. Sandboxed plugins are killed after two minutes. If you are looping over hundreds of related records, move the work to an asynchronous plugin or a flow.
  6. Security context. Using factory.CreateOrganizationService(context.UserId) means the plugin can only do what that user can do. Passing null runs as the system user and can do more — which is sometimes what you want and sometimes a security hole. Choose deliberately rather than by accident.
  7. The old assembly is still registered. You rebuilt, but you updated the assembly rather than the step, or the version number changed and a second registration is now live. Check the assembly's build time in the Plugin Registration Tool against your bin folder.
  8. The plugin is in a managed solution in production. You are fixing the unmanaged version in development and wondering why production still misbehaves. Confirm which solution layer is actually active.

What you cannot do online

It saves time to know the boundaries. Sandboxed plugins have no file system access, cannot write to the event log, cannot call out to localhost, and cannot use any assembly that is not merged into your plugin assembly with ILMerge. If a plugin works on a developer machine and fails in the sandbox, one of those is usually the reason.

You also cannot attach a remote debugger to the Dataverse sandbox. The profiler replay described above is the supported substitute, and for most problems the trace log gets you there faster anyway.

A short checklist

  1. Prove the step fires, with a deliberate throw.
  2. Turn plugin tracing to All and add context, input and exception traces.
  3. Reproduce, then read the trace log within 24 hours.
  4. Check System Jobs if the step is asynchronous.
  5. Work through the eight suspects above.
  6. Only then reach for the profiler.
  7. Put tracing back to Exception and uninstall the profiler when you are done.

Most plugin problems are not subtle once you can see what the plugin saw. Everything above is really just a way of getting to that point faster.

If there is a failure mode you keep hitting that is not on this list, tell me through the contact page and I will add it.

Comments

Popular posts from this blog

Mastering Dynamics 365 Plugin Development: A Complete Step-by-Step Guide

Dynamically Populating Ribbon Flyout Menu in Unified Interface

Dynamically Populating Ribbon Flyout Menu