Hi @ismcagdas,
I am working with the ASP.NET Zero ASP.NET Core + Angular application and would like to implement dynamic Entity History tracking.
According to the Entity History documentation, entity types that need to be tracked are added to EntityHistoryHelper.TrackedTypes, and the EntityHistory configuration is then registered through Configuration.EntityHistory.Selectors and EntityHistoryConfigProvider. The Angular application uses abp.custom.EntityHistory to determine which entities support the Entity History functionality.
However, our requirement is slightly different.
We have entities that can be created/configured dynamically, and we do not want to manually add every entity type to EntityHistoryHelper.TrackedTypes and redeploy the application whenever a new entity needs history tracking.
Our requirements are:
Dynamically enable Entity History tracking for an entity at runtime. Automatically record Create, Update, and Delete changes for the dynamically enabled entity. Have the changes available through the standard ASP.NET Zero Audit Logs → Change Logs / Entity History functionality. Have the Angular UI dynamically recognize that the entity supports Entity History and display the History / All Changes option. Avoid hard-coding each entity type in EntityHistoryHelper.TrackedTypes. Ideally, allow administrators to enable or disable Entity History for an entity without modifying source code or redeploying the application.
Could you please advise on the recommended ASP.NET Zero approach for achieving this?
Specifically:
Is there a supported way to dynamically register entity types with Configuration.EntityHistory.Selectors at runtime? Can EntityHistoryHelper.TrackedTypes be populated dynamically, or is it intended to be static? Is there an API/service that we should use to dynamically configure Entity History instead of modifying the module configuration? How should EntityHistoryConfigProvider be implemented if the list of tracked entities is dynamic? How can we ensure that the Angular abp.custom.EntityHistory.enabledEntities configuration is updated correctly for dynamically enabled entities? Is there a recommended implementation or sample code for this scenario?
We would appreciate guidance on the supported ASP.NET Zero architecture for implementing dynamic Entity History rather than creating a custom audit/history mechanism.
Thank you.
2 Answer(s)
-
0
Hi @ismcagdas,
any update?
Markdown is supportedCopy & paste or drag & drop images (max 30 MB per image) -
0
Short answer up front: you don't need to hard code anything. ABP evaluates entity-history selectors at runtime the predicate you register is invoked on every
SaveChanges, for every changed entity type.So instead of registering N static types, you register one selector whose predicate asks your own runtime store.
EntityHistoryHelper.TrackedTypesis ASP.NET Zero template code, not ABP API you are free to replace it.Can selectors be registered dynamically at runtime?
The selector list is built once in
PreInitialize, but the decision is dynamic. Register a single dynamic selector:// *.EntityFrameworkCore/EntityFrameworkCore/AbpZeroTemplateEntityFrameworkCoreModule.cs -> PreInitialize() Configuration.EntityHistory.IsEnabled = true; // master switch, keep it true IDynamicEntityHistoryConfiguration dynamicConfig = null; Configuration.EntityHistory.Selectors.Add(new NamedTypeSelector( "DynamicEntityHistorySelector", type => { // resolved lazily: components are not registered yet during PreInitialize dynamicConfig ??= IocManager.Resolve<IDynamicEntityHistoryConfiguration>(); return dynamicConfig.IsTracked(type); })); Configuration.CustomConfigProviders.Add(new DynamicEntityHistoryConfigProvider());Configuration.EntityHistory.Selectorsis anIEntityHistorySelectorList(a liveList<NamedTypeSelector>withRemoveByName), so you can technically mutate it at runtime but we don't recommend it: it's a singleton startup object, it isn't thread-safe, changes are lost on restart and they never reach the other nodes in a web farm. The single predicate approach has none of these problems.Is
EntityHistoryHelper.TrackedTypesmeant to be static?It is a convenience array that lives in your solution (
*.Core/EntityHistory/EntityHistoryHelper.cs); ABP never reads it. It's referenced in exactly three places, and all three must be replaced for a dynamic setup:AbpZeroTemplateEntityFrameworkCoreModule.PreInitialize→ the selector registration (above)EntityHistoryConfigProvider→ the list sent to the UI (below)AuditLogAppService.GetEntityHistoryObjectTypes()→ this one is easy to miss. It intersects the enabled entities with the staticHostSideTrackedTypes/TenantSideTrackedTypesarrays, so if you skip it the Object filter on Audit Logs → Entity Changes will stay empty for your dynamic entities.
Which API should hold the dynamic list?
There is no built in entity history manager you own the store. We would suggest an ABP setting (
ISettingManager, scopeApplication | Tenant): you get per tenant configuration, built-in caching and an editable value with no migration and no redeploy. A dedicated entity works equally well if you need auditing of the toggles themselves.// *.Core/EntityHistory/DynamicEntityHistoryConfiguration.cs public interface IDynamicEntityHistoryConfiguration { bool IsTracked(Type entityType); List<string> GetEnabledEntityTypeNames(); Task SetEnabledEntityTypeNamesAsync(List<string> entityTypeFullNames); } public class DynamicEntityHistoryConfiguration : IDynamicEntityHistoryConfiguration, ISingletonDependency { public const string SettingName = "App.EntityHistory.EnabledEntities"; // comma separated full names private const string CacheName = "DynamicEntityHistoryCache"; private readonly ISettingManager _settingManager; private readonly IAbpSession _abpSession; // ClaimsAbpSession is ISingletonDependency -> safe here private readonly ICacheManager _cacheManager; public DynamicEntityHistoryConfiguration( ISettingManager settingManager, IAbpSession abpSession, ICacheManager cacheManager) { _settingManager = settingManager; _abpSession = abpSession; _cacheManager = cacheManager; } public bool IsTracked(Type entityType) { return GetEnabledEntityTypeNames().Contains(entityType.FullName); } public List<string> GetEnabledEntityTypeNames() { var tenantId = _abpSession.TenantId; return _cacheManager.GetCache<string, List<string>>(CacheName) .Get(tenantId?.ToString() ?? "host", () => ReadFromSettings(tenantId)); } public async Task SetEnabledEntityTypeNamesAsync(List<string> entityTypeFullNames) { var value = entityTypeFullNames.JoinAsString(","); var tenantId = _abpSession.TenantId; if (tenantId.HasValue) { await _settingManager.ChangeSettingForTenantAsync(tenantId.Value, SettingName, value); } else { await _settingManager.ChangeSettingForApplicationAsync(SettingName, value); } await _cacheManager.GetCache<string, List<string>>(CacheName) .RemoveAsync(tenantId?.ToString() ?? "host"); } private List<string> ReadFromSettings(int? tenantId) { var value = tenantId.HasValue ? _settingManager.GetSettingValueForTenant(SettingName, tenantId.Value) : _settingManager.GetSettingValueForApplication(SettingName); return value.IsNullOrWhiteSpace() ? new List<string>() : value.Split(',', StringSplitOptions.RemoveEmptyEntries).Select(x => x.Trim()).ToList(); } }Define the setting in your
AppSettingProviderwithSettingScopes.Application | SettingScopes.Tenant, then expose a small[AbpAuthorize(...)]app service (GetAvailableEntities/GetEnabledEntities/SetEnabledEntities) for the admin screen.Two rules for the predicate: it runs inside the
SaveChangespipeline for every changed entry, so (a) keep it O(1) and cached, and (b) never open a unit of work / hit a repository there. Use a distributed cache (Redis) or a short TTL if you run multiple instances, since a toggle on node A must invalidate node B.How should
EntityHistoryConfigProviderbe implemented?ICustomConfigProvider.GetConfigis executed per request (on/AbpUserConfiguration/GetAll, and on everyIAbpStartupConfiguration.GetCustomConfig()call), so simply returning the current dynamic list is enough no restart, no redeploy:public class DynamicEntityHistoryConfigProvider : ICustomConfigProvider { public Dictionary<string, object> GetConfig(CustomConfigProviderContext context) { var startupConfiguration = context.IocResolver.Resolve<IAbpStartupConfiguration>(); if (!startupConfiguration.EntityHistory.IsEnabled) { return new Dictionary<string, object> { { EntityHistoryHelper.EntityHistoryConfigurationName, new EntityHistoryUiSetting { IsEnabled = false } } }; } var dynamicConfiguration = context.IocResolver.Resolve<IDynamicEntityHistoryConfiguration>(); return new Dictionary<string, object> { { EntityHistoryHelper.EntityHistoryConfigurationName, new EntityHistoryUiSetting { IsEnabled = true, EnabledEntities = dynamicConfiguration.GetEnabledEntityTypeNames() } } }; } }And update
AuditLogAppService.GetEntityHistoryObjectTypes()to build its list from the same source instead of intersecting withHostSideTrackedTypes/TenantSideTrackedTypes.For the admin screen's "available entities" list, enumerate the EF Core model inside the EntityFrameworkCore project (interface in
.Core, implementation in.EntityFrameworkCore):var candidates = dbContext.Model.GetEntityTypes() .Select(t => t.ClrType) .Where(t => t != null && t.IsPublic && EntityHelper.IsEntity(t)) .Select(t => t.FullName) .Distinct() .ToList();Keeping
abp.custom.EntityHistorycorrect in Angularabp.customis populated once at bootstrap:AppPreBootstrap.getUserConfiguration()calls/AbpUserConfiguration/GetAlland merges the result intoabp. Because the provider above is now dynamic, every fresh page load / login gets the current list automatically. After an admin toggles an entity, the running SPA still holds the old snapshot either reload the page (window.location.reload()) after saving, or read the list from your own app-service endpoint into a root Angular service.Note the casing difference: Angular receives camelCase (
abp.custom.EntityHistory.isEnabled,.enabledEntities) while MVC views use PascalCase seeroles.component.ts:129.Instead of repeating the check in every component, generalize it:
@Injectable({ providedIn: 'root' }) export class EntityHistoryService { isEnabled(entityTypeFullName: string): boolean { const config = (abp as any).custom?.EntityHistory; return !!config?.isEnabled && (config.enabledEntities ?? []).indexOf(entityTypeFullName) >= 0; } }The good news is that nothing else on the Angular side is entity-specific: the route
app/admin/entity-changes/:entityId/:entityTypeFullName,EntityChangesComponent,EntityChangeDetailModalComponentandEntityChangeAppService.GetEntityChangesByEntityall work off the entity type full name, so any dynamically enabled entity gets the History / All changes page for free once your action menu shows the item.Things to watch out for
- The type must be a public CLR class that is part of the EF Core model and an ABP entity.
[DisableAuditing]andConfiguration.EntityHistory.IgnoredTypesstill win over your selector;[Audited]still forces tracking on. Configuration.EntityHistory.IsEnabledis a global master switch evaluated at startup leave ittrueand do all per-entity decisions in the predicate.- Multi-tenancy: the predicate only receives a
Type, so per tenant behaviour must come fromIAbpSession.TenantIdinside your configuration service (as above).ClaimsAbpSessionis registered as a singleton in ABP, so injectingIAbpSessioninto your singleton is safe. - Anonymous changes require
Configuration.EntityHistory.IsEnabledForAnonymousUsers = true. EntityChange.EntityIdis stored JSON serialized that's why AZ'sGetEntityChangesByEntitycompares both the raw and the quoted id. Keep that in mind if you write your own queries.- Storage growth: enabling history on many entities fills
AbpEntityChanges/AbpEntityPropertyChangesquickly. Plan a retention/cleanup job. - Important caveat: if "dynamically created entities" means schema created at runtime with no CLR type (EAV-style / generated tables), ABP entity history cannot track them it works off the EF Core change tracker. In that case either (a) map them to a generic CLR entity (e.g.
DynamicEntityRecordwith a JSON payload column changes are then recorded as changes of that column), (b) use ABP's Dynamic Entity Properties, or (c) implement your ownIEntityHistoryStorewrite path. If your types exist at compile time and only the selection is dynamic (which is what your description sounds like), everything above applies directly.
So no custom audit mechanism needed you keep ABP's
EntityChangeSet/EntityChange/EntityPropertyChangetables and the standard Audit Logs → Entity Changes UI, and only swap the static type array for a runtime-backed predicate.If you share how your dynamic entities are defined (CLR types vs. runtime schema), we can be more specific.
Thank you
Markdown is supportedCopy & paste or drag & drop images (max 30 MB per image)