Blog Post

Microsoft Mission Critical Blog
7 MIN READ

Archiving Years of Dataverse Audit History Before You Prune It — A Pragmatic, Open Source Pattern

PravinT's avatar
PravinT
Icon for Microsoft rankMicrosoft
Jun 08, 2026

 

Why the audit table is special

The audit table is different from the rest of Dataverse in two ways that matter for an archive design:

  1. It’s an immutable, append-only record of who changed what, when, and from where — the closest thing Dataverse has to a forensic ledger.
  2. The valuable part of an audit row is not the row itself; it’s the diff (old value → new value, attribute mask, related-record context). The audit row stores that diff in a packed changedata column, and the bound RetrieveAuditDetails function is what decodes it into a structured OldValue / NewValue / ChangedAttributes shape your downstream tools can actually query. Synapse Link with the Delta Lake profile will carry the changedata column to the lake, but you still need a parser on the other side; this pattern calls RetrieveAuditDetails at archive-time so what lands in the destination is already decoded and immediately queryable.

That combination makes the audit table the single most useful Dataverse table for:

  • Regulatory and compliance investigations
  • “Why did this opportunity status change in Q3 of 2022?” forensic queries (years after the fact)
  • Internal analytics on user behaviour and process adoption

It’s also the table that grows the fastest. The Dataverse default retention is 90 days, but in practice many enterprises extend that to several years — or set it to never delete — to retain evidence for compliance and forensic review. The result, often after five to seven years, is an audit table holding tens of GB to multiple TB of capacity, dominating the entitlement bill, and rarely accessed in normal operations.

At that point the storage conversation becomes unavoidable. The realistic choices are:

  1. Keep buying entitlement. Predictable, but unbounded.
  2. Move the cold tail somewhere cheaper that you control, then let Dataverse’s audit-deletion job reclaim the space. The hot months stay in Dataverse where users expect them; the years of historical evidence live in your own storage account, queryable when you need them.

This pattern is for option 2 — specifically, for the one-time bulk export of multi-year history, with the option to keep a slow trickle running afterwards if you want to top up.

A crucial point that often gets lost: this pipeline does not need to run live. It is perfectly reasonable to be deliberately months or years behind real-time. The goal is to get a defensible copy of cold data out — the rows you are about to allow Dataverse to delete — not to mirror the audit feed in real time.

What “good” looks like for an external audit copy

Before showing any code, here’s the rubric I held this design to. If you build your own, hold yours to the same rubric:

PropertyWhy it matters
IdempotentRe-running the same time window must not duplicate rows. Network blips happen.
Crash-safe (exactly-once-effective)If the process dies mid-window, the next run must replay the same window cleanly. The watermark advances only after the data is durable in the destination.
Bounded memoryA backlog of millions of audits cannot be loaded all at once.
Backpressure-awareDataverse rate-limits aggressively. Throttle responses must not drop rows.
ObservableEvery window logs [entity] mode=BACKLOG/LIVE, lag=Nmin, window=10min, records=N so you can watch it work.
Sink-agnosticThe “where does it land” decision is config, not code. Storage choices change; the orchestrator shouldn’t.
Field-level discretionAudit details can carry PII. The pattern should let admins narrow which attributes leave the platform.

The pattern

The pipeline is conceptually four stages, repeated per entity, per time window:

Three details in this picture do most of the resiliency work. They are deceptively simple:

Detail 1: Half-open time windows (ge / lt)

The boundary moment (09:10:00.000) belongs to window 2, not window 1. So adjacent windows never overlap and never gap, no matter how many times you replay. This is the same trick Kafka uses for offsets — it’s why you can run the loop with confidence.

Detail 2: The destination document key is the Dataverse auditid GUID

Dataverse already assigns a globally unique GUID to every audit row. That GUID becomes the document id in the sink. So when you upsert the same audit twice, the second write is a no-op overwrite of the first — idempotency for free, no client-side dedupe table to maintain.

Detail 3: The watermark moves after the write, not with it

The naive version of this pipeline does:

The resilient version raises a typed exception when any record fails, and the watermark update is conditional on a clean write:

This single change is the difference between “best effort” and “exactly-once-effective.” It’s also the mistake most often made when people roll their own.

Choosing where it lands

The orchestrator is sink-agnostic — it talks to a single AuditSink interface (get_state, update_state, write_audits) and the destination is a config switch, not a code change. The reference implementation ships with four production-shaped sinks plus a no-op for testing. None of them is the answer; they map to platforms enterprises already operate:

SinkWhen to consider it
Azure Cosmos DB (NoSQL API)Operational lookups — “show me everything user X did to record Y in 2022” in milliseconds. Hierarchical partition keys (/entity + /auditYearMonth) keep partitions small as the archive grows over years. Document TTL doubles as a retention policy if you want one. Serverless mode suits a slow-trickle archive workload.
Azure Data Lake Storage Gen2 (Parquet)The cheap-cold-storage option. Years of audit history land as partitioned Parquet files (entity=…/year=…/month=…/), readable from Fabric notebooks, Synapse Serverless SQL, Databricks, or any Parquet engine. Costs scale with bytes, not throughput — ideal when the archive is rarely queried but must exist.
OneLake (Parquet)Same Parquet shape as ADLS, but landed inside a Microsoft Fabric Lakehouse. Immediately queryable from a Fabric SQL endpoint, notebooks, and Power BI without further plumbing. The natural choice if your downstream BI is Fabric.
Snowflake (MERGE INTO)The natural choice when Snowflake is already the analytics platform of record and adding a separate Microsoft analytics estate just for audit data isn’t on the table. MERGE INTO ... ON audit_id keeps the same idempotency contract as the Cosmos upsert, and the warehouse stays paused between archival batches.
No-op (logs only)First-day connectivity testing. Confirms the Dataverse side works before you provision any storage.

A reasonable default split many enterprises arrive at:

  • ADLS Gen2 / OneLake (or Snowflake, if that’s your platform) holds the durable historical archive — cheap, partitioned, queryable when (rarely) needed.
  • Cosmos DB holds the most recent N months for fast operational lookup if there is a use case for it; otherwise skip it entirely.

Adding a sink for storage you already own (e.g., BigQuery, Redshift, on-prem object storage) is roughly 100 lines of Python and one factory entry.

What real-world numbers will look like

The worked test run above shows what a clean sandbox run looks like. Real numbers in your tenant will vary by orders of magnitude depending on:

  • How many entities have auditing enabled
  • The shape of RetrieveAuditDetails calls (more changed attributes per row = more bytes per call)
  • Dataverse Web API rate limits applicable to your environment
  • Concurrency you allow (max_concurrent_entities in the config)
  • Sink throughput (Cosmos serverless RU autoscale, ADLS upload bandwidth, Snowflake warehouse size)

The useful operational signal is not the absolute throughput — it’s that the throughput is stable and the per-window log lines tick predictably. If they don’t, look at lag, sink errors, or 429 responses from Dataverse before scaling up concurrency.

When to reach for it (and when not to)

Reasonable fit:

  • You have multiple years of accumulated audit history in Dataverse and need to move the cold tail off the platform before pruning to reclaim entitlement.
  • You want the decoded RetrieveAuditDetails payload (old value → new value, attribute mask, related-record context) landing in the destination ready to query — rather than the packed changedata column Synapse Link delivers, which still needs a parser on the consumer side.
  • Your analytics platform of record sits outside Azure (most commonly Snowflake) and you don’t want to add Synapse + ADLS + Spark to your stack just to land the audit table.
  • Synapse Link isn’t an option in your tenant — region pairing, governance review, or the cost floor of running ADLS + a Spark pool 24/7 don’t fit your environment.
  • You’re comfortable running this as a batch job — once for the historical backfill, then perhaps quarterly or annually to top up — rather than as a live continuous feed. Being deliberately months or years behind real-time is fine and often desirable.
  • You want field-level control over which attributes leave the platform — useful when audit details contain regulated data.

Not a good fit:

  • You can run Azure Synapse Link with the Delta Lake profile and you’re happy parsing the packed changedata column on the consumer side, and your destination is ADLS / Synapse / Power BI. That’s the supported, first-class path for the audit table — use it.
  • You only need current state of business tables (account, contact, opportunity). Use Synapse Link / Fabric Link — they do exactly that and you don’t need this pattern.
  • You need sub-second freshness in the destination. The pattern’s natural cadence is one window length (10 min in the reference config); for true real-time, use Dataverse webhooks or change-tracking APIs.
  • You don’t have somewhere to operate a small Python container, function, or scheduled job — even an annual one.
  • You don’t have an internal owner who can be paged when the schedule fails.

The reference implementation

The code that backs this post lives at https://github.com/SweetsNSavories/DataverseAuditLogSyn under MIT, with no warranty. The unified-deployment folder is the version this post describes — single Python codebase, swap sinks via config.json, runs locally / in a container / as an Azure Function.

If you want the implementation depth this post deliberately leaves out — exact API shapes, watermark math, partial-failure drill, sink-author checklist, hosting variants, observability hooks, and the full list of operational responsibilities a self-hosted export carries — it all lives in one place:

Issues, forks, and pull requests welcome via the repo.

Generated 2026-05-14 from https://github.com/SweetsNSavories/DataverseAuditLogSyn/blob/main/docs/blog/archiving-dataverse-audit-logs.md · MIT licensed reference implementation, no warranty.

Published Jun 08, 2026
Version 1.0
No CommentsBe the first to comment