Audit trails (change logs) in Visual Studio LightSwitch

Introduction

This post elaborates on an excellent article of Beth Massi (http://blogs.msdn.com/b/bethmassi/archive/2011/06/22/how-to-create-a-simple-audit-trail-change-log-in-lightswitch.aspx) .

The idea of this post is provide a more generic approach for creating audit trails, suitable for “the enterprise”.

Audit trails is one of the prominent “cross-cutting concerns” in enterprise applications. LightSwitch doesn’t provide out-of-the-box functionality for creating change logs.

In a nutshell, change logs (or audit trails) captures server side changes happening in entities and writes these changes in a dedicated table.  Obviously, we need also functionality to consult change logs.

Apart from showing the enhanced audit trail functionality, I want to focus on an very cool feature of Lightswitch, namely the ability to ask an entity to show itself (only based on an Id and the entity name as as string) preferably by using its default edit screen and if there is no,  by creating on the fly an edit screen.

Objectives:

  • We want one audit table for our complete application and we don’t want a “hard link” towards the table which is audited.
  • The code necessary server side for hooking up the generation of the audit trail should be minimal.
  • The client side functionality for consulting the change log must be generic in the sense that it should be possible to visualize change logs of any entity type (customers, orders, products, …).
  • The change log viewer should have the possibility to navigate to the entity that has been logged (under the assumption that it has not been deleted previously). The effort necessary client side to visualize a new entity type should be … zero.

Structure of the audit trail table

The fields of the AuditTrail table fall in three categories:

  • a field for indicating the type of change (insert, update or delete): this is the ChangeType field;
  • fields describing the changes: ChangedBy,  OriginalValues, NewValues and Updated (date);
  • fields necessary for linking towards the entity which is audited: ReferenceId and ReferenceType. We need this info client side, when we want to navigate to the current entity involved in the audit trail.

 

Server side processing

We want to minimize the effort necessary to hook up change logging server side. So, we make use a helper class (making use of generics) providing functionality for creating an audit trails for an insert, an update and a delete. These methods are called during the save pipeline.

public static class AuditHelper

    {
        public static void CreateAuditTrailForUpdate<E>(E entity, ApplicationData app) where E : IEntityObject
        {
            AuditTrail auditRecord = app.AuditTrails.AddNew();
            auditRecord.ChangeType = "Updated";
            auditRecord.ReferenceId = (int)entity.Details.Properties["Id"].Value;
            auditRecord.ReferenceType = entity.Details.EntitySet.Details.Name;
            auditRecord.Updated = DateTime.Now;
            auditRecord.ChangedBy = Application.Current.User.FullName;
            StringBuilder newValues = new StringBuilder("New Values :" +  Environment.NewLine);
            StringBuilder oldValues = new StringBuilder("Original Values :" + Environment.NewLine);

            foreach (var prop in entity.Details.Properties.All().OfType<IEntityStorageProperty>())
            {
                if (prop.Name != "Id")
                {
                    if (!(Object.Equals(prop.Value, prop.OriginalValue)))
                    {
                        oldValues.AppendLine(string.Format("{0}: {1}", prop.Name, prop.OriginalValue));
                        newValues.AppendLine(string.Format("{0}: {1}", prop.Name, prop.Value));
                    }
                }
            }

            foreach (var prop in entity.Details.Properties.All().OfType<IEntityReferenceProperty>())
            {
                if (prop.Name != "Id")
                {
                    if (!(Object.Equals(prop.Value, prop.OriginalValue)))
                    {
                        oldValues.AppendLine(string.Format("{0}: {1}", prop.Name, prop.OriginalValue));
                        newValues.AppendLine(string.Format("{0}: {1}", prop.Name, prop.Value));
                    }
                }
            }

            auditRecord.OriginalValues = oldValues.ToString();
            auditRecord.NewValues = newValues.ToString();
        }

        public static void CreateAuditTrailForInsert<E>(E entity, ApplicationData app) where E : IEntityObject
        {
            AuditTrail auditRecord = app.AuditTrails.AddNew();
            auditRecord.ChangeType = "Inserted";
            auditRecord.ReferenceId = (int)entity.Details.Properties["Id"].Value;
            auditRecord.ReferenceType = entity.Details.EntitySet.Details.Name;
            auditRecord.Updated = DateTime.Now;
            auditRecord.ChangedBy = Application.Current.User.FullName;
            StringBuilder newValues = new StringBuilder("Inserted Values :" + Environment.NewLine);

            foreach (var prop in entity.Details.Properties.All().OfType<IEntityStorageProperty>())
            {
                if (prop.Name != "Id")
                {
                    newValues.AppendLine(string.Format("{0}: {1}",  prop.Name, prop.Value));
                }
            }

            foreach (var prop in entity.Details.Properties.All().OfType<IEntityReferenceProperty>())
            {
                if (prop.Name != "Id")
                {
                    newValues.AppendLine(string.Format("{0}: {1}", prop.Name, prop.Value));
                }
            }

            auditRecord.NewValues = newValues.ToString();
        }

        public static void CreateAuditTrailForDelete<E>(E entity, ApplicationData app) where E : IEntityObject
        {
            AuditTrail auditRecord = app.AuditTrails.AddNew();
            auditRecord.ChangeType = "Deleted";
            auditRecord.Updated = DateTime.Now;
            auditRecord.ChangedBy = Application.Current.User.FullName;
            auditRecord.ReferenceId = (int)entity.Details.Properties["Id"].Value;
            auditRecord.ReferenceType = entity.Details.EntitySet.Details.Name;
            StringBuilder oldValues = new StringBuilder("Deleted Values :" + Environment.NewLine);

            foreach (var prop in entity.Details.Properties.All().OfType<IEntityStorageProperty>())
            {
                if (prop.Name != "Id")
                {
                    oldValues.AppendLine(string.Format("{0}: {1}", prop.Name, prop.Value));
                }
            }

            foreach (var prop in entity.Details.Properties.All().OfType<IEntityReferenceProperty>())
            {
                if (prop.Name != "Id")
                {
                    oldValues.AppendLine(string.Format("{0}: {1}", prop.Name, prop.Value));
                }
            }

            auditRecord.OriginalValues = oldValues.ToString();
        }

    }

 

Consuming this helper class is very straight-forward:

partial void Customers_Updating(Customer entity)
        {
            AuditHelper.CreateAuditTrailForUpdate(entity,this);
        }

partial void Customers_Deleting(Customer entity)
        {
            AuditHelper.CreateAuditTrailForDelete(entity, this);
        }

partial void Customers_Inserted(Customer entity)
        {
            AuditHelper.CreateAuditTrailForInsert(entity, this);
        }

I can imagine there are ways to get rid of these 3 lines of code by making use of AOP tooling like PostSharp, but for the moment I don’t care.

Consulting the change log.

The tricky part in the screen logic is the navigation to the entity which has been audited. (by clicking on the Show link in the last column).

Luckily, LightSwitch has build-in functionality to call the default edit screen of an entity by means of the ShowDefaultScreen method which needs an IEntityObject as input parameter. If there is no default screen, Lightswitch will create it for you on the fly !

public partial class SearchAuditTrails
    {
        partial void Show_Execute()
        {
            IEntityObject entityObject = null;
            int referenceId = this.AuditTrails.SelectedItem.ReferenceId;
            string entitySetName = this.AuditTrails.SelectedItem.ReferenceType;
            entityObject = this.DataWorkspace.ApplicationData.GetEntityByKey(entitySetName, referenceId);

            if (entityObject != null)
            {
                this.Application.ShowDefaultScreen((IEntityObject)entityObject);
            }

            else
            {
                this.ShowMessageBox("Sorry, the record is no longer available...");
            }
        }

    }

The trick is to retrieve the EntityObject by means of following extension method:

    public static class DataServiceExtensions
    {
        public static IEntityObject GetEntityByKey(this IDataService dataService,
string entitySetName, params object[] keySegments)
        {
            ICreateQueryMethod singleOrDefaultQuery = dataService.Details.Methods[entitySetName +
 "_SingleOrDefault"] as ICreateQueryMethod;

            if (singleOrDefaultQuery == null)
            {
                throw new ArgumentException("An EntitySet SingleOfDefault query does
not exist for the specified EntitySet.", entitySetName);
            }

            ICreateQueryMethodInvocation singleOrDefaultQueryInvocation = singleOrDefaultQuery.CreateInvocation(keySegments);

            return singleOrDefaultQueryInvocation.Execute() as IEntityObject;
        }

    }

 

It should be clear that when your application grows (by adding new tables), you don’t need to do any client side update for coping with the new entity types. So, sit back and relax a bit and think how you would do this in an own setup of Silverlight, WCF, Entity Framework and your prefered MVVM toolkit ? Sure, it’s possible, take your favorite strategy pattern, IOC container and reflexive coding skills, …  but how much time/effort would it cost?

Conclusion

I hope this post demonstrates that LightSwitch is not only capable of creating great applications but that it can handle also cross-cutting concerns in an elegant way.
I try to attach soon a visual studio solution, demonstrating the above functionality, but I’ll wait for Lightswitch V1. So, it will be rather sooner than later :)

Hope this helps.

Paul