Most Canvas Apps rely on Dataverse's built-in audit log — but it doesn't capture business context, before/after field values, or read access. Learn how to build formula-level audit logging directly into your app, producing a rich, queryable change history that satisfies real compliance requirements.

Imagine you're the lead developer on a supply chain management app used by 200 warehouse staff across three facilities. One afternoon, a manager escalates an urgent issue: a purchase order was modified to change a quantity from 500 units to 50 units, a downstream shipment was short, and nobody can explain what happened or who did it. The native Dataverse audit log shows that something changed, but it doesn't capture which screen the user was on, what the original value was before the edit, or the business context behind the change — like which approval workflow step was active at the time.
This is the gap formula-level audit logging fills. Instead of relying entirely on platform-level logs (which are coarse-grained and locked behind admin portals), you build the audit trail directly into your app's formulas. Every Patch(), every delete, every navigation event that matters — captured, timestamped, and stored in a structured Dataverse table that your compliance team can query, export, and present to auditors.
By the end of this lesson, you'll have a complete, production-ready audit logging architecture in your Canvas App. This isn't just about writing a log record — it's about capturing enough context to reconstruct exactly what happened, when, and why.
What you'll learn:
LogAuditEvent() pattern using Patch() and named formulasYou should be comfortable with:
Patch(), Filter(), and LookUp() formulas — see Master Power Apps Formulas: Navigate, Filter, Lookup, and Patch for Professional Apps if you need a refresherIfError() is assumedYou do not need experience with Azure logging tools or compliance frameworks, though we'll discuss how this integrates with both.
Native Dataverse auditing is genuinely useful. When you enable it on a table, Dataverse records field-level changes at the platform layer — no app code required. But it has real limitations in practice:
It only captures what changes in Dataverse. If a user opens a record, reads sensitive compensation data, and closes it without editing, nothing is logged. Read access auditing exists in Dataverse but requires premium licensing configurations and still doesn't capture application context.
It doesn't capture business context. Dataverse knows that QuantityOrdered changed from 500 to 50. It doesn't know that the user was on the "Emergency Order Edit" screen, that the change was submitted as part of a "Supplier Shortage" workflow, or that the user tried to save once and got a validation error before succeeding on the second attempt.
Admins own the log. Your end users — managers, compliance officers — can't query the native audit log through the app. They need a system administrator to pull data from the audit history, which creates friction and delays.
Formula-level logging solves all three problems. You control the schema, the content, and the access. The tradeoff is that you're responsible for calling the logging code consistently, which is exactly what this lesson is about making easy and reliable.
Note: Formula-level logging complements platform auditing — it doesn't replace it. For most compliance scenarios, you'll want both running simultaneously. Platform auditing serves as the tamper-evident baseline; your formula logs serve as the rich, queryable business context layer.
The table design is where most people go wrong. They create an audit log that captures that something happened, but not enough to reconstruct what happened or why it matters. Here's a schema that holds up under real audit scrutiny.
Create a new Dataverse table named AuditLog (display name: "Audit Log"). Set it to no quick create and disable attachments — you want this table to be lean and write-optimized. Set the ownership to Organization rather than User/Team so that any system user can write to it without ownership-related permission issues.
Add the following columns:
| Column Display Name | Schema Name | Type | Notes |
|---|---|---|---|
| Event Type | cr_eventtype |
Choice | See values below |
| App Name | cr_appname |
Text (100) | Populated from a global constant |
| Screen Name | cr_screenname |
Text (100) | Which screen the event occurred on |
| Entity Name | cr_entityname |
Text (100) | The Dataverse table affected |
| Record ID | cr_recordid |
Text (100) | GUID of the affected record |
| Record Display Name | cr_recorddisplayname |
Text (250) | Human-readable identifier |
| Field Name | cr_fieldname |
Text (100) | For field-level changes |
| Old Value | cr_oldvalue |
Multiline Text | Serialized previous value |
| New Value | cr_newvalue |
Multiline Text | Serialized new value |
| Action Result | cr_actionresult |
Choice | Success / Failure |
| Error Message | cr_errormessage |
Multiline Text | Populated on failures |
| Session ID | cr_sessionid |
Text (100) | Ties events in one user session |
| Additional Context | cr_additionalcontext |
Multiline Text | JSON blob for custom data |
| User Email | cr_useremail |
Text (100) | From User().Email |
| User Display Name | cr_userdisplayname |
Text (250) | From User().FullName |
| Timestamp | cr_timestamp |
Date and Time | Set by formula, not system |
For the Event Type choice column, create these values:
RecordCreatedRecordUpdatedRecordDeletedRecordViewedNavigationEventBulkOperationExportActionAuthorizationFailureValidationFailureSessionStartFor Action Result, use: Success, Failure, PartialSuccess.
Tip: Use a separate
cr_timestampcolumn rather than relying solely on Dataverse's built-increatedon. Thecreatedonreflects when the record was physically written to Dataverse, which can be several seconds after the user action occurred. Your explicit timestamp, captured in the formula at the moment of the event, is more legally defensible.
The SessionID column deserves special attention. By generating a GUID at app start and storing it in a global variable, you can link all audit events from a single user session together. This lets you reconstruct a complete user journey: they opened the app, navigated to the Purchase Orders screen, viewed record #PO-2047, edited it, and saved — all visible as a correlated sequence.
Rather than writing a full Patch() call every time you want to log an event, you'll build a pattern using named formulas (via the App-level Formulas property) that lets you call a single, consistent logging expression throughout your app.
In your App.OnStart property, add:
// Initialize session-level variables for audit logging
Set(gv_AuditSessionID, GUID());
Set(gv_CurrentUser, User());
Set(gv_AppName, "WarehouseOps v2.4");
// Log session start
Patch(
AuditLog,
Defaults(AuditLog),
{
cr_eventtype: 'cr_eventtype (AuditLog)'.SessionStart,
cr_appname: gv_AppName,
cr_screenname: "App.OnStart",
cr_entityname: "",
cr_recordid: "",
cr_recorddisplayname: "",
cr_actionresult: 'cr_actionresult (AuditLog)'.Success,
cr_sessionid: Text(gv_AuditSessionID),
cr_useremail: gv_CurrentUser.Email,
cr_userdisplayname: gv_CurrentUser.FullName,
cr_timestamp: Now()
}
);
The choice column syntax ('cr_eventtype (AuditLog)'.SessionStart) is how you reference specific choice values in Canvas Apps. If your publisher prefix is different from cr, adjust accordingly.
Named formulas in the App's Formulas property can't execute side effects like Patch() — they're pure expressions. For a centralized logging function, the practical approach in Canvas Apps is to create a component with a custom property that accepts parameters and executes the patch. However, if components add too much architectural overhead for your current app, the pragmatic alternative is a clearly documented Patch() template that you paste and customize.
Here's the component-based approach, which is the right long-term pattern. Create a Canvas Component named AuditLogger. Add a Custom Property of type Action (behavior property) named LogEvent. Add input parameters to it:
EventType (Text)ScreenName (Text)EntityName (Text)RecordID (Text)RecordDisplayName (Text)FieldName (Text)OldValue (Text)NewValue (Text)ActionResult (Text)ErrorMessage (Text)AdditionalContext (Text)In the LogEvent behavior property, write:
IfError(
Patch(
AuditLog,
Defaults(AuditLog),
{
cr_eventtype: Switch(
AuditLogger.EventType,
"RecordCreated", 'cr_eventtype (AuditLog)'.RecordCreated,
"RecordUpdated", 'cr_eventtype (AuditLog)'.RecordUpdated,
"RecordDeleted", 'cr_eventtype (AuditLog)'.RecordDeleted,
"RecordViewed", 'cr_eventtype (AuditLog)'.RecordViewed,
"NavigationEvent", 'cr_eventtype (AuditLog)'.NavigationEvent,
"ValidationFailure", 'cr_eventtype (AuditLog)'.ValidationFailure,
"AuthorizationFailure", 'cr_eventtype (AuditLog)'.AuthorizationFailure,
"BulkOperation", 'cr_eventtype (AuditLog)'.BulkOperation,
'cr_eventtype (AuditLog)'.RecordUpdated
),
cr_appname: gv_AppName,
cr_screenname: AuditLogger.ScreenName,
cr_entityname: AuditLogger.EntityName,
cr_recordid: AuditLogger.RecordID,
cr_recorddisplayname: AuditLogger.RecordDisplayName,
cr_fieldname: AuditLogger.FieldName,
cr_oldvalue: AuditLogger.OldValue,
cr_newvalue: AuditLogger.NewValue,
cr_actionresult: If(
AuditLogger.ActionResult = "Success",
'cr_actionresult (AuditLog)'.Success,
'cr_actionresult (AuditLog)'.Failure
),
cr_errormessage: AuditLogger.ErrorMessage,
cr_sessionid: Text(gv_AuditSessionID),
cr_useremail: gv_CurrentUser.Email,
cr_userdisplayname: gv_CurrentUser.FullName,
cr_timestamp: Now()
}
),
// Swallow logging failures silently — never let audit failure block user operations
false
);
Warning: Notice the
IfError(..., false)wrapping the entirePatch(). This is not optional — it's a non-negotiable architectural decision. If your audit log write fails (network timeout, permission issue, Dataverse throttling), you absolutely cannot allow that failure to propagate to the user and block their actual work. Log the failure if you can, fail silently if you can't, but never surface audit infrastructure problems as user-facing errors.
This is the part that separates a useful audit log from a compliance-grade one. Knowing that a field changed is table stakes. Knowing it changed from what to what is what lets you reconstruct events.
The pattern requires capturing the "before" state when a user begins editing, then capturing the "after" state when they save. Here's how it works in practice.
When a user selects a record to edit — typically by tapping it in a gallery or clicking an "Edit" button — that's your moment to snapshot the current values. Store them in a collection or record variable:
// In the OnSelect of your Edit button or gallery row tap
Set(
gv_EditingRecord,
{
RecordID: Text(ThisItem.cr_purchaseorderid),
DisplayName: ThisItem.cr_ponumber,
OriginalValues: {
QuantityOrdered: ThisItem.cr_quantityordered,
UnitPrice: ThisItem.cr_unitprice,
SupplierId: ThisItem.cr_supplierid,
Status: ThisItem.cr_status,
DeliveryDate: ThisItem.cr_deliverydate,
Notes: ThisItem.cr_notes
}
}
);
Navigate(EditPurchaseOrderScreen);
The key here is that gv_EditingRecord.OriginalValues is a frozen snapshot. No matter what the user does in the edit form, this record variable holds the state as of when they clicked Edit.
When the user submits the form, compare the form values against gv_EditingRecord.OriginalValues and log only the fields that actually changed:
// In the OnSelect of your Save button
// First, attempt the actual data save
With(
{
SaveResult: IfError(
Patch(
PurchaseOrders,
LookUp(PurchaseOrders, cr_purchaseorderid = GUID(gv_EditingRecord.RecordID)),
{
cr_quantityordered: Value(txtQuantity.Text),
cr_unitprice: Value(txtUnitPrice.Text),
cr_supplierid: ddSupplier.Selected.Value,
cr_notes: txtNotes.Text
}
),
// Capture the error
{Error: true, ErrorMessage: FirstError.Message}
)
},
If(
IsError(SaveResult) || (IsRecord(SaveResult) && SaveResult.Error = true),
// Log the failure
AuditLoggerComponent.LogEvent(
"RecordUpdated",
"EditPurchaseOrderScreen",
"PurchaseOrders",
gv_EditingRecord.RecordID,
gv_EditingRecord.DisplayName,
"MultipleFields",
"",
"",
"Failure",
SaveResult.ErrorMessage,
JSON({Reason: "SaveFailed"})
);
Notify("Save failed. Please try again.", NotificationType.Error),
// Save succeeded — now log each changed field
If(
Value(txtQuantity.Text) <> gv_EditingRecord.OriginalValues.QuantityOrdered,
AuditLoggerComponent.LogEvent(
"RecordUpdated",
"EditPurchaseOrderScreen",
"PurchaseOrders",
gv_EditingRecord.RecordID,
gv_EditingRecord.DisplayName,
"QuantityOrdered",
Text(gv_EditingRecord.OriginalValues.QuantityOrdered),
txtQuantity.Text,
"Success",
"",
""
)
);
If(
Value(txtUnitPrice.Text) <> gv_EditingRecord.OriginalValues.UnitPrice,
AuditLoggerComponent.LogEvent(
"RecordUpdated",
"EditPurchaseOrderScreen",
"PurchaseOrders",
gv_EditingRecord.RecordID,
gv_EditingRecord.DisplayName,
"UnitPrice",
Text(gv_EditingRecord.OriginalValues.UnitPrice),
txtUnitPrice.Text,
"Success",
"",
""
)
);
Notify("Purchase order saved successfully.", NotificationType.Success);
Navigate(PurchaseOrderListScreen)
)
);
This produces one audit log record per changed field, which is more useful for compliance than a single "record updated" event. An auditor can ask "show me every change to QuantityOrdered over the past 90 days" and get a direct, filterable answer.
Key insight: The
With()function is your friend here. It lets you capture the result of thePatch()call in a local variable (SaveResult) and branch on success or failure — all within a single formula chain. WithoutWith(), you'd need to use a context variable and split this across multiple formula steps, which is messier. See the Canvas App Error Handling: Building Resilient Apps with IfError, Notify, and Graceful Failure Patterns lesson for more depth on this pattern.
Not every compliance scenario is about data changes. In healthcare, finance, and HR applications, simply viewing a record can be a compliance event. The GDPR principle of data access accountability and SOX controls around financial data both care about who viewed what.
Add this to the OnVisible property of any screen that displays sensitive records:
// PurchaseOrderDetailScreen.OnVisible
If(
!IsBlank(gv_SelectedPurchaseOrder),
AuditLoggerComponent.LogEvent(
"RecordViewed",
"PurchaseOrderDetailScreen",
"PurchaseOrders",
Text(gv_SelectedPurchaseOrder.cr_purchaseorderid),
gv_SelectedPurchaseOrder.cr_ponumber,
"",
"",
"",
"Success",
"",
JSON({ViewContext: "DirectNavigation"})
)
);
For navigation events between screens that represent workflow transitions, log on OnHidden of the source screen or OnVisible of the destination. Logging on OnVisible is generally more reliable because it fires even if the user navigates via the back button or a deep link.
Tip: Avoid logging navigation in the
OnSelectof every navigation button. If the navigation fails for any reason, you'd have a log entry for a screen the user never actually reached.OnVisiblegives you ground truth — the user is definitively on this screen when that property fires.
For logging authorization failures — for example, when a user attempts to access a screen they shouldn't — this pairs naturally with Implementing Role-Based Screen Access and Dynamic UI in Canvas Apps Using Azure AD Group Membership. In your role check logic, when a user is denied access, log it before redirecting them:
// In the role gate logic on a restricted screen's OnVisible
If(
!gv_UserIsWarehouseManager,
AuditLoggerComponent.LogEvent(
"AuthorizationFailure",
"FinancialSummaryScreen",
"",
"",
"",
"",
"",
"",
"Failure",
"User attempted to access restricted screen without WarehouseManager role",
JSON({AttemptedRole: "WarehouseManager", ActualRole: gv_UserRole})
);
Navigate(AccessDeniedScreen),
// Authorized — continue loading the screen
Set(gv_LoadingFinancialData, true);
// ... rest of screen initialization
);
Deletions are the highest-risk operation for audit purposes because the data is gone — the log entry is often the only evidence of what existed. Log deletions before executing them, and capture the full record state in the AdditionalContext field as a JSON snapshot.
// In the OnSelect of a Delete confirmation button
With(
{
RecordToDelete: gv_SelectedPurchaseOrder,
RecordSnapshot: JSON(
gv_SelectedPurchaseOrder,
JSONFormat.IndentFour
)
},
// Log the deletion attempt first, before we actually delete
AuditLoggerComponent.LogEvent(
"RecordDeleted",
"PurchaseOrderDetailScreen",
"PurchaseOrders",
Text(RecordToDelete.cr_purchaseorderid),
RecordToDelete.cr_ponumber,
"",
RecordSnapshot,
"",
"Success",
"",
JSON({DeletedBy: gv_CurrentUser.Email, Reason: txtDeleteReason.Text})
);
// Now execute the deletion
IfError(
Remove(PurchaseOrders, RecordToDelete),
Notify("Deletion failed. Record has been preserved.", NotificationType.Error)
);
Navigate(PurchaseOrderListScreen)
);
Note that we log the deletion with "Success" status before the Remove() call executes. This is intentional — if the system crashes or loses connection immediately after the delete but before the log writes, you'd have no record. By logging first, you ensure the deletion is documented even in edge cases.
For bulk operations, the pattern changes slightly. If you're using ForAll() to delete or update multiple records, log the bulk operation as a single event with metadata rather than individual events per record — otherwise you'll flood your audit log and hit Dataverse write throttles:
// Bulk status update
With(
{
AffectedIDs: Concat(
Filter(PurchaseOrders, cr_status = 'cr_status'.PendingApproval),
Text(cr_purchaseorderid) & ";",
cr_purchaseorderid
),
AffectedCount: CountIf(PurchaseOrders, cr_status = 'cr_status'.PendingApproval)
},
AuditLoggerComponent.LogEvent(
"BulkOperation",
"AdminBulkUpdateScreen",
"PurchaseOrders",
"",
"Bulk Status Change",
"Status",
"PendingApproval",
"Approved",
"Success",
"",
JSON({RecordCount: AffectedCount, Operation: "StatusBulkApprove"})
);
ForAll(
Filter(PurchaseOrders, cr_status = 'cr_status'.PendingApproval),
Patch(
PurchaseOrders,
ThisRecord,
{cr_status: 'cr_status'.Approved}
)
)
);
Warning:
ForAll()withPatch()does not execute sequentially by default in Canvas Apps — it runs in parallel, and the number of concurrent operations is limited. For large datasets, you may hit Dataverse API rate limits. See Canvas App Bulk Data Operations: Multi-Record Patch, ForAll Processing, and Progress Tracking for Large Dataset Updates for the right approach to managing this at scale.
One of the major advantages of formula-level logging over platform auditing is that you can query it from within the app and show it to end users — managers, compliance officers, or record owners — without them needing admin access.
Add a "Change History" screen to your app. It should include a date-range filter and a record ID filter. The gallery formula:
// Gallery.Items for the ChangeHistory screen
Sort(
Filter(
AuditLog,
cr_recordid = Text(gv_SelectedPurchaseOrder.cr_purchaseorderid)
&& cr_timestamp >= datePickerStart.SelectedDate
&& cr_timestamp <= DateAdd(datePickerEnd.SelectedDate, 1, TimeUnit.Days)
),
cr_timestamp,
SortOrder.Descending
)
For each gallery row, display:
Text(ThisItem.cr_timestamp, "mmm dd yyyy hh:mm:ss")ThisItem.cr_oldvalue & " → " & ThisItem.cr_newvalue for update events)This creates a human-readable timeline that looks like:
Mar 15 2024 14:32:07 — Jane Smith (EditPurchaseOrderScreen)
QuantityOrdered: 500 → 50
For a broader compliance view — all events across all records — add a separate admin-only screen filtered by user, date range, and event type. This is where your Power Apps Security: Roles, Sharing, and Data Permissions knowledge comes in: restrict this screen to compliance officer roles only, and log access to it as a RecordViewed event in the audit log itself (yes, audit the auditors).
Tip: Because the AuditLog table will grow quickly, add delegation-aware filtering using indexed columns. Mark
cr_timestampandcr_recordidas searchable in Dataverse. TheFilter()formula above oncr_timestamp >= datePickerStart.SelectedDatewill delegate correctly to Dataverse for these columns, so you won't hit the 500/2000 row delegation limit. Review Canvas App Delegation Deep Dive: Rewriting Non-Delegable Queries with Named Formulas, Explicit Column Selection, and Server-Side Filtering Patterns if you need to build more complex filtered queries against this table.
Every audit log write is a synchronous Dataverse API call from the app formula. This has real implications for perceived performance.
Batch audit writes using a queue collection. Instead of immediately patching Dataverse on every event, collect audit events into an in-memory collection and flush the collection periodically (on navigation, on screen exit, or every 10 events). This reduces the number of round-trips during high-frequency interactions:
// Instead of immediate Patch, add to queue
Collect(
col_AuditQueue,
{
EventType: "RecordViewed",
ScreenName: "PurchaseOrderDetailScreen",
// ... other fields
Timestamp: Now()
}
);
// Flush function — call on screen exit or every N events
If(
CountRows(col_AuditQueue) >= 10 || gv_FlushAuditQueue,
ForAll(
col_AuditQueue,
Patch(
AuditLog,
Defaults(AuditLog),
{
cr_eventtype: // map ThisRecord.EventType to choice value
cr_screenname: ThisRecord.ScreenName,
// ... other fields
cr_timestamp: ThisRecord.Timestamp
}
)
);
Clear(col_AuditQueue)
);
Warning: The queuing approach has one significant risk: if the user closes the app before the flush executes, queued events are lost. For compliance-critical events — deletions, data changes, authorization failures — always write immediately to Dataverse, synchronously, without queuing. Reserve the queue approach for read events and navigation events that are lower stakes.
Use Power Automate for high-volume or complex logging. For apps with hundreds of concurrent users or complex logging requirements (sending to SIEM systems, enriching with Azure AD attributes, writing to multiple destinations), trigger a Power Automate flow for each audit event rather than writing directly from the app. The flow can handle retries, batching, and fan-out. The downside is latency — flow triggers add 5-30 seconds of delay — which is acceptable for audit logs since they're not user-facing in real time.
The decision framework:
Build a Contract Review App with the following specifications. This exercise will cement every concept from the lesson.
Setup:
Contracts with columns: ContractNumber (text), ContractValue (currency), StartDate (date), Status (choice: Draft/UnderReview/Approved/Rejected), ReviewerNotes (multiline text).AuditLog table as specified in this lesson.Build the app:
Screen 1 — Contract List:
RecordViewed, navigate to Contract Detail screenNavigationEvent, navigate to New Contract screenScreen 2 — Contract Detail:
gv_EditingRecord with original values, navigate to Edit screenRecordDeleted with full JSON snapshot before removingScreen 3 — Contract Edit:
Patch(), log field-level changes on success, log failure with error message on failureNavigationEvent with AdditionalContext: {Action: "EditCancelled"}Screen 4 — Change History:
Stretch goal: Add a role check so that only users with "Contract Manager" in their job title (from User().FullName — or fake it with a toggle for testing) can see the Change History screen. Log the AuthorizationFailure when a non-manager tries to access it.
Mistake 1: Logging in the wrong place — formula fires multiple times
The OnVisible property of a screen fires every time the screen becomes visible — including when the user navigates back to it. If you're logging RecordViewed in OnVisible, you'll get duplicate entries every time the user returns to the screen. Fix this with a guard variable:
If(
!gv_ContractDetailLogged,
AuditLoggerComponent.LogEvent("RecordViewed", ...);
Set(gv_ContractDetailLogged, true)
);
// Reset this variable when you first navigate TO the screen (from the source),
// not when you're already on it.
Mistake 2: Forgetting to handle null/blank old values
When you log a field change and the old value is blank (new record creation), using Text(BlankValue) returns an empty string which is fine — but comparing BlankValue <> "SomeText" will behave unexpectedly. Use IsBlank() checks in your diff logic:
If(
!IsBlank(gv_EditingRecord) &&
Coalesce(Value(txtContractValue.Text), 0) <> Coalesce(gv_EditingRecord.OriginalValues.ContractValue, 0),
// Log the change
);
Mistake 3: Choice column syntax errors
The 'cr_eventtype (AuditLog)'.RecordCreated syntax is specific to Dataverse choice columns in Canvas Apps. If you get formula errors, check that: (a) the choice value's display name exactly matches what you're referencing, (b) the table name in parentheses is the schema name of the table, not the display name, and (c) your publisher prefix is correct.
Mistake 4: The audit log grows without bounds
Audit logs are forever by default. Without a retention policy, you'll hit Dataverse storage limits within months on an active app. Create a scheduled Power Automate flow (daily, off-hours) that deletes AuditLog records older than your retention requirement (90 days, 7 years, whatever your compliance standard mandates). Archive them to Azure Blob Storage or a Data Lake first if you need long-term preservation.
Mistake 5: Exposing the audit log to users with too-broad permissions
The AuditLog table in Dataverse has organization ownership, which means any user with read access to the table can read all audit events — including events from other users. Configure Dataverse column-level security or use row-level security (via Dataverse security roles scoped to business unit) to ensure users can only read their own events, while compliance officers see everything. This is covered in depth in Power Apps Security: Roles, Sharing, and Data Permissions.
Debugging tip: Use the Power Apps Monitor tool to observe audit log Patch() calls in real time. You can see whether they're succeeding, how long they're taking, and what payload they're sending to Dataverse. This is invaluable for verifying that your logging is firing at the right moments. The Debugging Canvas Apps: Using the Power Apps Monitor Tool and Formula Errors to Fix Issues Fast lesson walks through Monitor in detail if you haven't used it.
You now have a complete, production-ready formula-level audit logging system. Let's recap the architecture:
The most important principle underlying all of this: audit logging is infrastructure, not a feature. It needs to be invisible to users, reliable under all conditions, and rich enough to be useful when something actually goes wrong — which is, unfortunately, always when you least expect it.
Where to go next: