Skip to content

Commit dac180d

Browse files
mkholtClaude <noreply@anthropic.com> via Conducktor
andauthored
Plugin Trace Log implementation (#341)
* Evaluate ITracingService messages via String.Format and expose collected trace log Trace messages with format args are now evaluated through String.Format so invalid format strings surface during testing instead of being silently swallowed. Arg-less messages remain verbatim, matching platform behavior. Trace messages are also collected by the default tracing service factory and exposed via XrmMockupBase.TraceLog (with ClearTraceLog) so tests can assert on emitted trace output. Co-Authored-By: Claude <noreply@anthropic.com> via Conducktor <conducktor@contextand.com> * Group trace messages per plugin execution via PluginTraceLog Trace messages are now grouped by the plugin execution that produced them, mirroring the plugintracelog records of a real Dynamics 365 environment. Each XrmMockupBase.PluginTraceLog entry captures the invoking type, message name, primary entity, operation type, depth, correlation id, mode, request id, execution start time, duration, the ordered list of trace messages and any exception details. PluginTrigger now times each sync/async plugin execution, captures thrown exceptions and records an entry with the execution's own trace messages. Internal XrmMockup system plugins are excluded. Configuration/secure configuration and plugin step ids are not modeled by XrmMockup and remain null. Co-Authored-By: Claude <noreply@anthropic.com> via Conducktor <conducktor@contextand.com> * Extend grouped trace log to custom APIs and workflow activities The per-execution trace grouping introduced for plugins now also covers custom API executions and workflow activity executions (sync and async). A shared PluginTraceRecorder helper centralizes the timing, exception capture and trace message collection used by all three paths; CustomApiManager and WorkflowManager now route their executions through it. Custom API entries record the request name as the message and Plugin operation type; workflow entries record the workflow name as the type, WorkflowActivity operation type and the workflow's sync/async mode. Directly-invoked workflows (ExecuteWorkflowRequest, which has no plugin context) are executed but not logged. Co-Authored-By: Claude <noreply@anthropic.com> via Conducktor <conducktor@contextand.com> * Address Copilot PR feedback on trace-log capture - PluginTraceRecorder: fall back to the TargetInvocationException itself when it has no inner exception, so ExceptionDispatchInfo.Capture is never handed null (which would mask the original failure). - TracingService: expose Messages as a read-only wrapper so callers cannot cast it back to List<string> and mutate recorded trace output. - XrmMockupBase: return Array.Empty<string>() rather than allocating a new list on every TraceLog access when a custom tracing factory is configured. Co-Authored-By: Claude <noreply@anthropic.com> via Conducktor <conducktor@contextand.com> --------- Co-authored-by: Claude <noreply@anthropic.com> via Conducktor <conducktor@contextand.com>
1 parent 6c1bfb7 commit dac180d

16 files changed

Lines changed: 596 additions & 30 deletions

src/XrmMockup365/Core.cs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -837,6 +837,36 @@ public MockupServiceProviderAndFactory CreateServiceProviderAndFactory(PluginCon
837837
return new MockupServiceProviderAndFactory(this, pluginContext, TracingServiceFactory);
838838
}
839839

840+
private readonly List<PluginTraceLog> _pluginTraceLogs = new List<PluginTraceLog>();
841+
private readonly object _pluginTraceLogLock = new object();
842+
843+
public void RecordPluginTrace(PluginTraceLog log)
844+
{
845+
lock (_pluginTraceLogLock)
846+
{
847+
_pluginTraceLogs.Add(log);
848+
}
849+
}
850+
851+
internal IReadOnlyList<PluginTraceLog> PluginTraceLogs
852+
{
853+
get
854+
{
855+
lock (_pluginTraceLogLock)
856+
{
857+
return _pluginTraceLogs.ToList();
858+
}
859+
}
860+
}
861+
862+
internal void ClearPluginTraceLogs()
863+
{
864+
lock (_pluginTraceLogLock)
865+
{
866+
_pluginTraceLogs.Clear();
867+
}
868+
}
869+
840870
// ──────────────────────────────────────────────────────────────────────────
841871

842872
internal void AddTime(TimeSpan offset)
@@ -1116,6 +1146,7 @@ internal void ResetEnvironment()
11161146
this.TimeOffset = new TimeSpan();
11171147
workflowManager.ResetWorkflows(settings.IncludeAllWorkflows);
11181148

1149+
ClearPluginTraceLogs();
11191150
pluginManager.ResetPlugins();
11201151
this.db = new XrmDb(metadata.EntityMetadata, OnlineDataService);
11211152
this.RequestHandlers = GetRequestHandlers(db);

src/XrmMockup365/Internal/ICoreOperations.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@ internal interface ICoreOperations
5555
ITracingServiceFactory TracingServiceFactory { get; }
5656
MockupServiceProviderAndFactory CreateServiceProviderAndFactory(PluginContext pluginContext);
5757

58+
// Grouped trace log — PluginTrigger records one entry per plugin execution
59+
void RecordPluginTrace(PluginTraceLog log);
60+
5861
// Settings — used by WorkflowManager
5962
XrmMockupSettings GetMockupSettings();
6063
}

src/XrmMockup365/Plugin/CustomApiManager.cs

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -156,8 +156,20 @@ internal OrganizationResponse Execute(OrganizationRequest request, PluginContext
156156
thisPluginContext.Mode = 0;
157157

158158
var serviceProvider = new MockupServiceProviderAndFactory(_core, thisPluginContext, _core.TracingServiceFactory);
159-
registeredApis[request.RequestName](serviceProvider);
160-
159+
var api = registeredApis[request.RequestName];
160+
PluginTraceRecorder.Run(
161+
_core,
162+
serviceProvider,
163+
thisPluginContext,
164+
typeName: api.Target?.GetType().FullName,
165+
messageName: request.RequestName,
166+
primaryEntity: thisPluginContext.PrimaryEntityName,
167+
operationType: PluginTraceOperationType.Plugin,
168+
mode: XrmPluginCore.Enums.ExecutionMode.Synchronous,
169+
execute: () => api(serviceProvider),
170+
unwrapTargetInvocation: false,
171+
record: true);
172+
161173
pluginContext.OutputParameters.Clear();
162174
pluginContext.OutputParameters.AddRange(thisPluginContext.OutputParameters);
163175

src/XrmMockup365/Plugin/PluginManager.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -473,7 +473,9 @@ public void TriggerSystem(EventOperation operation, ExecutionStage stage,
473473
return;
474474
}
475475

476-
plugins.ForEach(p => p.ExecuteIfMatch(entity, preImage, postImage, pluginContext, _core));
476+
// System plugins are XrmMockup's own internal simulation, not user-registered steps,
477+
// so they are excluded from the grouped plugin trace log.
478+
plugins.ForEach(p => p.ExecuteIfMatch(entity, preImage, postImage, pluginContext, _core, recordTrace: false));
477479
}
478480

479481
private string GeneratePluginCacheKey(IEnumerable<Type> basePluginTypes, IEnumerable<MetaPlugin> plugins)
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
using DG.Tools.XrmMockup.Internal;
2+
using Microsoft.Xrm.Sdk;
3+
using System;
4+
using System.Collections.Generic;
5+
using System.Diagnostics;
6+
using System.Linq;
7+
using System.Reflection;
8+
using System.Runtime.ExceptionServices;
9+
using XrmPluginCore.Enums;
10+
11+
namespace DG.Tools.XrmMockup
12+
{
13+
/// <summary>
14+
/// Shared helper that runs a plugin, custom API or workflow execution while timing it and
15+
/// capturing any thrown exception, then records a grouped <see cref="PluginTraceLog"/> entry
16+
/// built from that execution's own trace messages together with the supplied metadata.
17+
/// </summary>
18+
internal static class PluginTraceRecorder
19+
{
20+
/// <param name="unwrapTargetInvocation">
21+
/// When true, a <see cref="TargetInvocationException"/> is unwrapped and its inner exception
22+
/// rethrown, preserving the plugin pipeline's historical behavior.
23+
/// </param>
24+
/// <param name="record">When false, the execution still runs but no trace entry is recorded (e.g. internal system plugins).</param>
25+
public static void Run(
26+
ICoreOperations core,
27+
MockupServiceProviderAndFactory provider,
28+
PluginContext ctx,
29+
string typeName,
30+
string messageName,
31+
string primaryEntity,
32+
PluginTraceOperationType operationType,
33+
ExecutionMode mode,
34+
Action execute,
35+
bool unwrapTargetInvocation,
36+
bool record)
37+
{
38+
var startTime = DateTime.UtcNow.Add(core.TimeOffset);
39+
var stopwatch = Stopwatch.StartNew();
40+
string exceptionDetails = null;
41+
try
42+
{
43+
execute();
44+
}
45+
catch (TargetInvocationException e) when (unwrapTargetInvocation)
46+
{
47+
// Fall back to the TargetInvocationException itself when there is no inner
48+
// exception, so Capture is never handed null (which would mask the failure).
49+
var toThrow = e.InnerException ?? (Exception)e;
50+
exceptionDetails = toThrow.ToString();
51+
ExceptionDispatchInfo.Capture(toThrow).Throw();
52+
}
53+
catch (Exception e)
54+
{
55+
exceptionDetails = e.ToString();
56+
throw;
57+
}
58+
finally
59+
{
60+
stopwatch.Stop();
61+
if (record)
62+
{
63+
var messages = (provider.GetService<ITracingService>() as TracingService)?.Messages.ToList()
64+
?? new List<string>();
65+
66+
core.RecordPluginTrace(new PluginTraceLog
67+
{
68+
TypeName = typeName,
69+
MessageName = messageName,
70+
PrimaryEntity = string.IsNullOrEmpty(primaryEntity) ? "none" : primaryEntity,
71+
OperationType = operationType,
72+
Depth = ctx.Depth,
73+
CorrelationId = ctx.CorrelationId,
74+
Mode = mode,
75+
RequestId = ctx.RequestId,
76+
ExecutionStartTime = startTime,
77+
ExecutionDurationMs = stopwatch.Elapsed.TotalMilliseconds,
78+
MessageBlock = messages,
79+
ExceptionDetails = exceptionDetails,
80+
// Configuration, SecureConfiguration and PluginStepId are not modeled by XrmMockup.
81+
});
82+
}
83+
}
84+
}
85+
}
86+
}

src/XrmMockup365/Plugin/PluginTrigger.cs

Lines changed: 29 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -75,13 +75,18 @@ public PluginExecutionProvider ToPluginExecution(object entityObject, Entity pre
7575
{
7676
// Create the plugin context
7777
var thisPluginContext = CreatePluginContext(pluginContext, guid, logicalName, preImage, postImage);
78-
return new PluginExecutionProvider(PluginExecute, core.CreateServiceProviderAndFactory(thisPluginContext));
78+
var provider = core.CreateServiceProviderAndFactory(thisPluginContext);
79+
// Preserve async exception semantics (no TargetInvocationException unwrapping), but
80+
// still time the execution and capture its trace messages into the grouped trace log.
81+
return new PluginExecutionProvider(
82+
p => ExecuteAndRecord(p, thisPluginContext, core, unwrapTargetInvocation: false, record: true),
83+
provider);
7984
}
8085

8186
return null;
8287
}
8388

84-
public void ExecuteIfMatch(object entityObject, Entity preImage, Entity postImage, PluginContext pluginContext, ICoreOperations core)
89+
public void ExecuteIfMatch(object entityObject, Entity preImage, Entity postImage, PluginContext pluginContext, ICoreOperations core, bool recordTrace = true)
8590
{
8691
// Check if it is supposed to execute. Returns preemptively, if it should not.
8792
var entity = entityObject as Entity;
@@ -107,14 +112,7 @@ public void ExecuteIfMatch(object entityObject, Entity preImage, Entity postImag
107112

108113
//Create Serviceprovider, and execute plugin
109114
MockupServiceProviderAndFactory provider = core.CreateServiceProviderAndFactory(thisPluginContext);
110-
try
111-
{
112-
PluginExecute(provider);
113-
}
114-
catch (TargetInvocationException e)
115-
{
116-
ExceptionDispatchInfo.Capture(e.InnerException).Throw();
117-
}
115+
ExecuteAndRecord(provider, thisPluginContext, core, unwrapTargetInvocation: true, record: recordTrace);
118116

119117
foreach (var parameter in thisPluginContext.SharedVariables)
120118
{
@@ -123,6 +121,27 @@ public void ExecuteIfMatch(object entityObject, Entity preImage, Entity postImag
123121
}
124122
}
125123

124+
/// <summary>
125+
/// Executes the plugin while timing it and capturing any thrown exception, then (when
126+
/// <paramref name="record"/> is true) records a grouped <see cref="PluginTraceLog"/> entry
127+
/// containing this execution's trace messages and metadata.
128+
/// </summary>
129+
private void ExecuteAndRecord(MockupServiceProviderAndFactory provider, PluginContext ctx, ICoreOperations core, bool unwrapTargetInvocation, bool record)
130+
{
131+
PluginTraceRecorder.Run(
132+
core,
133+
provider,
134+
ctx,
135+
typeName: PluginExecute.Target?.GetType().FullName,
136+
messageName: !string.IsNullOrEmpty(ctx.MessageName) ? ctx.MessageName : Operation,
137+
primaryEntity: ctx.PrimaryEntityName,
138+
operationType: PluginTraceOperationType.Plugin,
139+
mode: this.Mode,
140+
execute: () => PluginExecute(provider),
141+
unwrapTargetInvocation: unwrapTargetInvocation,
142+
record: record);
143+
}
144+
126145
private void CheckInfiniteLoop(PluginContext pluginContext)
127146
{
128147
if (pluginContext.Depth > 8)

src/XrmMockup365/PluginTraceLog.cs

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
using System;
2+
using System.Collections.Generic;
3+
using XrmPluginCore.Enums;
4+
5+
namespace DG.Tools.XrmMockup
6+
{
7+
/// <summary>
8+
/// The type of operation that produced a <see cref="PluginTraceLog"/>, mirroring the
9+
/// OperationType option set on the platform's plugintracelog entity.
10+
/// </summary>
11+
public enum PluginTraceOperationType
12+
{
13+
Unknown = 0,
14+
Plugin = 1,
15+
WorkflowActivity = 2,
16+
}
17+
18+
/// <summary>
19+
/// A single grouped trace-log entry, capturing all trace messages emitted by one
20+
/// plugin (or workflow activity) execution together with the metadata identifying it,
21+
/// mirroring the plugintracelog records produced by a real Dynamics 365 environment.
22+
/// </summary>
23+
public sealed class PluginTraceLog
24+
{
25+
/// <summary>The full name of the invoking type, e.g. the plugin class.</summary>
26+
public string TypeName { get; internal set; }
27+
28+
/// <summary>The message being processed, e.g. Create, Update or a custom API name.</summary>
29+
public string MessageName { get; internal set; }
30+
31+
/// <summary>The primary entity of the operation, or "none" if there is no bound entity.</summary>
32+
public string PrimaryEntity { get; internal set; }
33+
34+
/// <summary>
35+
/// The unsecure configuration supplied to the plugin step.
36+
/// <para>Not currently modeled by XrmMockup and therefore always null.</para>
37+
/// </summary>
38+
public string Configuration { get; internal set; }
39+
40+
/// <summary>
41+
/// The secure configuration supplied to the plugin step.
42+
/// <para>Not currently modeled by XrmMockup and therefore always null.</para>
43+
/// </summary>
44+
public string SecureConfiguration { get; internal set; }
45+
46+
/// <summary>Whether the entry originated from a plugin or a workflow activity.</summary>
47+
public PluginTraceOperationType OperationType { get; internal set; }
48+
49+
/// <summary>
50+
/// The id of the plugin step (SDK message processing step) that was executed.
51+
/// <para>Not currently modeled by XrmMockup and therefore always null.</para>
52+
/// </summary>
53+
public Guid? PluginStepId { get; internal set; }
54+
55+
/// <summary>The execution depth: 1 for a directly invoked plugin, 2 for a plugin invoked by a plugin, etc.</summary>
56+
public int Depth { get; internal set; }
57+
58+
/// <summary>The correlation id shared across the whole execution chain.</summary>
59+
public Guid CorrelationId { get; internal set; }
60+
61+
/// <summary>Whether the execution was synchronous or asynchronous.</summary>
62+
public ExecutionMode Mode { get; internal set; }
63+
64+
/// <summary>The id of the request that triggered the execution, if available.</summary>
65+
public Guid? RequestId { get; internal set; }
66+
67+
/// <summary>The time at which execution started, according to the mockup's (possibly offset) clock.</summary>
68+
public DateTime ExecutionStartTime { get; internal set; }
69+
70+
/// <summary>The wall-clock duration of the execution, in milliseconds.</summary>
71+
public double ExecutionDurationMs { get; internal set; }
72+
73+
/// <summary>
74+
/// The trace messages emitted during the execution, in order. This is the platform's
75+
/// "Message Block", kept as a list rather than a single concatenated string.
76+
/// </summary>
77+
public IReadOnlyList<string> MessageBlock { get; internal set; } = new List<string>();
78+
79+
/// <summary>The message and stack trace of the exception that ended the execution, or null if it succeeded.</summary>
80+
public string ExceptionDetails { get; internal set; }
81+
82+
/// <summary>The <see cref="MessageBlock"/> joined into a single string, matching the platform's concatenated block.</summary>
83+
public string MessageBlockText => string.Join(Environment.NewLine, MessageBlock ?? new List<string>());
84+
}
85+
}

src/XrmMockup365/TracingService.cs

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,43 @@
1-
using Microsoft.Xrm.Sdk;
1+
using Microsoft.Xrm.Sdk;
22
using System;
3+
using System.Collections.Generic;
34

45
namespace DG.Tools.XrmMockup
56
{
67
internal sealed class TracingService : ITracingService
78
{
9+
private readonly Action<string> _sink;
10+
private readonly List<string> _messages = new List<string>();
11+
12+
public TracingService() : this(null) { }
13+
14+
/// <param name="sink">Optional callback invoked with each fully-evaluated trace message, allowing a consumer to collect them for assertions.</param>
15+
public TracingService(Action<string> sink)
16+
{
17+
_sink = sink;
18+
}
19+
20+
/// <summary>
21+
/// The trace messages emitted through this service instance, in order. Because a fresh
22+
/// service is created per plugin/workflow execution, this scopes the messages to that
23+
/// single execution and lets them be grouped into a <see cref="PluginTraceLog"/>.
24+
/// </summary>
25+
public IReadOnlyList<string> Messages => _messages.AsReadOnly();
26+
827
public void Trace(string format, params object[] args)
928
{
10-
try
11-
{
12-
Console.WriteLine(format, args);
13-
}
14-
catch
15-
{
16-
Console.WriteLine(format);
17-
}
29+
// Match the platform's sandbox tracing service: a message supplied without
30+
// arguments is emitted verbatim (so literal braces in e.g. XML/HTML are fine),
31+
// while a message with arguments is evaluated through String.Format. We do NOT
32+
// swallow a resulting FormatException, so invalid format strings surface during
33+
// testing instead of silently slipping through to production.
34+
var message = (args == null || args.Length == 0)
35+
? format
36+
: string.Format(format, args);
37+
38+
Console.WriteLine(message);
39+
_messages.Add(message);
40+
_sink?.Invoke(message);
1841
}
1942
}
2043
}

0 commit comments

Comments
 (0)