Understanding Tracking in Windows Workflow Foundation

This post will show how to create a simple console application that executes tracking queries in Windows Workflow Foundation 3.0.

In my last post, I created a demo that shows how easy it is to use the persistence capabilities of WF.  Another incredibly valuable feature of WF 3.0 is the ability to use tracking.  Tracking, in as simple terms as possible, is a mechanism that allows you to write events to a database to log various events within your workflow.  Instead of peppering your workflows with Console.WriteLine or Debug.WriteLine calls, you can use tracking as a way to write events asynchronously to a durable store so that they can be queried later.  I wanted to try to write a simple demo that shows off tracking as simply as possible.  Tracking is a pretty simple concept.  As the workflow instance moves through its lifecycle (Initialized, Loaded, Persisted, Completed, etc), you can trap the events and times that the events occur.  Similarly, you can track activities as they move through the states in the ActivityExecutionStatus lifecycle. 

ActivityExecutionStatus state diagram

This is useful because you can query the tracking store to determine what state a workflow is in and what activity it last executed.  For workflows of any complexity, this is a huge bonus because it relieves you of having to create your own reporting structure in the database, code logic to write to that reporting store, and finally write reports against that store to convey to end users what's going on.  Instead, you can leverage the tracking capabilities in WF to simply query the tracking store and display the results however you want. 

When you install .NET 3.0, a set of SQL scripts are created at C:\Windows\Microsoft.NET\Framework\v3.0\Windows Workflow Foundation\SQL\EN\ called tracking_schema.sql and tracking_logic.sql.  Just create a database (I called mine Tracking, using SQLExpress) and run those scripts to create the tables and stored procs to support the out of box tracking for WF.

There are several excellent examples of using tracking in the SDK to get you started.  For example, there is a Simple Tracking Sample that will display tracking events for a workflow to a Console window.  There's also a Console Tracking Service sample that shows how to build your own tracking service that emits tracking events to the Console.  I am going to build a simple tracking example here to get you started and show how easy this is.

Using Tracking in Your Application

Using tracking is as simple as adding 2 lines of code to the host application that creates the workflow runtime.

                 //Add the tracking service
                SqlTrackingService track = new SqlTrackingService(@"Data Source=.\sqlexpress;Initial Catalog=Tracking;Integrated Security=True;Pooling=False");
                workflowRuntime.AddService(track);

Alternatively, you can provide this in configuration.  Careful in copying this, the type attribute has several line breaks in it that need to be removed (trying to accommodate the formatting for my blog to make it readable).

 <?xml version="1.0" encoding="utf-8" ?>
<configuration>
  <configSections>
    <section name="WorkflowRuntime"
             type="System.Workflow.Runtime.Configuration.WorkflowRuntimeSection, 
             System.Workflow.Runtime, Version=3.0.0.0, Culture=neutral, 
             PublicKeyToken=31bf3856ad364e35"/>
  </configSections>
  <WorkflowRuntime Name="MyApplication">
    <Services>
      <add type="System.Workflow.Runtime.Tracking.SqlTrackingService, 
           System.Workflow.Runtime, Version=3.0.00000.0, Culture=neutral, 
           PublicKeyToken=31bf3856ad364e35"
           
          ConnectionString="Data Source=.\sqlexpress;Initial Catalog=Tracking;Integrated Security=True;Pooling=False"/>

    </Services>
  </WorkflowRuntime>
</configuration>

That's it, just provide a connection string to the database that contains the schema and logic for the tracking store (the scripts for which are located on your disk when you install Visual Studio 2008 or .NET Framework 3.0 at C:\Windows\Microsoft.NET\Framework\v3.0\Windows Workflow Foundation\SQL\EN\).  Run your workflow application, and you should se... nothing.  That's right, everything should just work normally because the tracking records are written asynchronously to the database.  You can verify the records are being written by going to the WorkflowInstance table in your tracking database and viewing the records.

image

Now that the records are in the database, you can use tracking queries to query the application and display the results in a number of ways.

Building a Simple Tracking Application

Most of the work for querying tracking can be done in two simple lines of custom code that we'll wrap in a function, GetTrackingEvents:

         IList<SqlTrackingWorkflowInstance> GetTrackingEvents(string connectionString)
        {
            SqlTrackingQuery query = new SqlTrackingQuery(connectionString);

            return query.GetWorkflows(new SqlTrackingQueryOptions());
        }

This function will get all of the workflows using the default tracking profile, which basically says "give me all of the events for all workflows in the tracking store".  You can limit by date range and other capabilities, we'll get to that in a minute.  For now, let's use our GetTrackingEvents function.

 static void WriteTrackingEvents(IList<SqlTrackingWorkflowInstance> instances)
{
    foreach (SqlTrackingWorkflowInstance instance in instances)
    {
        foreach (WorkflowTrackingRecord instanceRecord in instance.WorkflowEvents)
        {
            Console.ForegroundColor = ConsoleColor.Green;
            Console.WriteLine("EventDescription : {0}  DateTime : {1}",
                instanceRecord.TrackingWorkflowEvent,
                instanceRecord.EventDateTime);
        }

        foreach (ActivityTrackingRecord activityRecord in instance.ActivityEvents)
        {
            Console.ForegroundColor = ConsoleColor.Cyan;
            Console.WriteLine("\t{0} - {1} : {2}",
                activityRecord.QualifiedName,
                activityRecord.ExecutionStatus,
                activityRecord.EventDateTime);
        }
    }
}

Honestly, that's the bulk of the work.  You don't have to write a bunch of SQL queries, that part has already been handled for you.  This static method will accept the generic list of SqlTrackingWorkflowInstance objects and will dump out the WorkflowEvents and ActivityEvents to console.  These two event types capture different types of information.  WorfklowEvents contain information pertaining to the workflow instance, while ActivityEvents capture information about the ActivityExecutionStatus lifecycle.

To consume these methods, we need to pass the connection string to the GetTrackingEvents method (obviously, it's a parameter!)  However, there's another bit of information that's needed here... a reference to the assembly that contains the workflow and the assemblies that the workflow uses for its activities.  This could be as simple as copying the DLLs and EXE for your workflow application the bin directory of our new tracking application, but there's a more elegant way to handle this.  In an earlier post, I showed the introduction of the .NET 2.0 ReflectionOnly context.  We can leverage this context to provide a custom assembly resolver.

         Assembly ResolveCallback(object sender, ResolveEventArgs args)
        {
            string[] dlls = Directory.GetFiles(_path, "*.dll");
            string[] exes = Directory.GetFiles(_path, "*.exe");
            
            //Combine all DLLs and EXEs to a single array
            string[] assemblies = new string[dlls.Length + exes.Length];
            dlls.CopyTo(assemblies, 0);
            exes.CopyTo(assemblies, dlls.Length);

            foreach (string file in assemblies)
            {
                Assembly asm = Assembly.ReflectionOnlyLoadFrom(file);
                if (asm.FullName == args.Name)
                {
                    asm = System.Reflection.Assembly.LoadFile(file);
                    return asm;
                }
            }           
            return null;
        }

This method is called when the assembly cannot be resolved in the GAC or local bin directory.  To wire this callback into the assembly resolver pipeline, we add it into the constructor of our application.

         public Program()
        {
            AppDomain.CurrentDomain.AssemblyResolve +=
              new ResolveEventHandler(ResolveCallback);
        }

If you look inside the ResolveCallback method above, you'll see reference to a variable, _path.  We are going to have the user pass this information into the console application.  Here is the full listing for our application.

 using System;
using System.Collections.Generic;
using System.Workflow.Runtime.Tracking;
using System.Reflection;
using System.IO;

namespace SimpleTracking
{
    class Program
    {
        static string _path;
        static string _connectionString = @"Data Source=.\sqlexpress;Initial Catalog=Tracking;Integrated Security=True;Pooling=False";

        public Program()
        {
            AppDomain.CurrentDomain.AssemblyResolve +=
              new ResolveEventHandler(ResolveCallback);
        }


        static void Main(string[] args)
        {
            ProcessArgs(args);

            Program p = new Program();
            IList<SqlTrackingWorkflowInstance> instances = 
               p.GetTrackingEvents(_connectionString);
           
            WriteTrackingEvents(instances);
            
            Console.WriteLine("Press the <ENTER> key to continue");
            Console.ReadLine();
        }

        static void ProcessArgs(string[] args)
        {
            if (args.Length == 0)
            {
                Console.WriteLine("Usage:  SimpleTracking.exe connectionString path");
            }
            _connectionString = args[0];
            _path = args[1];
        }

        IList<SqlTrackingWorkflowInstance> GetTrackingEvents(string connectionString)
        {
            SqlTrackingQuery query = new SqlTrackingQuery(connectionString);

            return query.GetWorkflows(new SqlTrackingQueryOptions());
        }

        static void WriteTrackingEvents(IList<SqlTrackingWorkflowInstance> instances)
        {
            foreach (SqlTrackingWorkflowInstance instance in instances)
            {
                foreach (WorkflowTrackingRecord instanceRecord in instance.WorkflowEvents)
                {
                    Console.ForegroundColor = ConsoleColor.Green;
                    Console.WriteLine("EventDescription : {0}  DateTime : {1}",
                        instanceRecord.TrackingWorkflowEvent,
                        instanceRecord.EventDateTime);
                }

                foreach (ActivityTrackingRecord activityRecord in instance.ActivityEvents)
                {
                    Console.ForegroundColor = ConsoleColor.Cyan;
                    Console.WriteLine("\t{0} - {1} : {2}",
                        activityRecord.QualifiedName,
                        activityRecord.ExecutionStatus,
                        activityRecord.EventDateTime);
                }
            }
        }


        Assembly ResolveCallback(object sender, ResolveEventArgs args)
        {
            string[] dlls = Directory.GetFiles(_path, "*.dll");
            string[] exes = Directory.GetFiles(_path, "*.exe");
            
            //Combine all DLLs and EXEs to a single array
            string[] assemblies = new string[dlls.Length + exes.Length];
            dlls.CopyTo(assemblies, 0);
            exes.CopyTo(assemblies, dlls.Length);

            foreach (string file in assemblies)
            {
                Assembly asm = Assembly.ReflectionOnlyLoadFrom(file);
                if (asm.FullName == args.Name)
                {
                    asm = System.Reflection.Assembly.LoadFile(file);
                    return asm;
                }
            }           
            return null;
        }
    }
}

To make things easier to debug, you can set the command line arguments for the connection string and folder where your workflow lives in the debug project properties pane.

image

When you run the application, you will see the events written to the console output, with workflow events in green and activity events in cyan.

image

 

Filtering Tracking Data

Another interesting feature is the ability to filter the data that you are working with.  As your worklfows continue to execute, you can imagine quite a few records being written to the database.  Maybe your application shouldn't show them all, maybe you just want the tracking events for the past few days.  Or perhaps you are only concerned with a single type of workflow and don't want to load all of the workflows and their various types.  You can do this by altering our custom GetTrackingEvents method to use SqlTrackingQueryOptions.

         IList<SqlTrackingWorkflowInstance> GetTrackingEvents(string connectionString)
        {
            SqlTrackingQuery query = new SqlTrackingQuery(connectionString);
            SqlTrackingQueryOptions options = new SqlTrackingQueryOptions();
            options.StatusMinDateTime = DateTime.Now.AddDays(-3);
            options.StatusMaxDateTime = DateTime.Now;
            options.WorkflowType = typeof(Persistence.Workflow1);
            return query.GetWorkflows(options);
        }

Slightly more complex than using simple filtering is the ability to define a profile for tracking queries.  Tracking profiles allow you to define a much more granular definition of the types of tracking events that are interesting to you.  Creating tracking profiles programmatically requires a bit of familiarity with the object model, but thankfully there's a sample in the SDK, "Tracking Profile Designer Sample" that makes this work much simpler.

Click here for larger image

If you want to see how tracking profiles come into the picture, I highly recommend reading Windows Workflow Foundation: Tracking Services Deep Dive.

The Value of Workflow Tracking

There are plenty of links on the web that show simple examples of using tracking.  Like I said before, the value of WF tracking is that you don't have to create the reporting database store, write the code that populates the store, and then write the applications that query the store.  You simply leverage the query capabilities of WF and integrate into your application.  There are some incredibly powerful capabilities that you can provide your end users just by stealing borrowing some code from several excellent examples. 

For example, there is a Workflow Monitor sample application that hosts the workflow designer in a Windows Forms application to read the tracking data and convey visually where your workflow left off.  Instead of simply dumping the events to console, you can use the events to display the status of your workflow.  As you click on the activity events, the workflow designer will update and highlight the activity corresponding to the event you clicked.

image

Jon Flanders extended that sample and created an AJAX workflow monitor, which I showed how to upgrade to use the AJAX workflow monitor with ASP.NET 3.5.  As you click the Select link for each of the activity events, it highlights the image.

The workflow designer can be rehosted in many ways, and the code to do this is basically the same as that found in the Workflow Monitor sample above.  My personal favorite is this one, re-hosting the designer to display tracking information in an MMC snap-in, part of the DinnerNow sample.

image

David Aiken also created a PowerShell cmdlet as part of the DinnerNow sample that lets you query the tracking store and view a visual representation.  This app is cool because it is a WPF application that includes a zoom scrollbar to allow you to zoom easily in and out of the workflow.

I really like the look of the last example, it highlights the activity with a color.  In fact, I showed how to customize the WF designer code to provide custom highlighting in a previous post.

 

Resources

 

Windows Workflow Foundation: Tracking Services Introduction

Windows Workflow Foundation: Tracking Services Deep Dive

Tracking Profile Designer Sample

how to customize the WF designer code to provide custom highlighting

DinnerNow sample

use the AJAX workflow monitor with ASP.NET 3.5

Workflow Monitor sample