Recently, I ran into a mountain of errors whilst trying to create a sophisticated audit log system using Prisma. It felt like every step I turned was the wrong one.
I tried $transaction
queries, Prisma clientExtensions and SQL triggers but there was always some kink that couldn't be ironed out. Whether it be the inability to access user context, unable to access before and after content or $transaction
timeout errors.
There was always something that couldn't be bypassed. Here's how I eventually solved it.
There were a few things I wanted to acheive:
- An audit log should have a user relation (e.g. store the userId).
- Be abstracted away from models.
- Stores the before and after content of the change.
The first and worst solution
The first and worst solution was really clunky. It was the first tackle I had at
the problem and decided to just brute force the issue. I create an AuditLog
table and just
created a new entry after every update manually.
using SQL Triggers
This is where I turned to SQL triggers, I thought i'd solved the problem and this would be the end of it. With the only
caveat being that I had to add a lastUpdatedByUserId
column to every table. This would make the talbes unnormailsed, but I was willing
to accpet that if it meant I could have audit logs working.
The great thing about this method was that SQL provides OLD
and NEW
objects that contain the before and after content oof the change.
This was perfact for what I wanted to acheive. However, they aren't stored as objects e.g. I can't loop through the JSON object and would
have to list out every single field in the table. This was a deal breaker for me, as there are many tables that I would have to keep
trakc and up to date with changes when I updated the database.
The real downfall of this system was the fact that SQL is a declarative langauge rather than a imperative language like TypeScript.
using Prisma clientExtensions
This was actually the first solution that I thought of, but it quickly went [word] when I couldn't get the user context. So, I moved on pretty quickly past this method. However, later, I found out how to add user context to queries. It isn't the best method, but it's the best so far.
The major problem with this method is that it doesn't work with Prisma $transaction
queries as the middleware doesn't know it is
inside of a transaction query (reference). A transaction query is a query that is wrapped in a transaction, so if one query fails, the whole transaction will
be rolled back. At the moment, I have just removed all $transaction
queries from my codebase, but I will have to come back to this at some point.
This is a major problem as I have to use $transaction
with Prisma middleware and return this error:
Adding user context to Prisma queries
To add user context to Prisma queries, I created a withQueryContext
funciton that adds the user context to the query.
That is, until user context is fully supported.
Now we can wrap any Prisa query with the withQueryContext
function to add the user context to the query.
This will cause an error with Prisma, so in middleware we need to remove the userId
and modelId
from the query after we have
used it.
As you can see, we only return the rest of the arguments and not userId
and modelId
to avoid any errors. However, now we can
still use the values in the middleware.
Adding Audit Logs to Prisma
Now that we have the dynamic context sorted, we can move on to audit logs. I've already shown the base of the code in the last codeblock. But here, I will go through each section in detail.
First, we should define the models that we want to include in the audit logs. This is so we can ignore any
tables that don't need audit logs e.g. the audit log table itself. Then, we also define the operations that we want to
audit and the fields we want to ignore. We only want to create audit logs for create, update and delete operations. We also want to ignore the updatedAt
field
as this will always change in these operations (except for delete). This extends to the many operations too, although
I haven't implemented audit logs for these yet, so I will just go through the unique operations.
If the model has been validated as not something that we want to audit, then we should just return the query as it was. If the user isn't authenticated, then we can't create an audit log, so again we should just return the query.
With SQL Triggers, we could access the OLD
and NEW
objects to get the changes that were made. However,
with Prisma, we can't take advantage of this. Instead, we have to query the current model for the previous
state of the data and then later execure the original query to get the changes.
This function will query the model for the "previous" (current) data. If the operation is a unique operation, then we
run findUnique
and if it is a many operation, then we run findMany
. We then return the result or an empty object if
the result is null. This makes it easier later on to create a difference
function for the two objects.
I struggled to create a type for params
besides using any
. I tried using Prisma.Extension["query"]
which worked to a certain point but the types weren't quite right.
If you have any suggestions please let me know! (reference).
Also, there is a known issue where the PrismaClient type is wrong when being used with .$extends
so I just use and any in this case too. I could pass it as a generic.
Here we intialise oldValues
and newValues
to an empty object for comparison later on.
If it's a create
operation we just return an empty object as there will be no old value.
The getOldValue
function will either query the current model or just return an empty object.
From here, we need to remove any "select" parameters that may have been added as this will
prevent us from comparing the data. Then we call the actual query which should return the
result of the operation. I created an equal
helper function to compare the objects as if
they are equal there is no need to create an audit log (reference).
We now need to check if it is a delete operation, as if it is we don't need to do the difference checks.
Finally, we can create the audit log and return the result. This is fairly self-explanatory
so I won't go into too much detail, but, we just create the audit log in the database and update the
total user edits by one. I have separated audit logs into multiple tables but you could just do it
in one. I use a $transaction
query here to rollback any changes if either of the transactions
fail.
As you can see, I have also used a totalEdits
column to store the total edits for efficiency
rather than doing an aggregate query each time.
Well, this is how I solved my audit log dilemma. However, as mentioned before, the client
extensions won't have access to $transaction
context and so it will result in a
timeout error. At the moment, there is no way around this other than using more than
one Prisma instance or just removing $transaction queries in places where you require middleware.