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<IEntityStorageProperty>())
{
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




Hi Paul,
Will this only work for the Application data base or will it also work for external tables that might not have “Id” as a key?
Thanks,
Dave
Hi Dave,(E entity, ApplicationData app) where E : IEntityObject
The key segment won’t be a problem. I tried it out.
But my current implementation only points to ApplicationData (refering to the intrinsic database). So, if you change second param in “YourDataSource”, it should work.
public static void CreateAuditTrailForUpdate
Thanks
paul.
This is some fantastic work. Thanks for sharing. I also enjoyed the security post. I’d really like to see the LightSwitch Team review your code and incorporate it into the product – with attribution and compensation going to you.
This is excellent thanks, I was looking for an audit solution for my new application and this looks like it will fit the bill perfectly. And it’s in C#
I’ve tried to implement this but the ApplicationData member in the line:
“public static void CreateAuditTrailForUpdate(E entity, ApplicationData app) where E : IEntityObject”
Is not found. I just get “The type or namespace name ‘ApplicationData’ could not be found (are you missing a using directive or an assembly reference?)”
I think I must be missing something fundamental, any suggestions?
OK, forget that question I’ve worked it out – and I’ve got it working with tables with different primary key names. Works a treat
Can I make one suggestion though? Include the using statements, as people new to LightSwitch like me are not familiar with the name spaces yet. And they are not well documented elsewhere either.
You are right, using statements should be there as well. I’m still learning the art of writing good blog posts. I should definitely make a download of the complete solution available as well. Thanks for your suggestion Nigel! Paul.
hi Dave, I’m new to all of this, so please excuse my question? Regarding the class files, AuditHelper, SearchAuditTrails, and DataServiceExtensions, do I create these classes and put them in the Server>UserCode directory
Hi,
The audithelper should be available server side. SearchAuditTrails is just a screen. The dataServiceExtensions should be called from that screen, so it should be in the usercode folder of the client project.
good luck.
paul.
Is there a trick to logging changes on one Data Source into another (ApplicationData) that I am missing in relation to Namespaces ?
When when attaching code to call AuditHelper.Create as part of the …_Updating, I can’t get the “this” to resolve correctly, as the events on the DataSource (SQL tables) entities are in the DataSourceService class, not DataSource.
Do I need to create Audit tables within the same DataSource ?
Hi Andrew,
Thanks a lot for the feedback !
You are completely right.
The current implementation only works with the intrinsic database, not with external databases.
So, the audithelper methods, get now as a parameter the ApplicationData object.
I think it would be possilbe to create the dataworkspace inside these methods (rather than getting it in as a param), by using:
Application.Current.CreateDataWorkspace().ApplicationData …
By doing so, it should be possible to handle any datasource.
Please let me know, if you could find a way, otherwise I’ll look into it.
-paul.
[...] One LightSwitch data-service that hosts the “generic auditing table”, it’s largely based on Paul Van Bladel’s articles on generic auditing trails. [...]