cds-caching 3.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -26,15 +26,17 @@ class CacheStatisticsHandler {
26
26
  persistenceInterval: 10 * 1000, // 10 seconds
27
27
  maxLatencies: 2000,
28
28
  maxKeyMetrics: 1000, // Track top accessed keys
29
+ maxTagMetrics: 1000, // Track top accessed tags
29
30
  maxMetricFieldLength: MAX_FIELD_LENGTH, // Cap per free-text KeyMetrics field
30
31
  keyMetricsEnabled: false,
32
+ tagMetricsEnabled: false,
31
33
  metricsEnabled: false, // Main metrics enabled flag (process default)
32
34
  ...options
33
35
  };
34
36
 
35
37
  /** @type {Map<string, object>} */
36
38
  this._buckets = new Map();
37
- /** @type {Map<string, { metricsEnabled?: boolean, keyMetricsEnabled?: boolean }>} */
39
+ /** @type {Map<string, { metricsEnabled?: boolean, keyMetricsEnabled?: boolean, tagMetricsEnabled?: boolean }>} */
38
40
  this._overlays = new Map();
39
41
  /** Throwaway bucket for MTX writes outside a tenant context (discarded). */
40
42
  this._discardBucket = null;
@@ -71,6 +73,7 @@ class CacheStatisticsHandler {
71
73
  nativeErrors: 0,
72
74
  totalNativeOperations: 0,
73
75
  keyAccess: new Map(),
76
+ tagAccess: new Map(),
74
77
  startTime: Date.now(),
75
78
  lastReset: Date.now()
76
79
  };
@@ -134,13 +137,22 @@ class CacheStatisticsHandler {
134
137
  return this.options.keyMetricsEnabled === true;
135
138
  }
136
139
 
140
+ _tagMetricsOn(tenantId) {
141
+ const key = tenantId ?? this._bucketKey();
142
+ if (key && this._overlays.has(key)) {
143
+ const overlay = this._overlays.get(key);
144
+ if (overlay.tagMetricsEnabled !== undefined) return overlay.tagMetricsEnabled;
145
+ }
146
+ return this.options.tagMetricsEnabled === true;
147
+ }
148
+
137
149
  _isBucketEmpty(bucket) {
138
150
  const c = bucket?.current;
139
151
  if (!c) return true;
140
152
  return c.hits === 0 && c.misses === 0 && c.errors === 0
141
153
  && c.nativeSets === 0 && c.nativeGets === 0 && c.nativeDeletes === 0
142
154
  && c.nativeClears === 0 && c.nativeDeleteByTags === 0 && c.nativeErrors === 0
143
- && c.keyAccess.size === 0;
155
+ && c.keyAccess.size === 0 && c.tagAccess.size === 0;
144
156
  }
145
157
 
146
158
  /**
@@ -153,7 +165,7 @@ class CacheStatisticsHandler {
153
165
  try {
154
166
  if (isMultitenantMode()) {
155
167
  await this.persistAllTenantMetrics();
156
- } else if (this._metricsOn(DEFAULT_TENANT) || this._keyMetricsOn(DEFAULT_TENANT)) {
168
+ } else if (this._metricsOn(DEFAULT_TENANT) || this._keyMetricsOn(DEFAULT_TENANT) || this._tagMetricsOn(DEFAULT_TENANT)) {
157
169
  await this.persistMetrics();
158
170
  }
159
171
  } catch (error) {
@@ -184,6 +196,17 @@ class CacheStatisticsHandler {
184
196
  }
185
197
  }
186
198
 
199
+ enableTagMetrics(enabled) {
200
+ if (isMultitenantMode() && hasTenantContext()) {
201
+ const t = currentTenant();
202
+ const overlay = this._overlays.get(t) || {};
203
+ overlay.tagMetricsEnabled = enabled;
204
+ this._overlays.set(t, overlay);
205
+ } else {
206
+ this.options.tagMetricsEnabled = enabled;
207
+ }
208
+ }
209
+
187
210
  enableMetrics(enabled) {
188
211
  if (isMultitenantMode() && hasTenantContext()) {
189
212
  const t = currentTenant();
@@ -218,11 +241,16 @@ class CacheStatisticsHandler {
218
241
  this.recordKeyAccess(key, 'hit', { ...metadata, latency });
219
242
  }
220
243
 
244
+ // Record tag access if tag metrics is enabled (independent of main metrics)
245
+ if (this._tagMetricsOn()) {
246
+ this._recordTagsAccess(metadata.tags, 'hit', { ...metadata, latency });
247
+ }
248
+
221
249
  // Emit OTel metric (independent of metricsEnabled)
222
250
  telemetry.recordHit(latency, { 'cache.name': this.options.cache });
223
251
 
224
252
  // Log for debugging
225
- this.log.debug(`Recorded HIT for key: ${key}, latency: ${latency}ms, enabled: ${this._metricsOn()}, keyMetrics: ${this._keyMetricsOn()}`);
253
+ this.log.debug(`Recorded HIT for key: ${key}, latency: ${latency}ms, enabled: ${this._metricsOn()}, keyMetrics: ${this._keyMetricsOn()}, tagMetrics: ${this._tagMetricsOn()}`);
226
254
  }
227
255
 
228
256
  /**
@@ -248,11 +276,16 @@ class CacheStatisticsHandler {
248
276
  this.recordKeyAccess(key, 'miss', { ...metadata, latency });
249
277
  }
250
278
 
279
+ // Record tag access if tag metrics is enabled (independent of main metrics)
280
+ if (this._tagMetricsOn()) {
281
+ this._recordTagsAccess(metadata.tags, 'miss', { ...metadata, latency });
282
+ }
283
+
251
284
  // Emit OTel metric (independent of metricsEnabled)
252
285
  telemetry.recordMiss(latency, { 'cache.name': this.options.cache });
253
286
 
254
287
  // Log for debugging
255
- this.log.debug(`Recorded MISS for key: ${key}, latency: ${latency}ms, enabled: ${this._metricsOn()}, keyMetrics: ${this._keyMetricsOn()}`);
288
+ this.log.debug(`Recorded MISS for key: ${key}, latency: ${latency}ms, enabled: ${this._metricsOn()}, keyMetrics: ${this._keyMetricsOn()}, tagMetrics: ${this._tagMetricsOn()}`);
256
289
  }
257
290
 
258
291
  /**
@@ -279,11 +312,15 @@ class CacheStatisticsHandler {
279
312
  this.recordKeyAccess(key, 'set', { ...metadata, latency });
280
313
  }
281
314
 
315
+ if (this._tagMetricsOn()) {
316
+ this._recordTagsAccess(metadata.tags, 'set', { ...metadata, latency });
317
+ }
318
+
282
319
  // Emit OTel metric (independent of metricsEnabled)
283
320
  telemetry.recordSet({ 'cache.name': this.options.cache });
284
321
 
285
322
  // Log for debugging
286
- this.log.debug(`Recorded SET for key: ${key}, latency: ${latency}ms, enabled: ${this._metricsOn()}, keyMetrics: ${this._keyMetricsOn()}`);
323
+ this.log.debug(`Recorded SET for key: ${key}, latency: ${latency}ms, enabled: ${this._metricsOn()}, keyMetrics: ${this._keyMetricsOn()}, tagMetrics: ${this._tagMetricsOn()}`);
287
324
  }
288
325
 
289
326
  /**
@@ -310,11 +347,15 @@ class CacheStatisticsHandler {
310
347
  this.recordKeyAccess(key, 'delete', { ...metadata, latency });
311
348
  }
312
349
 
350
+ if (this._tagMetricsOn()) {
351
+ this._recordTagsAccess(metadata.tags, 'delete', { ...metadata, latency });
352
+ }
353
+
313
354
  // Emit OTel metric (independent of metricsEnabled)
314
355
  telemetry.recordDelete({ 'cache.name': this.options.cache });
315
356
 
316
357
  // Log for debugging
317
- this.log.debug(`Recorded DELETE for key: ${key}, latency: ${latency}ms, enabled: ${this._metricsOn()}, keyMetrics: ${this._keyMetricsOn()}`);
358
+ this.log.debug(`Recorded DELETE for key: ${key}, latency: ${latency}ms, enabled: ${this._metricsOn()}, keyMetrics: ${this._keyMetricsOn()}, tagMetrics: ${this._tagMetricsOn()}`);
318
359
  }
319
360
 
320
361
  /**
@@ -341,11 +382,15 @@ class CacheStatisticsHandler {
341
382
  this.recordKeyAccess(key, 'nativeSet', metadata);
342
383
  }
343
384
 
385
+ if (this._tagMetricsOn()) {
386
+ this._recordTagsAccess(metadata.tags, 'nativeSet', metadata);
387
+ }
388
+
344
389
  // Emit OTel metric (independent of metricsEnabled)
345
390
  telemetry.recordSet({ 'cache.name': this.options.cache });
346
391
 
347
392
  // Log for debugging
348
- this.log.debug(`Recorded NATIVE SET for key: ${key}, enabled: ${this._metricsOn()}, keyMetrics: ${this._keyMetricsOn()}`);
393
+ this.log.debug(`Recorded NATIVE SET for key: ${key}, enabled: ${this._metricsOn()}, keyMetrics: ${this._keyMetricsOn()}, tagMetrics: ${this._tagMetricsOn()}`);
349
394
  }
350
395
 
351
396
  /**
@@ -372,8 +417,12 @@ class CacheStatisticsHandler {
372
417
  this.recordKeyAccess(key, hit ? 'nativeHit' : 'nativeMiss', metadata);
373
418
  }
374
419
 
420
+ if (this._tagMetricsOn()) {
421
+ this._recordTagsAccess(metadata.tags, hit ? 'nativeHit' : 'nativeMiss', metadata);
422
+ }
423
+
375
424
  // Log for debugging
376
- this.log.debug(`Recorded NATIVE GET for key: ${key}, hit: ${hit}, enabled: ${this._metricsOn()}, keyMetrics: ${this._keyMetricsOn()}`);
425
+ this.log.debug(`Recorded NATIVE GET for key: ${key}, hit: ${hit}, enabled: ${this._metricsOn()}, keyMetrics: ${this._keyMetricsOn()}, tagMetrics: ${this._tagMetricsOn()}`);
377
426
  }
378
427
 
379
428
  /**
@@ -400,11 +449,15 @@ class CacheStatisticsHandler {
400
449
  this.recordKeyAccess(key, 'nativeDelete', metadata);
401
450
  }
402
451
 
452
+ if (this._tagMetricsOn()) {
453
+ this._recordTagsAccess(metadata.tags, 'nativeDelete', metadata);
454
+ }
455
+
403
456
  // Emit OTel metric (independent of metricsEnabled)
404
457
  telemetry.recordDelete({ 'cache.name': this.options.cache });
405
458
 
406
459
  // Log for debugging
407
- this.log.debug(`Recorded NATIVE DELETE for key: ${key}, enabled: ${this._metricsOn()}, keyMetrics: ${this._keyMetricsOn()}`);
460
+ this.log.debug(`Recorded NATIVE DELETE for key: ${key}, enabled: ${this._metricsOn()}, keyMetrics: ${this._keyMetricsOn()}, tagMetrics: ${this._tagMetricsOn()}`);
408
461
  }
409
462
 
410
463
  /**
@@ -748,6 +801,157 @@ class CacheStatisticsHandler {
748
801
  }
749
802
  }
750
803
 
804
+ /**
805
+ * Normalize tags from metadata into a deduped list of resolved tag strings.
806
+ * @param {string[]|undefined} tags
807
+ * @returns {string[]}
808
+ * @private
809
+ */
810
+ _normalizeTags(tags) {
811
+ if (!Array.isArray(tags) || tags.length === 0) return [];
812
+ const seen = new Set();
813
+ const out = [];
814
+ for (const tag of tags) {
815
+ if (typeof tag !== 'string' || !tag) continue;
816
+ if (seen.has(tag)) continue;
817
+ seen.add(tag);
818
+ out.push(tag);
819
+ }
820
+ return out;
821
+ }
822
+
823
+ /**
824
+ * Record access for every tag on a metadata object.
825
+ * @param {string[]|undefined} tags
826
+ * @param {string} operation
827
+ * @param {object} metadata
828
+ * @private
829
+ */
830
+ _recordTagsAccess(tags, operation, metadata = {}) {
831
+ for (const tag of this._normalizeTags(tags)) {
832
+ this.recordTagAccess(tag, operation, metadata);
833
+ }
834
+ }
835
+
836
+ /**
837
+ * Record tag access with latency tracking.
838
+ * A hit on an entry with N tags increments N tag rows — tag totals may exceed cache totals.
839
+ * @param {string} tag - resolved tag string
840
+ * @param {string} operation - hit|miss|set|delete|nativeHit|nativeMiss|nativeSet|nativeDelete
841
+ * @param {object} metadata
842
+ */
843
+ recordTagAccess(tag, operation, metadata = {}) {
844
+ if (!tag) {
845
+ this.log.warn('recordTagAccess called with null/undefined tag');
846
+ return;
847
+ }
848
+
849
+ if (!this.stats.current.tagAccess.has(tag)) {
850
+ this.stats.current.tagAccess.set(tag, {
851
+ tag,
852
+ hits: 0,
853
+ misses: 0,
854
+ sets: 0,
855
+ deletes: 0,
856
+ errors: 0,
857
+ totalRequests: 0,
858
+ hitRatio: 0,
859
+ cacheEfficiency: 0,
860
+ hitLatencies: [],
861
+ missLatencies: [],
862
+ setLatencies: [],
863
+ deleteLatencies: [],
864
+ avgHitLatency: 0,
865
+ avgMissLatency: 0,
866
+ avgReadThroughLatency: 0,
867
+ minHitLatency: Infinity,
868
+ maxHitLatency: 0,
869
+ minMissLatency: Infinity,
870
+ maxMissLatency: 0,
871
+ throughput: 0,
872
+ errorRate: 0,
873
+ nativeHits: 0,
874
+ nativeMisses: 0,
875
+ nativeSets: 0,
876
+ nativeDeletes: 0,
877
+ nativeErrors: 0,
878
+ totalNativeOperations: 0,
879
+ nativeThroughput: 0,
880
+ nativeErrorRate: 0,
881
+ lastAccess: Date.now(),
882
+ timestamp: Date.now(),
883
+ });
884
+ }
885
+
886
+ const tagStats = this.stats.current.tagAccess.get(tag);
887
+ tagStats.lastAccess = Date.now();
888
+
889
+ switch (operation) {
890
+ case 'hit':
891
+ case 'miss':
892
+ case 'set':
893
+ case 'delete': {
894
+ const pluralMap = { hit: 'hits', miss: 'misses', set: 'sets', delete: 'deletes' };
895
+ const prop = pluralMap[operation];
896
+ if (prop) tagStats[prop]++;
897
+ tagStats.totalRequests = tagStats.hits + tagStats.misses;
898
+ if (metadata.latency !== undefined) {
899
+ this.recordKeyLatency(tagStats, operation, metadata.latency);
900
+ }
901
+ break;
902
+ }
903
+ case 'nativeHit':
904
+ case 'nativeMiss':
905
+ case 'nativeSet':
906
+ case 'nativeDelete': {
907
+ const nativeProperty = {
908
+ nativeHit: 'nativeHits',
909
+ nativeMiss: 'nativeMisses',
910
+ nativeSet: 'nativeSets',
911
+ nativeDelete: 'nativeDeletes',
912
+ }[operation];
913
+ if (!nativeProperty) return;
914
+ tagStats[nativeProperty]++;
915
+ tagStats.totalNativeOperations = tagStats.nativeHits + tagStats.nativeMisses + tagStats.nativeSets + tagStats.nativeDeletes;
916
+ break;
917
+ }
918
+ default:
919
+ this.log.warn(`Unknown tag operation type: ${operation}`);
920
+ return;
921
+ }
922
+
923
+ const totalReadThrough = tagStats.hits + tagStats.misses;
924
+ if (totalReadThrough > 0) {
925
+ tagStats.hitRatio = (tagStats.hits / totalReadThrough) * 100;
926
+ }
927
+
928
+ const uptimeMs = Date.now() - tagStats.timestamp;
929
+ if (uptimeMs > 0) {
930
+ tagStats.throughput = (tagStats.totalRequests / uptimeMs) * 1000;
931
+ tagStats.nativeThroughput = (tagStats.totalNativeOperations / uptimeMs) * 1000;
932
+ }
933
+
934
+ if (tagStats.totalRequests > 0) {
935
+ tagStats.errorRate = (tagStats.errors / tagStats.totalRequests) * 100;
936
+ }
937
+ if (tagStats.totalNativeOperations > 0) {
938
+ tagStats.nativeErrorRate = (tagStats.nativeErrors / tagStats.totalNativeOperations) * 100;
939
+ }
940
+ if (tagStats.avgHitLatency > 0 && tagStats.avgMissLatency > 0) {
941
+ tagStats.cacheEfficiency = tagStats.avgMissLatency / tagStats.avgHitLatency;
942
+ }
943
+
944
+ if (this.stats.current.tagAccess.size > this.options.maxTagMetrics) {
945
+ const entries = Array.from(this.stats.current.tagAccess.entries());
946
+ entries.sort((a, b) => {
947
+ const totalA = a[1].totalRequests + a[1].totalNativeOperations;
948
+ const totalB = b[1].totalRequests + b[1].totalNativeOperations;
949
+ return totalB - totalA;
950
+ });
951
+ this.stats.current.tagAccess = new Map(entries.slice(0, this.options.maxTagMetrics));
952
+ }
953
+ }
954
+
751
955
  /**
752
956
  * Record latency for a specific key operation
753
957
  * @param {object} keyStats - the key statistics object
@@ -999,7 +1203,7 @@ class CacheStatisticsHandler {
999
1203
  */
1000
1204
  async persistMetrics() {
1001
1205
  // Only persist if at least one type of metrics is enabled
1002
- if (!this._metricsOn() && !this._keyMetricsOn()) {
1206
+ if (!this._metricsOn() && !this._keyMetricsOn() && !this._tagMetricsOn()) {
1003
1207
  this.log.debug(`PersistStats called but all statistics are disabled for cache ${this.options.cache}`);
1004
1208
  return;
1005
1209
  }
@@ -1026,6 +1230,7 @@ class CacheStatisticsHandler {
1026
1230
  try {
1027
1231
  await this.persistenceManager.persistHourlyStats(stats, hourlyId, hourlyTimestamp, this._metricsOn());
1028
1232
  await this.persistenceManager.persistKeyMetrics(this.stats.current.keyAccess, this._keyMetricsOn());
1233
+ await this.persistenceManager.persistTagMetrics(this.stats.current.tagAccess, this._tagMetricsOn());
1029
1234
 
1030
1235
  this.resetCurrentStats(tenantKey);
1031
1236
 
@@ -1157,6 +1362,24 @@ class CacheStatisticsHandler {
1157
1362
  return this.stats.current.keyAccess;
1158
1363
  }
1159
1364
 
1365
+ async getTagMetrics(tag, from, to) {
1366
+ if (!this._tagMetricsOn() || !isPluginModelAvailable()) return null;
1367
+ const query = SELECT.from("plugin_cds_caching_TagMetrics")
1368
+ .where({ cache: this.options.cache, tag });
1369
+
1370
+ if (from) query.and({ timestamp: { '>=': from.toISOString() } });
1371
+ if (to) query.and({ timestamp: { '<=': to.toISOString() } });
1372
+
1373
+ query.orderBy({ timestamp: 'desc' });
1374
+
1375
+ return await query;
1376
+ }
1377
+
1378
+ async getCurrentTagMetrics() {
1379
+ if (!this._tagMetricsOn()) return null;
1380
+ return this.stats.current.tagAccess;
1381
+ }
1382
+
1160
1383
  async getCurrentStats() {
1161
1384
  if (!this._metricsOn()) return null;
1162
1385
 
@@ -1259,10 +1482,10 @@ class CacheStatisticsHandler {
1259
1482
  * Manually trigger persistence (for testing and debugging)
1260
1483
  */
1261
1484
  async triggerPersistence() {
1262
- this.log.info(`Manually triggering persistence for cache ${this.options.cache}, enabled: ${this._metricsOn()}, keyMetrics: ${this._keyMetricsOn()}`);
1485
+ this.log.info(`Manually triggering persistence for cache ${this.options.cache}, enabled: ${this._metricsOn()}, keyMetrics: ${this._keyMetricsOn()}, tagMetrics: ${this._tagMetricsOn()}`);
1263
1486
  if (isMultitenantMode()) {
1264
1487
  await this.persistAllTenantMetrics();
1265
- } else if (this._metricsOn() || this._keyMetricsOn()) {
1488
+ } else if (this._metricsOn() || this._keyMetricsOn() || this._tagMetricsOn()) {
1266
1489
  await this.persistMetrics();
1267
1490
  } else {
1268
1491
  this.log.info(`Persistence skipped - all statistics are disabled for cache ${this.options.cache}`);
@@ -1276,6 +1499,7 @@ class CacheStatisticsHandler {
1276
1499
  return {
1277
1500
  metricsEnabled: this._metricsOn(),
1278
1501
  keyMetricsEnabled: this._keyMetricsOn(),
1502
+ tagMetricsEnabled: this._tagMetricsOn(),
1279
1503
  intervalExists: this.persistInterval !== null,
1280
1504
  lastPersisted: this.stats.lastPersisted,
1281
1505
  persistenceInterval: this.options.persistenceInterval,
@@ -1297,6 +1521,19 @@ class CacheStatisticsHandler {
1297
1521
  this.resetCurrentStats();
1298
1522
  }
1299
1523
 
1524
+ async clearTagMetrics() {
1525
+ if (isPluginModelAvailable()) {
1526
+ await this.persistenceManager.deleteTagMetrics();
1527
+ }
1528
+ // Clear only in-memory tag buckets; leave key/cache counters intact
1529
+ for (const bucket of this._buckets.values()) {
1530
+ bucket.current.tagAccess = new Map();
1531
+ }
1532
+ if (this._discardBucket) {
1533
+ this._discardBucket.current.tagAccess = new Map();
1534
+ }
1535
+ }
1536
+
1300
1537
  /**
1301
1538
  * Delete metrics (alias for clearMetrics for compatibility)
1302
1539
  * @returns {Promise<void>}
@@ -1312,6 +1549,14 @@ class CacheStatisticsHandler {
1312
1549
  async deleteKeyMetrics() {
1313
1550
  return this.clearKeyMetrics();
1314
1551
  }
1552
+
1553
+ /**
1554
+ * Delete tag metrics (alias for clearTagMetrics for compatibility)
1555
+ * @returns {Promise<void>}
1556
+ */
1557
+ async deleteTagMetrics() {
1558
+ return this.clearTagMetrics();
1559
+ }
1315
1560
  }
1316
1561
 
1317
1562
  module.exports = CacheStatisticsHandler;
@@ -115,6 +115,23 @@ class RuntimeConfigurationManager {
115
115
  this.log.debug(`Key tracking ${enabled ? 'enabled' : 'disabled'} for cache ${this.cacheName}`);
116
116
  }
117
117
 
118
+ /**
119
+ * Update tag metrics enabled status in database
120
+ * @param {boolean} enabled - Whether tag metrics should be enabled
121
+ */
122
+ async setTagMetricsEnabled(enabled) {
123
+ if (!isPluginModelAvailable()) return;
124
+ if (isMultitenantMode() && !hasTenantContext()) {
125
+ this.log.debug(`Skipping setTagMetricsEnabled in MTX mode (no tenant context)`);
126
+ return;
127
+ }
128
+ const db = await cds.connect.to('db');
129
+ const { Caches } = cds.entities('plugin.cds_caching');
130
+ await db.update(Caches, this.cacheName).with({ tagMetricsEnabled: enabled });
131
+
132
+ this.log.debug(`Tag tracking ${enabled ? 'enabled' : 'disabled'} for cache ${this.cacheName}`);
133
+ }
134
+
118
135
 
119
136
 
120
137
  /**
@@ -125,7 +142,7 @@ class RuntimeConfigurationManager {
125
142
  const keyManagement = this._resolveKeyManagement();
126
143
 
127
144
  if (!isPluginModelAvailable()) {
128
- return { metricsEnabled: false, keyMetricsEnabled: false, keyManagement, throwOnErrors: this.options.throwOnErrors };
145
+ return { metricsEnabled: false, keyMetricsEnabled: false, tagMetricsEnabled: false, keyManagement, throwOnErrors: this.options.throwOnErrors };
129
146
  }
130
147
 
131
148
  // In MTX mode without tenant context, skip DB access — return defaults
@@ -133,6 +150,7 @@ class RuntimeConfigurationManager {
133
150
  return {
134
151
  metricsEnabled: false,
135
152
  keyMetricsEnabled: false,
153
+ tagMetricsEnabled: false,
136
154
  keyManagement,
137
155
  throwOnErrors: this.options.throwOnErrors
138
156
  };
@@ -146,6 +164,7 @@ class RuntimeConfigurationManager {
146
164
  return {
147
165
  metricsEnabled: cacheConfig?.metricsEnabled === true || cacheConfig?.metricsEnabled === 1 || false,
148
166
  keyMetricsEnabled: cacheConfig?.keyMetricsEnabled === true || cacheConfig?.keyMetricsEnabled === 1 || false,
167
+ tagMetricsEnabled: cacheConfig?.tagMetricsEnabled === true || cacheConfig?.tagMetricsEnabled === 1 || false,
149
168
  keyManagement,
150
169
  throwOnErrors: this.options.throwOnErrors
151
170
  };
@@ -154,6 +173,7 @@ class RuntimeConfigurationManager {
154
173
  return {
155
174
  metricsEnabled: false,
156
175
  keyMetricsEnabled: false,
176
+ tagMetricsEnabled: false,
157
177
  keyManagement,
158
178
  throwOnErrors: this.options.throwOnErrors
159
179
  };
@@ -77,6 +77,31 @@ class StatisticsPersistenceManager {
77
77
  }
78
78
  }
79
79
 
80
+ /**
81
+ * Persist tag metrics
82
+ * @param {Map} tagAccess - Tag access data
83
+ * @param {boolean} tagMetricsEnabled - Whether tag metrics are enabled
84
+ */
85
+ async persistTagMetrics(tagAccess, tagMetricsEnabled) {
86
+ if (!tagMetricsEnabled || tagAccess.size === 0 || !isPluginModelAvailable()) return;
87
+
88
+ if (isMultitenantMode() && !hasTenantContext()) {
89
+ this.log.debug(`Skipping tag metrics persistence in MTX mode (no tenant context)`);
90
+ return;
91
+ }
92
+
93
+ this.log.debug(`Persisting ${tagAccess.size} tag access records for cache ${this.cacheName}`);
94
+
95
+ const { TagMetrics } = cds.entities('plugin.cds_caching');
96
+ for (const [tag, tagStats] of tagAccess) {
97
+ try {
98
+ await this._persistTagMetric(TagMetrics, tag, tagStats);
99
+ } catch (error) {
100
+ this.log.error(`Failed to persist tag metric for tag ${tag}:`, error);
101
+ }
102
+ }
103
+ }
104
+
80
105
  /**
81
106
  * Delete metrics for this cache
82
107
  */
@@ -107,6 +132,21 @@ class StatisticsPersistenceManager {
107
132
  this.log.debug(`Deleted key metrics for cache ${this.cacheName}`);
108
133
  }
109
134
 
135
+ /**
136
+ * Delete tag metrics for this cache
137
+ */
138
+ async deleteTagMetrics() {
139
+ if (!isPluginModelAvailable()) return;
140
+ if (isMultitenantMode() && !hasTenantContext()) {
141
+ this.log.debug(`Skipping deleteTagMetrics in MTX mode (no tenant context)`);
142
+ return;
143
+ }
144
+ const db = await cds.connect.to('db');
145
+ const { TagMetrics } = cds.entities('plugin.cds_caching');
146
+ await db.delete(TagMetrics).where({ cache: this.cacheName });
147
+ this.log.debug(`Deleted tag metrics for cache ${this.cacheName}`);
148
+ }
149
+
110
150
  /**
111
151
  * Create new hourly stats record
112
152
  * @private
@@ -235,7 +275,7 @@ class StatisticsPersistenceManager {
235
275
  // Calculate read-through performance metrics
236
276
  if (totalRequests > 0) {
237
277
  updatedStats.hitRatio = (updatedStats.hits / totalRequests) * 100;
238
- updatedStats.throughput = totalRequests / (stats.uptimeMs / 1000);
278
+ updatedStats.throughput = stats.uptimeMs > 0 ? totalRequests / (stats.uptimeMs / 1000) : 0;
239
279
  updatedStats.errorRate = (updatedStats.errors / totalRequests) * 100;
240
280
  }
241
281
 
@@ -247,7 +287,7 @@ class StatisticsPersistenceManager {
247
287
  // Calculate native function performance metrics
248
288
  const totalNativeOps = updatedStats.totalNativeOperations;
249
289
  if (totalNativeOps > 0) {
250
- updatedStats.nativeThroughput = totalNativeOps / (stats.uptimeMs / 1000);
290
+ updatedStats.nativeThroughput = stats.uptimeMs > 0 ? totalNativeOps / (stats.uptimeMs / 1000) : 0;
251
291
  updatedStats.nativeErrorRate = (updatedStats.nativeErrors / totalNativeOps) * 100;
252
292
  }
253
293
 
@@ -380,9 +420,9 @@ class StatisticsPersistenceManager {
380
420
  : 0,
381
421
 
382
422
  // Update percentiles (use max/min of existing and current, with min using mergeMin)
383
- minHitLatency: mergeMin(Number(existingKey.minHitLatency) || 0, keyStats.minHitLatency),
423
+ minHitLatency: mergeMin(Number(existingKey.minHitLatency) || 0, keyStats.minHitLatency === Infinity ? 0 : keyStats.minHitLatency),
384
424
  maxHitLatency: Math.max(Number(existingKey.maxHitLatency) || 0, keyStats.maxHitLatency || 0),
385
- minMissLatency: mergeMin(Number(existingKey.minMissLatency) || 0, keyStats.minMissLatency),
425
+ minMissLatency: mergeMin(Number(existingKey.minMissLatency) || 0, keyStats.minMissLatency === Infinity ? 0 : keyStats.minMissLatency),
386
426
  maxMissLatency: Math.max(Number(existingKey.maxMissLatency) || 0, keyStats.maxMissLatency || 0)
387
427
  };
388
428
 
@@ -399,6 +439,99 @@ class StatisticsPersistenceManager {
399
439
  .set(updatedKeyStats)
400
440
  .where({ ID: keyId, cache: this.cacheName, keyName: key });
401
441
  }
442
+
443
+ /**
444
+ * Persist a single tag metric
445
+ * @private
446
+ */
447
+ async _persistTagMetric(TagMetrics, tag, tagStats) {
448
+ const tagId = `tag:${this.cacheName}:${tag}`;
449
+
450
+ const existingTag = await SELECT.one.from(TagMetrics)
451
+ .where({ ID: tagId, cache: this.cacheName, tag });
452
+
453
+ if (!existingTag) {
454
+ await this._createTagMetric(TagMetrics, tag, tagStats, tagId);
455
+ } else {
456
+ await this._updateTagMetric(TagMetrics, tag, tagStats, tagId, existingTag);
457
+ }
458
+ }
459
+
460
+ /**
461
+ * Create new tag metric record
462
+ * @private
463
+ */
464
+ async _createTagMetric(TagMetrics, tag, tagStats, tagId) {
465
+ await INSERT.into(TagMetrics).entries([{
466
+ ID: tagId,
467
+ cache: this.cacheName,
468
+ tag,
469
+ lastAccess: new Date(tagStats.lastAccess).toISOString(),
470
+ period: 'current',
471
+ hits: tagStats.hits,
472
+ misses: tagStats.misses,
473
+ errors: tagStats.errors,
474
+ totalRequests: tagStats.totalRequests,
475
+ hitRatio: tagStats.hitRatio,
476
+ cacheEfficiency: tagStats.cacheEfficiency,
477
+ avgHitLatency: tagStats.avgHitLatency || 0,
478
+ minHitLatency: tagStats.minHitLatency === Infinity ? 0 : tagStats.minHitLatency,
479
+ maxHitLatency: tagStats.maxHitLatency || 0,
480
+ avgMissLatency: tagStats.avgMissLatency || 0,
481
+ minMissLatency: tagStats.minMissLatency === Infinity ? 0 : tagStats.minMissLatency,
482
+ maxMissLatency: tagStats.maxMissLatency || 0,
483
+ avgReadThroughLatency: tagStats.avgReadThroughLatency || 0,
484
+ throughput: tagStats.throughput || 0,
485
+ errorRate: tagStats.errorRate || 0,
486
+ nativeHits: tagStats.nativeHits || 0,
487
+ nativeMisses: tagStats.nativeMisses || 0,
488
+ nativeSets: tagStats.nativeSets || 0,
489
+ nativeDeletes: tagStats.nativeDeletes || 0,
490
+ nativeErrors: tagStats.nativeErrors || 0,
491
+ totalNativeOperations: tagStats.totalNativeOperations || 0,
492
+ nativeThroughput: tagStats.nativeThroughput || 0,
493
+ nativeErrorRate: tagStats.nativeErrorRate || 0,
494
+ timestamp: new Date(tagStats.timestamp).toISOString(),
495
+ }]);
496
+ }
497
+
498
+ /**
499
+ * Update existing tag metric record
500
+ * @private
501
+ */
502
+ async _updateTagMetric(TagMetrics, tag, tagStats, tagId, existingTag) {
503
+ const totalHits = (Number(existingTag.hits) || 0) + tagStats.hits;
504
+ const totalMisses = (Number(existingTag.misses) || 0) + tagStats.misses;
505
+
506
+ const updatedTagStats = {
507
+ hits: totalHits,
508
+ misses: totalMisses,
509
+ errors: (Number(existingTag.errors) || 0) + (tagStats.errors || 0),
510
+ totalRequests: totalHits + totalMisses,
511
+ lastAccess: new Date(tagStats.lastAccess).toISOString(),
512
+ hitRatio: (totalHits + totalMisses) > 0 ? (totalHits / (totalHits + totalMisses)) * 100 : 0,
513
+ nativeHits: (Number(existingTag.nativeHits) || 0) + (tagStats.nativeHits || 0),
514
+ nativeMisses: (Number(existingTag.nativeMisses) || 0) + (tagStats.nativeMisses || 0),
515
+ nativeSets: (Number(existingTag.nativeSets) || 0) + (tagStats.nativeSets || 0),
516
+ nativeDeletes: (Number(existingTag.nativeDeletes) || 0) + (tagStats.nativeDeletes || 0),
517
+ nativeErrors: (Number(existingTag.nativeErrors) || 0) + (tagStats.nativeErrors || 0),
518
+ totalNativeOperations: (Number(existingTag.totalNativeOperations) || 0) + (tagStats.totalNativeOperations || 0),
519
+ avgHitLatency: totalHits > 0
520
+ ? (((Number(existingTag.avgHitLatency) || 0) * (Number(existingTag.hits) || 0)) + (tagStats.avgHitLatency * tagStats.hits)) / totalHits
521
+ : 0,
522
+ avgMissLatency: totalMisses > 0
523
+ ? (((Number(existingTag.avgMissLatency) || 0) * (Number(existingTag.misses) || 0)) + (tagStats.avgMissLatency * tagStats.misses)) / totalMisses
524
+ : 0,
525
+ minHitLatency: mergeMin(Number(existingTag.minHitLatency) || 0, tagStats.minHitLatency === Infinity ? 0 : tagStats.minHitLatency),
526
+ maxHitLatency: Math.max(Number(existingTag.maxHitLatency) || 0, tagStats.maxHitLatency || 0),
527
+ minMissLatency: mergeMin(Number(existingTag.minMissLatency) || 0, tagStats.minMissLatency === Infinity ? 0 : tagStats.minMissLatency),
528
+ maxMissLatency: Math.max(Number(existingTag.maxMissLatency) || 0, tagStats.maxMissLatency || 0),
529
+ };
530
+
531
+ await UPDATE(TagMetrics)
532
+ .set(updatedTagStats)
533
+ .where({ ID: tagId, cache: this.cacheName, tag });
534
+ }
402
535
  }
403
536
 
404
537
  module.exports = StatisticsPersistenceManager;