cds-caching 2.0.1 → 2.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.
- package/README.md +10 -2
- package/app/dashboard/resources/Eventing-preload-0.js +1 -1
- package/app/dashboard/resources/Library-preload.js +1 -1
- package/app/dashboard/resources/sap/ui/Global-dbg.js +1 -1
- package/app/dashboard/resources/sap/ui/Global.js +1 -1
- package/app/dashboard/resources/sap/ui/test/starter/runTest.js +1 -1
- package/app/dashboard/resources/sap-ui-core-nojQuery.js +1 -1
- package/app/dashboard/resources/sap-ui-core.js +1 -1
- package/app/dashboard/resources/sap-ui-custom.js +1 -1
- package/cds-plugin.js +28 -3
- package/index.cds +15 -2
- package/lib/CachingService.js +3 -0
- package/lib/config-normalizer.js +28 -0
- package/lib/dashboard-guard.js +32 -0
- package/lib/operations/CapOperations.js +15 -2
- package/lib/support/CacheStatisticsHandler.js +6 -0
- package/lib/support/StatisticsPersistenceManager.js +54 -52
- package/lib/support/metricsSanitizer.js +84 -0
- package/package.json +1 -1
- package/srv/caching-api-service.js +78 -18
package/README.md
CHANGED
|
@@ -25,6 +25,7 @@ Please also read the introduction blog post: [Boosting performance in SAP Cloud
|
|
|
25
25
|
| [Programmatic API](docs/programmatic-api.md) | Full API reference for cache operations |
|
|
26
26
|
| [Protocol Support](docs/protocols.md) | Caching across OData, REST, GraphQL, HCQL, and MCP |
|
|
27
27
|
| [Key Management](docs/key-management.md) | Key templates, context awareness, custom keys |
|
|
28
|
+
| [Security](docs/security.md) | Authorization, cache key isolation, production checklist |
|
|
28
29
|
| [Metrics Guide](docs/metrics-guide.md) | Statistics, monitoring, and performance tracking |
|
|
29
30
|
| [OpenTelemetry Integration](docs/telemetry.md) | Distributed tracing and metrics export |
|
|
30
31
|
| [OData API](docs/odata-api.md) | REST endpoints for management and monitoring |
|
|
@@ -33,6 +34,7 @@ Please also read the introduction blog post: [Boosting performance in SAP Cloud
|
|
|
33
34
|
| [Deployment Guide](docs/deployment-guide.md) | SAP BTP deployment for Redis, PostgreSQL, HANA, CDS |
|
|
34
35
|
| [Migration Guide](docs/migration-guide.md) | **Upgrading to 2.0** and earlier releases |
|
|
35
36
|
| [Example Application](docs/example-app.md) | Sample app with caching patterns |
|
|
37
|
+
| [Federation Integration](docs/federation.md) | Using cds-caching with [cds-data](https://github.com/mikezaschka/cds-data) |
|
|
36
38
|
|
|
37
39
|
## Getting Started
|
|
38
40
|
|
|
@@ -259,12 +261,14 @@ See **[Feature Activation](docs/feature-activation.md)** for reuse vs own activa
|
|
|
259
261
|
| BTP with HTML5 repo | `cds add caching-metrics` + `metrics.enabled` — no `metrics.reuse.dashboard` |
|
|
260
262
|
| API only, no UI | `metrics.reuse.api` or `using … index.cds` + `metrics.enabled` |
|
|
261
263
|
|
|
262
|
-
Restrict
|
|
264
|
+
The API requires an authenticated user by default. Restrict it to an administrative role with a fully-qualified annotate — do **not** repeat the `using` import:
|
|
263
265
|
|
|
264
266
|
```cds
|
|
265
|
-
annotate plugin.cds_caching.CachingApiService with @requires: '
|
|
267
|
+
annotate plugin.cds_caching.CachingApiService with @requires: 'CacheAdmin';
|
|
266
268
|
```
|
|
267
269
|
|
|
270
|
+
See the [Security Guide](docs/security.md) for the production checklist, including cache key isolation for user-filtered data.
|
|
271
|
+
|
|
268
272
|
## Multi-Tenancy (MTX)
|
|
269
273
|
|
|
270
274
|
cds-caching supports SAP BTP multi-tenant applications using `@sap/cds-mtxs`. When multitenancy is detected, the plugin automatically:
|
|
@@ -436,6 +440,10 @@ To add the monitoring dashboard, use `metrics.reuse.dashboard` for local reuse,
|
|
|
436
440
|
| [Programmatic API](docs/programmatic-api.md) | JavaScript methods for cache operations |
|
|
437
441
|
| [OData API](docs/odata-api.md) | REST endpoints for monitoring and management |
|
|
438
442
|
|
|
443
|
+
## Related Projects
|
|
444
|
+
|
|
445
|
+
[cds-data](https://github.com/mikezaschka/cds-data) provides composable CAP plugins for federation, replication, and materialization. Use `cds-caching` as the optional peer for `cache.strategy: 'response'` on `@federation.delegate` / `@federation.replicate` views — see [Federation Integration](docs/federation.md).
|
|
446
|
+
|
|
439
447
|
## Contributing
|
|
440
448
|
|
|
441
449
|
Contributions are welcome! Please submit pull requests to the [repository](https://github.com/mikezaschka/cds-caching).
|
|
@@ -102,7 +102,7 @@ sap.ui.predefine("sap/base/util/merge", ["./_merge"],function(e){"use strict";va
|
|
|
102
102
|
* (c) Copyright 2025 SAP SE or an SAP affiliate company.
|
|
103
103
|
* Licensed under the Apache License, Version 2.0 - see LICENSE.txt.
|
|
104
104
|
*/
|
|
105
|
-
sap.ui.predefine("sap/ui/Global", ["sap/ui/VersionInfo","sap/base/Log","sap/base/assert","sap/base/util/ObjectPath"],function(e,a,r,t){"use strict";if(globalThis.OpenAjax&&globalThis.OpenAjax.hub){OpenAjax.hub.registerLibrary("sap","http://www.sap.com/","0.1",{})}var n;let s={version:"1.136.0",buildinfo:{lastchange:"",buildtime:"
|
|
105
|
+
sap.ui.predefine("sap/ui/Global", ["sap/ui/VersionInfo","sap/base/Log","sap/base/assert","sap/base/util/ObjectPath"],function(e,a,r,t){"use strict";if(globalThis.OpenAjax&&globalThis.OpenAjax.hub){OpenAjax.hub.registerLibrary("sap","http://www.sap.com/","0.1",{})}var n;let s={version:"1.136.0",buildinfo:{lastchange:"",buildtime:"20260816-1454"}};s=Object.assign(sap.ui,s);var o=sap.ui.loader._.getSyncCallBehavior();sap.ui.getVersionInfo=function(r){if(r&&r.async){a.info("Do not use deprecated function 'sap.ui.getVersionInfo'. Use"+" 'sap/ui/VersionInfo' module's asynchronous .load function instead")}else{a.warning("Do not use deprecated function 'sap.ui.getVersionInfo' synchronously! Use"+" 'sap/ui/VersionInfo' module's asynchronous .load function instead","Deprecation",null,function(){return{type:"sap.ui.getVersionInfo",name:"Global"}})}return e._load(r)};sap.ui.namespace=function(e){r(false,"sap.ui.namespace is long time deprecated and shouldn't be used");return t.create(e)};sap.ui.lazyRequire=function(e,s,i){r(typeof e==="string"&&e,"lazyRequire: sClassName must be a non-empty string");r(!s||typeof s==="string","lazyRequire: sMethods must be empty or a string");if(o===2){a.error("[nosync] lazy stub creation ignored for '"+e+"'");return}var u=e.replace(/\//gi,"."),l=u.lastIndexOf("."),c=u.substr(0,l),p=u.substr(l+1),f=t.create(c),y=f[p],d=(s||"new").split(" "),b=d.indexOf("new");i=i||u;if(!y){if(b>=0){y=function(){if(o){if(o===1){a.error("[nosync] lazy stub for constructor '"+u+"' called")}}else{a.debug("lazy stub for constructor '"+u+"' called.")}sap.ui.requireSync(i.replace(/\./g,"/"));var t=f[p];r(typeof t==="function","lazyRequire: oRealClass must be a function after loading");if(t._sapUiLazyLoader){throw new Error("lazyRequire: stub '"+u+"'has not been replaced by module '"+i+"'")}var s=Object.create(t.prototype);if(!(this instanceof y)){n=n||sap.ui.require("sap/ui/base/Object");if(n&&s instanceof n){a.error("Constructor "+e+' has been called without "new" operator!',null,null,function(){try{throw new Error}catch(e){return e}})}}var l=t.apply(s,arguments);if(l&&(typeof l==="function"||typeof l==="object")){s=l}return s};y._sapUiLazyLoader=true;d.splice(b,1)}else{y={}}f[p]=y}d.forEach(function(e){if(!y[e]){y[e]=function(){if(o){if(o===1){a.error("[no-sync] lazy stub for method '"+u+"."+e+"' called")}}else{a.debug("lazy stub for method '"+u+"."+e+"' called.")}sap.ui.requireSync(i.replace(/\./g,"/"));var t=f[p];r(typeof t==="function"||typeof t==="object","lazyRequire: oRealClass must be a function or object after loading");r(typeof t[e]==="function","lazyRequire: method must be a function");if(t[e]._sapUiLazyLoader){throw new Error("lazyRequire: stub '"+u+"."+e+"' has not been replaced by loaded module '"+i+"'")}return t[e].apply(t,arguments)};y[e]._sapUiLazyLoader=true}})};sap.ui.lazyRequire._isStub=function(e){r(typeof e==="string"&&e,"lazyRequire._isStub: sClassName must be a non-empty string");var a=e.lastIndexOf("."),n=e.slice(0,a),s=e.slice(a+1),o=t.get(n||"");return!!(o&&typeof o[s]==="function"&&o[s]._sapUiLazyLoader)};sap.ui.resource=function(e,a){r(typeof e==="string","sLibraryName must be a string");r(typeof a==="string","sResourcePath must be a string");return sap.ui.require.toUrl((String(e).replace(/\./g,"/")+"/"+a).replace(/^\/*/,""))};sap.ui.localResources=function(e){r(e,"sNamespace must not be empty");var a={};a[e.replace(/\./g,"/")]="./"+e.replace(/\./g,"/");sap.ui.loader.config({paths:a})};return s});
|
|
106
106
|
/*!
|
|
107
107
|
* OpenUI5
|
|
108
108
|
* (c) Copyright 2025 SAP SE or an SAP affiliate company.
|
|
@@ -76,7 +76,7 @@ sap.ui.predefine("sap/base/util/resolveReference", ["sap/base/util/ObjectPath"],
|
|
|
76
76
|
* (c) Copyright 2025 SAP SE or an SAP affiliate company.
|
|
77
77
|
* Licensed under the Apache License, Version 2.0 - see LICENSE.txt.
|
|
78
78
|
*/
|
|
79
|
-
sap.ui.predefine("sap/ui/Global", ["sap/ui/VersionInfo","sap/base/Log","sap/base/assert","sap/base/util/ObjectPath"],function(e,a,r,t){"use strict";if(globalThis.OpenAjax&&globalThis.OpenAjax.hub){OpenAjax.hub.registerLibrary("sap","http://www.sap.com/","0.1",{})}var n;let s={version:"1.136.0",buildinfo:{lastchange:"",buildtime:"
|
|
79
|
+
sap.ui.predefine("sap/ui/Global", ["sap/ui/VersionInfo","sap/base/Log","sap/base/assert","sap/base/util/ObjectPath"],function(e,a,r,t){"use strict";if(globalThis.OpenAjax&&globalThis.OpenAjax.hub){OpenAjax.hub.registerLibrary("sap","http://www.sap.com/","0.1",{})}var n;let s={version:"1.136.0",buildinfo:{lastchange:"",buildtime:"20260816-1454"}};s=Object.assign(sap.ui,s);var o=sap.ui.loader._.getSyncCallBehavior();sap.ui.getVersionInfo=function(r){if(r&&r.async){a.info("Do not use deprecated function 'sap.ui.getVersionInfo'. Use"+" 'sap/ui/VersionInfo' module's asynchronous .load function instead")}else{a.warning("Do not use deprecated function 'sap.ui.getVersionInfo' synchronously! Use"+" 'sap/ui/VersionInfo' module's asynchronous .load function instead","Deprecation",null,function(){return{type:"sap.ui.getVersionInfo",name:"Global"}})}return e._load(r)};sap.ui.namespace=function(e){r(false,"sap.ui.namespace is long time deprecated and shouldn't be used");return t.create(e)};sap.ui.lazyRequire=function(e,s,i){r(typeof e==="string"&&e,"lazyRequire: sClassName must be a non-empty string");r(!s||typeof s==="string","lazyRequire: sMethods must be empty or a string");if(o===2){a.error("[nosync] lazy stub creation ignored for '"+e+"'");return}var u=e.replace(/\//gi,"."),l=u.lastIndexOf("."),c=u.substr(0,l),p=u.substr(l+1),f=t.create(c),y=f[p],d=(s||"new").split(" "),b=d.indexOf("new");i=i||u;if(!y){if(b>=0){y=function(){if(o){if(o===1){a.error("[nosync] lazy stub for constructor '"+u+"' called")}}else{a.debug("lazy stub for constructor '"+u+"' called.")}sap.ui.requireSync(i.replace(/\./g,"/"));var t=f[p];r(typeof t==="function","lazyRequire: oRealClass must be a function after loading");if(t._sapUiLazyLoader){throw new Error("lazyRequire: stub '"+u+"'has not been replaced by module '"+i+"'")}var s=Object.create(t.prototype);if(!(this instanceof y)){n=n||sap.ui.require("sap/ui/base/Object");if(n&&s instanceof n){a.error("Constructor "+e+' has been called without "new" operator!',null,null,function(){try{throw new Error}catch(e){return e}})}}var l=t.apply(s,arguments);if(l&&(typeof l==="function"||typeof l==="object")){s=l}return s};y._sapUiLazyLoader=true;d.splice(b,1)}else{y={}}f[p]=y}d.forEach(function(e){if(!y[e]){y[e]=function(){if(o){if(o===1){a.error("[no-sync] lazy stub for method '"+u+"."+e+"' called")}}else{a.debug("lazy stub for method '"+u+"."+e+"' called.")}sap.ui.requireSync(i.replace(/\./g,"/"));var t=f[p];r(typeof t==="function"||typeof t==="object","lazyRequire: oRealClass must be a function or object after loading");r(typeof t[e]==="function","lazyRequire: method must be a function");if(t[e]._sapUiLazyLoader){throw new Error("lazyRequire: stub '"+u+"."+e+"' has not been replaced by loaded module '"+i+"'")}return t[e].apply(t,arguments)};y[e]._sapUiLazyLoader=true}})};sap.ui.lazyRequire._isStub=function(e){r(typeof e==="string"&&e,"lazyRequire._isStub: sClassName must be a non-empty string");var a=e.lastIndexOf("."),n=e.slice(0,a),s=e.slice(a+1),o=t.get(n||"");return!!(o&&typeof o[s]==="function"&&o[s]._sapUiLazyLoader)};sap.ui.resource=function(e,a){r(typeof e==="string","sLibraryName must be a string");r(typeof a==="string","sResourcePath must be a string");return sap.ui.require.toUrl((String(e).replace(/\./g,"/")+"/"+a).replace(/^\/*/,""))};sap.ui.localResources=function(e){r(e,"sNamespace must not be empty");var a={};a[e.replace(/\./g,"/")]="./"+e.replace(/\./g,"/");sap.ui.loader.config({paths:a})};return s});
|
|
80
80
|
/*!
|
|
81
81
|
* OpenUI5
|
|
82
82
|
* (c) Copyright 2025 SAP SE or an SAP affiliate company.
|
|
@@ -61,7 +61,7 @@ sap.ui.define([
|
|
|
61
61
|
*/
|
|
62
62
|
version: "1.136.0",
|
|
63
63
|
// buildinfo.lastchange is deprecated and is therefore defaulted to empty string
|
|
64
|
-
buildinfo : { lastchange : "", buildtime : "
|
|
64
|
+
buildinfo : { lastchange : "", buildtime : "20260816-1454" }
|
|
65
65
|
};
|
|
66
66
|
|
|
67
67
|
/**
|
|
@@ -3,5 +3,5 @@
|
|
|
3
3
|
* (c) Copyright 2025 SAP SE or an SAP affiliate company.
|
|
4
4
|
* Licensed under the Apache License, Version 2.0 - see LICENSE.txt.
|
|
5
5
|
*/
|
|
6
|
-
sap.ui.define(["sap/ui/VersionInfo","sap/base/Log","sap/base/assert","sap/base/util/ObjectPath"],function(e,a,r,t){"use strict";if(globalThis.OpenAjax&&globalThis.OpenAjax.hub){OpenAjax.hub.registerLibrary("sap","http://www.sap.com/","0.1",{})}var n;let s={version:"1.136.0",buildinfo:{lastchange:"",buildtime:"
|
|
6
|
+
sap.ui.define(["sap/ui/VersionInfo","sap/base/Log","sap/base/assert","sap/base/util/ObjectPath"],function(e,a,r,t){"use strict";if(globalThis.OpenAjax&&globalThis.OpenAjax.hub){OpenAjax.hub.registerLibrary("sap","http://www.sap.com/","0.1",{})}var n;let s={version:"1.136.0",buildinfo:{lastchange:"",buildtime:"20260816-1454"}};s=Object.assign(sap.ui,s);var o=sap.ui.loader._.getSyncCallBehavior();sap.ui.getVersionInfo=function(r){if(r&&r.async){a.info("Do not use deprecated function 'sap.ui.getVersionInfo'. Use"+" 'sap/ui/VersionInfo' module's asynchronous .load function instead")}else{a.warning("Do not use deprecated function 'sap.ui.getVersionInfo' synchronously! Use"+" 'sap/ui/VersionInfo' module's asynchronous .load function instead","Deprecation",null,function(){return{type:"sap.ui.getVersionInfo",name:"Global"}})}return e._load(r)};sap.ui.namespace=function(e){r(false,"sap.ui.namespace is long time deprecated and shouldn't be used");return t.create(e)};sap.ui.lazyRequire=function(e,s,i){r(typeof e==="string"&&e,"lazyRequire: sClassName must be a non-empty string");r(!s||typeof s==="string","lazyRequire: sMethods must be empty or a string");if(o===2){a.error("[nosync] lazy stub creation ignored for '"+e+"'");return}var u=e.replace(/\//gi,"."),l=u.lastIndexOf("."),c=u.substr(0,l),p=u.substr(l+1),f=t.create(c),y=f[p],d=(s||"new").split(" "),b=d.indexOf("new");i=i||u;if(!y){if(b>=0){y=function(){if(o){if(o===1){a.error("[nosync] lazy stub for constructor '"+u+"' called")}}else{a.debug("lazy stub for constructor '"+u+"' called.")}sap.ui.requireSync(i.replace(/\./g,"/"));var t=f[p];r(typeof t==="function","lazyRequire: oRealClass must be a function after loading");if(t._sapUiLazyLoader){throw new Error("lazyRequire: stub '"+u+"'has not been replaced by module '"+i+"'")}var s=Object.create(t.prototype);if(!(this instanceof y)){n=n||sap.ui.require("sap/ui/base/Object");if(n&&s instanceof n){a.error("Constructor "+e+' has been called without "new" operator!',null,null,function(){try{throw new Error}catch(e){return e}})}}var l=t.apply(s,arguments);if(l&&(typeof l==="function"||typeof l==="object")){s=l}return s};y._sapUiLazyLoader=true;d.splice(b,1)}else{y={}}f[p]=y}d.forEach(function(e){if(!y[e]){y[e]=function(){if(o){if(o===1){a.error("[no-sync] lazy stub for method '"+u+"."+e+"' called")}}else{a.debug("lazy stub for method '"+u+"."+e+"' called.")}sap.ui.requireSync(i.replace(/\./g,"/"));var t=f[p];r(typeof t==="function"||typeof t==="object","lazyRequire: oRealClass must be a function or object after loading");r(typeof t[e]==="function","lazyRequire: method must be a function");if(t[e]._sapUiLazyLoader){throw new Error("lazyRequire: stub '"+u+"."+e+"' has not been replaced by loaded module '"+i+"'")}return t[e].apply(t,arguments)};y[e]._sapUiLazyLoader=true}})};sap.ui.lazyRequire._isStub=function(e){r(typeof e==="string"&&e,"lazyRequire._isStub: sClassName must be a non-empty string");var a=e.lastIndexOf("."),n=e.slice(0,a),s=e.slice(a+1),o=t.get(n||"");return!!(o&&typeof o[s]==="function"&&o[s]._sapUiLazyLoader)};sap.ui.resource=function(e,a){r(typeof e==="string","sLibraryName must be a string");r(typeof a==="string","sResourcePath must be a string");return sap.ui.require.toUrl((String(e).replace(/\./g,"/")+"/"+a).replace(/^\/*/,""))};sap.ui.localResources=function(e){r(e,"sNamespace must not be empty");var a={};a[e.replace(/\./g,"/")]="./"+e.replace(/\./g,"/");sap.ui.loader.config({paths:a})};return s});
|
|
7
7
|
//# sourceMappingURL=Global.js.map
|
|
@@ -351,7 +351,7 @@ sap.ui.predefine("sap/base/util/uid", [],function(){"use strict";var e=0;var n=f
|
|
|
351
351
|
* (c) Copyright 2025 SAP SE or an SAP affiliate company.
|
|
352
352
|
* Licensed under the Apache License, Version 2.0 - see LICENSE.txt.
|
|
353
353
|
*/
|
|
354
|
-
sap.ui.predefine("sap/ui/Global", ["sap/ui/VersionInfo","sap/base/Log","sap/base/assert","sap/base/util/ObjectPath"],function(e,a,r,t){"use strict";if(globalThis.OpenAjax&&globalThis.OpenAjax.hub){OpenAjax.hub.registerLibrary("sap","http://www.sap.com/","0.1",{})}var n;let s={version:"1.136.0",buildinfo:{lastchange:"",buildtime:"
|
|
354
|
+
sap.ui.predefine("sap/ui/Global", ["sap/ui/VersionInfo","sap/base/Log","sap/base/assert","sap/base/util/ObjectPath"],function(e,a,r,t){"use strict";if(globalThis.OpenAjax&&globalThis.OpenAjax.hub){OpenAjax.hub.registerLibrary("sap","http://www.sap.com/","0.1",{})}var n;let s={version:"1.136.0",buildinfo:{lastchange:"",buildtime:"20260816-1454"}};s=Object.assign(sap.ui,s);var o=sap.ui.loader._.getSyncCallBehavior();sap.ui.getVersionInfo=function(r){if(r&&r.async){a.info("Do not use deprecated function 'sap.ui.getVersionInfo'. Use"+" 'sap/ui/VersionInfo' module's asynchronous .load function instead")}else{a.warning("Do not use deprecated function 'sap.ui.getVersionInfo' synchronously! Use"+" 'sap/ui/VersionInfo' module's asynchronous .load function instead","Deprecation",null,function(){return{type:"sap.ui.getVersionInfo",name:"Global"}})}return e._load(r)};sap.ui.namespace=function(e){r(false,"sap.ui.namespace is long time deprecated and shouldn't be used");return t.create(e)};sap.ui.lazyRequire=function(e,s,i){r(typeof e==="string"&&e,"lazyRequire: sClassName must be a non-empty string");r(!s||typeof s==="string","lazyRequire: sMethods must be empty or a string");if(o===2){a.error("[nosync] lazy stub creation ignored for '"+e+"'");return}var u=e.replace(/\//gi,"."),l=u.lastIndexOf("."),c=u.substr(0,l),p=u.substr(l+1),f=t.create(c),y=f[p],d=(s||"new").split(" "),b=d.indexOf("new");i=i||u;if(!y){if(b>=0){y=function(){if(o){if(o===1){a.error("[nosync] lazy stub for constructor '"+u+"' called")}}else{a.debug("lazy stub for constructor '"+u+"' called.")}sap.ui.requireSync(i.replace(/\./g,"/"));var t=f[p];r(typeof t==="function","lazyRequire: oRealClass must be a function after loading");if(t._sapUiLazyLoader){throw new Error("lazyRequire: stub '"+u+"'has not been replaced by module '"+i+"'")}var s=Object.create(t.prototype);if(!(this instanceof y)){n=n||sap.ui.require("sap/ui/base/Object");if(n&&s instanceof n){a.error("Constructor "+e+' has been called without "new" operator!',null,null,function(){try{throw new Error}catch(e){return e}})}}var l=t.apply(s,arguments);if(l&&(typeof l==="function"||typeof l==="object")){s=l}return s};y._sapUiLazyLoader=true;d.splice(b,1)}else{y={}}f[p]=y}d.forEach(function(e){if(!y[e]){y[e]=function(){if(o){if(o===1){a.error("[no-sync] lazy stub for method '"+u+"."+e+"' called")}}else{a.debug("lazy stub for method '"+u+"."+e+"' called.")}sap.ui.requireSync(i.replace(/\./g,"/"));var t=f[p];r(typeof t==="function"||typeof t==="object","lazyRequire: oRealClass must be a function or object after loading");r(typeof t[e]==="function","lazyRequire: method must be a function");if(t[e]._sapUiLazyLoader){throw new Error("lazyRequire: stub '"+u+"."+e+"' has not been replaced by loaded module '"+i+"'")}return t[e].apply(t,arguments)};y[e]._sapUiLazyLoader=true}})};sap.ui.lazyRequire._isStub=function(e){r(typeof e==="string"&&e,"lazyRequire._isStub: sClassName must be a non-empty string");var a=e.lastIndexOf("."),n=e.slice(0,a),s=e.slice(a+1),o=t.get(n||"");return!!(o&&typeof o[s]==="function"&&o[s]._sapUiLazyLoader)};sap.ui.resource=function(e,a){r(typeof e==="string","sLibraryName must be a string");r(typeof a==="string","sResourcePath must be a string");return sap.ui.require.toUrl((String(e).replace(/\./g,"/")+"/"+a).replace(/^\/*/,""))};sap.ui.localResources=function(e){r(e,"sNamespace must not be empty");var a={};a[e.replace(/\./g,"/")]="./"+e.replace(/\./g,"/");sap.ui.loader.config({paths:a})};return s});
|
|
355
355
|
/*!
|
|
356
356
|
* OpenUI5
|
|
357
357
|
* (c) Copyright 2025 SAP SE or an SAP affiliate company.
|
|
@@ -344,7 +344,7 @@ sap.ui.predefine("sap/base/util/uid", [],function(){"use strict";var e=0;var n=f
|
|
|
344
344
|
* (c) Copyright 2025 SAP SE or an SAP affiliate company.
|
|
345
345
|
* Licensed under the Apache License, Version 2.0 - see LICENSE.txt.
|
|
346
346
|
*/
|
|
347
|
-
sap.ui.predefine("sap/ui/Global", ["sap/ui/VersionInfo","sap/base/Log","sap/base/assert","sap/base/util/ObjectPath"],function(e,a,r,t){"use strict";if(globalThis.OpenAjax&&globalThis.OpenAjax.hub){OpenAjax.hub.registerLibrary("sap","http://www.sap.com/","0.1",{})}var n;let s={version:"1.136.0",buildinfo:{lastchange:"",buildtime:"
|
|
347
|
+
sap.ui.predefine("sap/ui/Global", ["sap/ui/VersionInfo","sap/base/Log","sap/base/assert","sap/base/util/ObjectPath"],function(e,a,r,t){"use strict";if(globalThis.OpenAjax&&globalThis.OpenAjax.hub){OpenAjax.hub.registerLibrary("sap","http://www.sap.com/","0.1",{})}var n;let s={version:"1.136.0",buildinfo:{lastchange:"",buildtime:"20260816-1454"}};s=Object.assign(sap.ui,s);var o=sap.ui.loader._.getSyncCallBehavior();sap.ui.getVersionInfo=function(r){if(r&&r.async){a.info("Do not use deprecated function 'sap.ui.getVersionInfo'. Use"+" 'sap/ui/VersionInfo' module's asynchronous .load function instead")}else{a.warning("Do not use deprecated function 'sap.ui.getVersionInfo' synchronously! Use"+" 'sap/ui/VersionInfo' module's asynchronous .load function instead","Deprecation",null,function(){return{type:"sap.ui.getVersionInfo",name:"Global"}})}return e._load(r)};sap.ui.namespace=function(e){r(false,"sap.ui.namespace is long time deprecated and shouldn't be used");return t.create(e)};sap.ui.lazyRequire=function(e,s,i){r(typeof e==="string"&&e,"lazyRequire: sClassName must be a non-empty string");r(!s||typeof s==="string","lazyRequire: sMethods must be empty or a string");if(o===2){a.error("[nosync] lazy stub creation ignored for '"+e+"'");return}var u=e.replace(/\//gi,"."),l=u.lastIndexOf("."),c=u.substr(0,l),p=u.substr(l+1),f=t.create(c),y=f[p],d=(s||"new").split(" "),b=d.indexOf("new");i=i||u;if(!y){if(b>=0){y=function(){if(o){if(o===1){a.error("[nosync] lazy stub for constructor '"+u+"' called")}}else{a.debug("lazy stub for constructor '"+u+"' called.")}sap.ui.requireSync(i.replace(/\./g,"/"));var t=f[p];r(typeof t==="function","lazyRequire: oRealClass must be a function after loading");if(t._sapUiLazyLoader){throw new Error("lazyRequire: stub '"+u+"'has not been replaced by module '"+i+"'")}var s=Object.create(t.prototype);if(!(this instanceof y)){n=n||sap.ui.require("sap/ui/base/Object");if(n&&s instanceof n){a.error("Constructor "+e+' has been called without "new" operator!',null,null,function(){try{throw new Error}catch(e){return e}})}}var l=t.apply(s,arguments);if(l&&(typeof l==="function"||typeof l==="object")){s=l}return s};y._sapUiLazyLoader=true;d.splice(b,1)}else{y={}}f[p]=y}d.forEach(function(e){if(!y[e]){y[e]=function(){if(o){if(o===1){a.error("[no-sync] lazy stub for method '"+u+"."+e+"' called")}}else{a.debug("lazy stub for method '"+u+"."+e+"' called.")}sap.ui.requireSync(i.replace(/\./g,"/"));var t=f[p];r(typeof t==="function"||typeof t==="object","lazyRequire: oRealClass must be a function or object after loading");r(typeof t[e]==="function","lazyRequire: method must be a function");if(t[e]._sapUiLazyLoader){throw new Error("lazyRequire: stub '"+u+"."+e+"' has not been replaced by loaded module '"+i+"'")}return t[e].apply(t,arguments)};y[e]._sapUiLazyLoader=true}})};sap.ui.lazyRequire._isStub=function(e){r(typeof e==="string"&&e,"lazyRequire._isStub: sClassName must be a non-empty string");var a=e.lastIndexOf("."),n=e.slice(0,a),s=e.slice(a+1),o=t.get(n||"");return!!(o&&typeof o[s]==="function"&&o[s]._sapUiLazyLoader)};sap.ui.resource=function(e,a){r(typeof e==="string","sLibraryName must be a string");r(typeof a==="string","sResourcePath must be a string");return sap.ui.require.toUrl((String(e).replace(/\./g,"/")+"/"+a).replace(/^\/*/,""))};sap.ui.localResources=function(e){r(e,"sNamespace must not be empty");var a={};a[e.replace(/\./g,"/")]="./"+e.replace(/\./g,"/");sap.ui.loader.config({paths:a})};return s});
|
|
348
348
|
/*!
|
|
349
349
|
* OpenUI5
|
|
350
350
|
* (c) Copyright 2025 SAP SE or an SAP affiliate company.
|
|
@@ -344,7 +344,7 @@ sap.ui.predefine("sap/base/util/uid", [],function(){"use strict";var e=0;var n=f
|
|
|
344
344
|
* (c) Copyright 2025 SAP SE or an SAP affiliate company.
|
|
345
345
|
* Licensed under the Apache License, Version 2.0 - see LICENSE.txt.
|
|
346
346
|
*/
|
|
347
|
-
sap.ui.predefine("sap/ui/Global", ["sap/ui/VersionInfo","sap/base/Log","sap/base/assert","sap/base/util/ObjectPath"],function(e,a,r,t){"use strict";if(globalThis.OpenAjax&&globalThis.OpenAjax.hub){OpenAjax.hub.registerLibrary("sap","http://www.sap.com/","0.1",{})}var n;let s={version:"1.136.0",buildinfo:{lastchange:"",buildtime:"
|
|
347
|
+
sap.ui.predefine("sap/ui/Global", ["sap/ui/VersionInfo","sap/base/Log","sap/base/assert","sap/base/util/ObjectPath"],function(e,a,r,t){"use strict";if(globalThis.OpenAjax&&globalThis.OpenAjax.hub){OpenAjax.hub.registerLibrary("sap","http://www.sap.com/","0.1",{})}var n;let s={version:"1.136.0",buildinfo:{lastchange:"",buildtime:"20260816-1454"}};s=Object.assign(sap.ui,s);var o=sap.ui.loader._.getSyncCallBehavior();sap.ui.getVersionInfo=function(r){if(r&&r.async){a.info("Do not use deprecated function 'sap.ui.getVersionInfo'. Use"+" 'sap/ui/VersionInfo' module's asynchronous .load function instead")}else{a.warning("Do not use deprecated function 'sap.ui.getVersionInfo' synchronously! Use"+" 'sap/ui/VersionInfo' module's asynchronous .load function instead","Deprecation",null,function(){return{type:"sap.ui.getVersionInfo",name:"Global"}})}return e._load(r)};sap.ui.namespace=function(e){r(false,"sap.ui.namespace is long time deprecated and shouldn't be used");return t.create(e)};sap.ui.lazyRequire=function(e,s,i){r(typeof e==="string"&&e,"lazyRequire: sClassName must be a non-empty string");r(!s||typeof s==="string","lazyRequire: sMethods must be empty or a string");if(o===2){a.error("[nosync] lazy stub creation ignored for '"+e+"'");return}var u=e.replace(/\//gi,"."),l=u.lastIndexOf("."),c=u.substr(0,l),p=u.substr(l+1),f=t.create(c),y=f[p],d=(s||"new").split(" "),b=d.indexOf("new");i=i||u;if(!y){if(b>=0){y=function(){if(o){if(o===1){a.error("[nosync] lazy stub for constructor '"+u+"' called")}}else{a.debug("lazy stub for constructor '"+u+"' called.")}sap.ui.requireSync(i.replace(/\./g,"/"));var t=f[p];r(typeof t==="function","lazyRequire: oRealClass must be a function after loading");if(t._sapUiLazyLoader){throw new Error("lazyRequire: stub '"+u+"'has not been replaced by module '"+i+"'")}var s=Object.create(t.prototype);if(!(this instanceof y)){n=n||sap.ui.require("sap/ui/base/Object");if(n&&s instanceof n){a.error("Constructor "+e+' has been called without "new" operator!',null,null,function(){try{throw new Error}catch(e){return e}})}}var l=t.apply(s,arguments);if(l&&(typeof l==="function"||typeof l==="object")){s=l}return s};y._sapUiLazyLoader=true;d.splice(b,1)}else{y={}}f[p]=y}d.forEach(function(e){if(!y[e]){y[e]=function(){if(o){if(o===1){a.error("[no-sync] lazy stub for method '"+u+"."+e+"' called")}}else{a.debug("lazy stub for method '"+u+"."+e+"' called.")}sap.ui.requireSync(i.replace(/\./g,"/"));var t=f[p];r(typeof t==="function"||typeof t==="object","lazyRequire: oRealClass must be a function or object after loading");r(typeof t[e]==="function","lazyRequire: method must be a function");if(t[e]._sapUiLazyLoader){throw new Error("lazyRequire: stub '"+u+"."+e+"' has not been replaced by loaded module '"+i+"'")}return t[e].apply(t,arguments)};y[e]._sapUiLazyLoader=true}})};sap.ui.lazyRequire._isStub=function(e){r(typeof e==="string"&&e,"lazyRequire._isStub: sClassName must be a non-empty string");var a=e.lastIndexOf("."),n=e.slice(0,a),s=e.slice(a+1),o=t.get(n||"");return!!(o&&typeof o[s]==="function"&&o[s]._sapUiLazyLoader)};sap.ui.resource=function(e,a){r(typeof e==="string","sLibraryName must be a string");r(typeof a==="string","sResourcePath must be a string");return sap.ui.require.toUrl((String(e).replace(/\./g,"/")+"/"+a).replace(/^\/*/,""))};sap.ui.localResources=function(e){r(e,"sNamespace must not be empty");var a={};a[e.replace(/\./g,"/")]="./"+e.replace(/\./g,"/");sap.ui.loader.config({paths:a})};return s});
|
|
348
348
|
/*!
|
|
349
349
|
* OpenUI5
|
|
350
350
|
* (c) Copyright 2025 SAP SE or an SAP affiliate company.
|
|
@@ -1657,7 +1657,7 @@ sap.ui.predefine("sap/tnt/library", ["./IllustratedMessageType","sap/ui/base/Dat
|
|
|
1657
1657
|
* (c) Copyright 2025 SAP SE or an SAP affiliate company.
|
|
1658
1658
|
* Licensed under the Apache License, Version 2.0 - see LICENSE.txt.
|
|
1659
1659
|
*/
|
|
1660
|
-
sap.ui.predefine("sap/ui/Global", ["sap/ui/VersionInfo","sap/base/Log","sap/base/assert","sap/base/util/ObjectPath"],function(e,a,r,t){"use strict";if(globalThis.OpenAjax&&globalThis.OpenAjax.hub){OpenAjax.hub.registerLibrary("sap","http://www.sap.com/","0.1",{})}var n;let s={version:"1.136.0",buildinfo:{lastchange:"",buildtime:"
|
|
1660
|
+
sap.ui.predefine("sap/ui/Global", ["sap/ui/VersionInfo","sap/base/Log","sap/base/assert","sap/base/util/ObjectPath"],function(e,a,r,t){"use strict";if(globalThis.OpenAjax&&globalThis.OpenAjax.hub){OpenAjax.hub.registerLibrary("sap","http://www.sap.com/","0.1",{})}var n;let s={version:"1.136.0",buildinfo:{lastchange:"",buildtime:"20260816-1454"}};s=Object.assign(sap.ui,s);var o=sap.ui.loader._.getSyncCallBehavior();sap.ui.getVersionInfo=function(r){if(r&&r.async){a.info("Do not use deprecated function 'sap.ui.getVersionInfo'. Use"+" 'sap/ui/VersionInfo' module's asynchronous .load function instead")}else{a.warning("Do not use deprecated function 'sap.ui.getVersionInfo' synchronously! Use"+" 'sap/ui/VersionInfo' module's asynchronous .load function instead","Deprecation",null,function(){return{type:"sap.ui.getVersionInfo",name:"Global"}})}return e._load(r)};sap.ui.namespace=function(e){r(false,"sap.ui.namespace is long time deprecated and shouldn't be used");return t.create(e)};sap.ui.lazyRequire=function(e,s,i){r(typeof e==="string"&&e,"lazyRequire: sClassName must be a non-empty string");r(!s||typeof s==="string","lazyRequire: sMethods must be empty or a string");if(o===2){a.error("[nosync] lazy stub creation ignored for '"+e+"'");return}var u=e.replace(/\//gi,"."),l=u.lastIndexOf("."),c=u.substr(0,l),p=u.substr(l+1),f=t.create(c),y=f[p],d=(s||"new").split(" "),b=d.indexOf("new");i=i||u;if(!y){if(b>=0){y=function(){if(o){if(o===1){a.error("[nosync] lazy stub for constructor '"+u+"' called")}}else{a.debug("lazy stub for constructor '"+u+"' called.")}sap.ui.requireSync(i.replace(/\./g,"/"));var t=f[p];r(typeof t==="function","lazyRequire: oRealClass must be a function after loading");if(t._sapUiLazyLoader){throw new Error("lazyRequire: stub '"+u+"'has not been replaced by module '"+i+"'")}var s=Object.create(t.prototype);if(!(this instanceof y)){n=n||sap.ui.require("sap/ui/base/Object");if(n&&s instanceof n){a.error("Constructor "+e+' has been called without "new" operator!',null,null,function(){try{throw new Error}catch(e){return e}})}}var l=t.apply(s,arguments);if(l&&(typeof l==="function"||typeof l==="object")){s=l}return s};y._sapUiLazyLoader=true;d.splice(b,1)}else{y={}}f[p]=y}d.forEach(function(e){if(!y[e]){y[e]=function(){if(o){if(o===1){a.error("[no-sync] lazy stub for method '"+u+"."+e+"' called")}}else{a.debug("lazy stub for method '"+u+"."+e+"' called.")}sap.ui.requireSync(i.replace(/\./g,"/"));var t=f[p];r(typeof t==="function"||typeof t==="object","lazyRequire: oRealClass must be a function or object after loading");r(typeof t[e]==="function","lazyRequire: method must be a function");if(t[e]._sapUiLazyLoader){throw new Error("lazyRequire: stub '"+u+"."+e+"' has not been replaced by loaded module '"+i+"'")}return t[e].apply(t,arguments)};y[e]._sapUiLazyLoader=true}})};sap.ui.lazyRequire._isStub=function(e){r(typeof e==="string"&&e,"lazyRequire._isStub: sClassName must be a non-empty string");var a=e.lastIndexOf("."),n=e.slice(0,a),s=e.slice(a+1),o=t.get(n||"");return!!(o&&typeof o[s]==="function"&&o[s]._sapUiLazyLoader)};sap.ui.resource=function(e,a){r(typeof e==="string","sLibraryName must be a string");r(typeof a==="string","sResourcePath must be a string");return sap.ui.require.toUrl((String(e).replace(/\./g,"/")+"/"+a).replace(/^\/*/,""))};sap.ui.localResources=function(e){r(e,"sNamespace must not be empty");var a={};a[e.replace(/\./g,"/")]="./"+e.replace(/\./g,"/");sap.ui.loader.config({paths:a})};return s});
|
|
1661
1661
|
/*!
|
|
1662
1662
|
* OpenUI5
|
|
1663
1663
|
* (c) Copyright 2025 SAP SE or an SAP affiliate company.
|
package/cds-plugin.js
CHANGED
|
@@ -2,11 +2,25 @@ const cds = require('@sap/cds')
|
|
|
2
2
|
const { fs, path } = cds.utils;
|
|
3
3
|
const CachingService = require('./lib/CachingService')
|
|
4
4
|
const { scanCachingAnnotations } = require('./lib/util')
|
|
5
|
-
const { getCachingRequiresEntries } = require('./lib/config-normalizer')
|
|
5
|
+
const { getCachingRequiresEntries, detectMisplacedKeyManagement } = require('./lib/config-normalizer')
|
|
6
6
|
const { resolvePluginRoots } = require('./lib/plugin-roots')
|
|
7
7
|
|
|
8
8
|
const LOG = cds.log("cds-caching");
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* Parsed project package.json, or an empty object when unreadable.
|
|
12
|
+
* @returns {object}
|
|
13
|
+
*/
|
|
14
|
+
function readProjectPackage() {
|
|
15
|
+
try {
|
|
16
|
+
const pkgPath = path.join(cds.root, 'package.json')
|
|
17
|
+
if (!fs.existsSync(pkgPath)) return {}
|
|
18
|
+
return JSON.parse(fs.readFileSync(pkgPath, 'utf8'))
|
|
19
|
+
} catch {
|
|
20
|
+
return {}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
10
24
|
// Auto-register plugin entity models based on service configuration.
|
|
11
25
|
// See docs/feature-activation.md for reuse vs project-owned activation.
|
|
12
26
|
const cachingEntries = getCachingRequiresEntries(cds.env.requires ?? {})
|
|
@@ -22,6 +36,9 @@ for (const root of pluginRoots) {
|
|
|
22
36
|
}
|
|
23
37
|
for (const message of warnings) LOG.warn(message)
|
|
24
38
|
|
|
39
|
+
const misplacedKeyManagement = detectMisplacedKeyManagement(cds.env, readProjectPackage())
|
|
40
|
+
if (misplacedKeyManagement) LOG.warn(misplacedKeyManagement)
|
|
41
|
+
|
|
25
42
|
cds.on('served', scanCachingAnnotations)
|
|
26
43
|
|
|
27
44
|
if (reuseDashboard) {
|
|
@@ -35,7 +52,16 @@ if (reuseDashboard) {
|
|
|
35
52
|
);
|
|
36
53
|
}
|
|
37
54
|
cds.once('bootstrap', (app) => {
|
|
38
|
-
|
|
55
|
+
// Run CAP's middlewares first so cds.context.user is populated, then gate
|
|
56
|
+
// the static assets on it.
|
|
57
|
+
const { requireAuthenticatedUser, capRequestMiddlewares } = require('./lib/dashboard-guard');
|
|
58
|
+
|
|
59
|
+
app.use(
|
|
60
|
+
'/caching-dashboard',
|
|
61
|
+
...capRequestMiddlewares(),
|
|
62
|
+
requireAuthenticatedUser,
|
|
63
|
+
require('express').static(dashboardPath)
|
|
64
|
+
);
|
|
39
65
|
(app._app_links ??= []).push('/caching-dashboard');
|
|
40
66
|
LOG.info("Serving cds-caching dashboard at /caching-dashboard");
|
|
41
67
|
});
|
|
@@ -56,7 +82,6 @@ cds.build?.register?.('cds-caching', class CachingBuildPlugin extends cds.build.
|
|
|
56
82
|
clean() { }
|
|
57
83
|
|
|
58
84
|
static hasTask() {
|
|
59
|
-
cds.log('cds-caching').info('hasTask', cds.env.requires);
|
|
60
85
|
const requires = cds.env.requires || {};
|
|
61
86
|
const dbKind = requires.db?.kind || '';
|
|
62
87
|
const isHanaDB = dbKind === 'hana' || dbKind === 'sql';
|
package/index.cds
CHANGED
|
@@ -2,13 +2,26 @@ using from './db/statistics';
|
|
|
2
2
|
|
|
3
3
|
context plugin.cds_caching {
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
/**
|
|
6
|
+
* Management API for cache entries and metrics.
|
|
7
|
+
*
|
|
8
|
+
* Guarded with `authenticated-user` by default, because these operations can
|
|
9
|
+
* read and flush cache contents. Override in your own model to require a
|
|
10
|
+
* dedicated role instead:
|
|
11
|
+
*
|
|
12
|
+
* annotate plugin.cds_caching.CachingApiService with @requires: 'CacheAdmin';
|
|
13
|
+
*/
|
|
14
|
+
@impl : 'cds-caching/srv/caching-api-service'
|
|
15
|
+
@requires: 'authenticated-user'
|
|
6
16
|
service CachingApiService {
|
|
7
17
|
|
|
18
|
+
// Writes go through the bound actions below, never through CRUD, so that
|
|
19
|
+
// config rows (metricsEnabled, config) cannot be tampered with directly.
|
|
20
|
+
@readonly
|
|
8
21
|
entity Caches as projection on plugin.cds_caching.Caches
|
|
9
22
|
actions {
|
|
10
23
|
|
|
11
|
-
function getEntries()
|
|
24
|
+
function getEntries(top : Integer, skip : Integer) returns array of {
|
|
12
25
|
entryKey : String;
|
|
13
26
|
value : String;
|
|
14
27
|
timestamp : DateTime;
|
package/lib/CachingService.js
CHANGED
|
@@ -23,6 +23,9 @@ class CachingService extends cds.Service {
|
|
|
23
23
|
credentials: {},
|
|
24
24
|
namespace: null,
|
|
25
25
|
throwOnErrors: false,
|
|
26
|
+
// When enabled, read-through HTTP responses carry the full cache key in
|
|
27
|
+
// `x-sap-cap-cache-key`. Useful for debugging, unsafe in production.
|
|
28
|
+
debugHeaders: false,
|
|
26
29
|
// When enabled, basic cache operations (`get`, `set`, `delete`, ...)
|
|
27
30
|
// will be executed in a dedicated cache transaction (`cache.tx()`),
|
|
28
31
|
// isolating them from the caller's request transaction (e.g. concurrent BEFORE handlers).
|
package/lib/config-normalizer.js
CHANGED
|
@@ -109,6 +109,7 @@ function getStatisticsHandlerOptions(metrics) {
|
|
|
109
109
|
if (metrics.persistenceInterval != null) opts.persistenceInterval = metrics.persistenceInterval
|
|
110
110
|
if (metrics.maxLatencies != null) opts.maxLatencies = metrics.maxLatencies
|
|
111
111
|
if (metrics.maxKeyMetrics != null) opts.maxKeyMetrics = metrics.maxKeyMetrics
|
|
112
|
+
if (metrics.maxMetricFieldLength != null) opts.maxMetricFieldLength = metrics.maxMetricFieldLength
|
|
112
113
|
if (metrics.enabled === true) opts.metricsEnabled = true
|
|
113
114
|
if (metrics.keyMetricsEnabled === true) opts.keyMetricsEnabled = true
|
|
114
115
|
return opts
|
|
@@ -122,10 +123,37 @@ function isMetricsConfigured(normalized) {
|
|
|
122
123
|
return normalized.metrics != null
|
|
123
124
|
}
|
|
124
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Detect a `keyManagement` block placed outside `cds.requires.<cache>`.
|
|
128
|
+
*
|
|
129
|
+
* Releases up to 2.0.2 documented this as a top-level `"cds-caching"` key, which
|
|
130
|
+
* the runtime never read — so keys silently stayed context-free even though the
|
|
131
|
+
* project looked configured for user-aware caching. Warn loudly instead.
|
|
132
|
+
*
|
|
133
|
+
* @param {object} [env={}] - cds.env
|
|
134
|
+
* @param {object} [projectPackage={}] - Parsed project package.json
|
|
135
|
+
* @returns {string|null} Warning message, or null when nothing is misplaced
|
|
136
|
+
*/
|
|
137
|
+
function detectMisplacedKeyManagement(env = {}, projectPackage = {}) {
|
|
138
|
+
const misplaced =
|
|
139
|
+
env['cds-caching']?.keyManagement ??
|
|
140
|
+
projectPackage['cds-caching']?.keyManagement
|
|
141
|
+
|
|
142
|
+
if (!misplaced) return null
|
|
143
|
+
|
|
144
|
+
return warnOnce(
|
|
145
|
+
'misplaced-keyManagement',
|
|
146
|
+
'cds-caching: a "cds-caching.keyManagement" block was found outside "cds.requires", where it is ignored. ' +
|
|
147
|
+
'Cache keys are therefore NOT user/tenant/locale aware. Move it into the individual cache config, e.g. ' +
|
|
148
|
+
'{"cds":{"requires":{"caching":{"keyManagement":{"isUserAware":true}}}}}. See docs/key-management.md.'
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
|
|
125
152
|
module.exports = {
|
|
126
153
|
normalizeCachingConfig,
|
|
127
154
|
getCachingRequiresEntries,
|
|
128
155
|
getStatisticsHandlerOptions,
|
|
129
156
|
isMetricsConfigured,
|
|
157
|
+
detectMisplacedKeyManagement,
|
|
130
158
|
resetDeprecationWarnings,
|
|
131
159
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
const cds = require('@sap/cds')
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Express guard requiring an authenticated user.
|
|
5
|
+
*
|
|
6
|
+
* The dashboard is served as static files outside CAP's service adapters, so it
|
|
7
|
+
* inherits no authorization from the model. Restricting `CachingApiService` alone
|
|
8
|
+
* would leave this route open.
|
|
9
|
+
*
|
|
10
|
+
* @param {object} req - Express request
|
|
11
|
+
* @param {object} res - Express response
|
|
12
|
+
* @param {Function} next - Downstream handler
|
|
13
|
+
*/
|
|
14
|
+
function requireAuthenticatedUser(req, res, next) {
|
|
15
|
+
const user = cds.context?.user
|
|
16
|
+
if (user?.is?.('authenticated-user')) return next()
|
|
17
|
+
|
|
18
|
+
res.status(401).set('WWW-Authenticate', 'Basic realm="cds-caching"').end()
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* CAP's own request middlewares (context, trace, auth, model), flattened and
|
|
23
|
+
* compacted so they can be spread into an `app.use()` call. These populate
|
|
24
|
+
* `cds.context.user`, which the guard above depends on.
|
|
25
|
+
*
|
|
26
|
+
* @returns {Function[]}
|
|
27
|
+
*/
|
|
28
|
+
function capRequestMiddlewares() {
|
|
29
|
+
return (cds.middlewares?.before ?? []).flat().filter(Boolean)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
module.exports = { requireAuthenticatedUser, capRequestMiddlewares }
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const TagResolver = require('../support/TagResolver');
|
|
2
2
|
const telemetry = require('../support/Telemetry');
|
|
3
|
+
const { redactHeaders } = require('../support/metricsSanitizer');
|
|
3
4
|
/**
|
|
4
5
|
* Manages CAP-specific cache operations
|
|
5
6
|
*/
|
|
@@ -19,6 +20,16 @@ class CapOperations {
|
|
|
19
20
|
this.log = log || console;
|
|
20
21
|
}
|
|
21
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Whether the full cache key may be echoed back in the `x-sap-cap-cache-key`
|
|
25
|
+
* response header. Off unless opted in, because the key is enough to target a
|
|
26
|
+
* specific cached entry from outside.
|
|
27
|
+
* @returns {boolean}
|
|
28
|
+
*/
|
|
29
|
+
debugHeadersEnabled() {
|
|
30
|
+
return this.runtimeConfigManager?.options?.debugHeaders === true;
|
|
31
|
+
}
|
|
32
|
+
|
|
22
33
|
/**
|
|
23
34
|
* Safely execute cache operations with error handling
|
|
24
35
|
* @param {Function} operation - The cache operation to execute
|
|
@@ -140,7 +151,7 @@ class CapOperations {
|
|
|
140
151
|
subject: request.subject,
|
|
141
152
|
query: request.query,
|
|
142
153
|
event: request.event,
|
|
143
|
-
headers: request.headers,
|
|
154
|
+
headers: redactHeaders(request.headers),
|
|
144
155
|
}),
|
|
145
156
|
cacheOptions: JSON.stringify(requestOptions)
|
|
146
157
|
};
|
|
@@ -283,7 +294,9 @@ class CapOperations {
|
|
|
283
294
|
req.cacheOptions = req.event ? this.extractFunctionCacheOptions(req, arguments[2]) : this.extractEntityCacheOptions(req, arguments[2]);
|
|
284
295
|
|
|
285
296
|
req.cacheKey = this.keyManager.createKey(req, {}, req.cacheOptions.key);
|
|
286
|
-
|
|
297
|
+
if (this.debugHeadersEnabled()) {
|
|
298
|
+
req.res?.setHeader('x-sap-cap-cache-key', req.cacheKey);
|
|
299
|
+
}
|
|
287
300
|
|
|
288
301
|
// Track cache operation timing
|
|
289
302
|
const startTime = process.hrtime();
|
|
@@ -4,6 +4,7 @@ const StatisticsPersistenceManager = require('./StatisticsPersistenceManager');
|
|
|
4
4
|
const telemetry = require('./Telemetry');
|
|
5
5
|
const { query } = require('@sap/cds/lib/env/defaults');
|
|
6
6
|
const { isPluginModelAvailable } = require('../util');
|
|
7
|
+
const { truncateMetadataFields, MAX_FIELD_LENGTH } = require('./metricsSanitizer');
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Class to handle all things caching statistics.
|
|
@@ -17,6 +18,7 @@ class CacheStatisticsHandler {
|
|
|
17
18
|
persistenceInterval: 10 * 1000, // 10 seconds
|
|
18
19
|
maxLatencies: 2000,
|
|
19
20
|
maxKeyMetrics: 1000, // Track top accessed keys
|
|
21
|
+
maxMetricFieldLength: MAX_FIELD_LENGTH, // Cap per free-text KeyMetrics field
|
|
20
22
|
keyMetricsEnabled: false,
|
|
21
23
|
metricsEnabled: false, // Main metrics enabled flag
|
|
22
24
|
...options
|
|
@@ -418,6 +420,10 @@ class CacheStatisticsHandler {
|
|
|
418
420
|
return;
|
|
419
421
|
}
|
|
420
422
|
|
|
423
|
+
// Single choke point for every operation path, so no caller can persist
|
|
424
|
+
// an unbounded serialized request into KeyMetrics.
|
|
425
|
+
metadata = truncateMetadataFields(metadata, this.options.maxMetricFieldLength);
|
|
426
|
+
|
|
421
427
|
if (!this.stats.current.keyAccess.has(key)) {
|
|
422
428
|
this.log.debug(`Creating new key stats for: ${key}`);
|
|
423
429
|
this.stats.current.keyAccess.set(key, {
|
|
@@ -37,13 +37,14 @@ class StatisticsPersistenceManager {
|
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
try {
|
|
40
|
-
const
|
|
40
|
+
const { Metrics } = cds.entities('plugin.cds_caching');
|
|
41
|
+
const existingHourly = await SELECT.one.from(Metrics)
|
|
41
42
|
.where({ ID: hourlyId, cache: this.cacheName });
|
|
42
43
|
|
|
43
44
|
if (!existingHourly) {
|
|
44
|
-
await this._createHourlyStats(stats, hourlyId, hourlyTimestamp);
|
|
45
|
+
await this._createHourlyStats(Metrics, stats, hourlyId, hourlyTimestamp);
|
|
45
46
|
} else {
|
|
46
|
-
await this._updateHourlyStats(stats, hourlyId, existingHourly);
|
|
47
|
+
await this._updateHourlyStats(Metrics, stats, hourlyId, existingHourly);
|
|
47
48
|
}
|
|
48
49
|
} catch (error) {
|
|
49
50
|
this.log.error(`Failed to persist hourly stats for cache ${this.cacheName}:`, error);
|
|
@@ -66,9 +67,10 @@ class StatisticsPersistenceManager {
|
|
|
66
67
|
|
|
67
68
|
this.log.debug(`Persisting ${keyAccess.size} key access records for cache ${this.cacheName}`);
|
|
68
69
|
|
|
70
|
+
const { KeyMetrics } = cds.entities('plugin.cds_caching');
|
|
69
71
|
for (const [key, keyStats] of keyAccess) {
|
|
70
72
|
try {
|
|
71
|
-
await this._persistKeyMetric(key, keyStats);
|
|
73
|
+
await this._persistKeyMetric(KeyMetrics, key, keyStats);
|
|
72
74
|
} catch (error) {
|
|
73
75
|
this.log.error(`Failed to persist key metric for key ${key}:`, error);
|
|
74
76
|
}
|
|
@@ -101,8 +103,8 @@ class StatisticsPersistenceManager {
|
|
|
101
103
|
* Create new hourly stats record
|
|
102
104
|
* @private
|
|
103
105
|
*/
|
|
104
|
-
async _createHourlyStats(stats, hourlyId, hourlyTimestamp) {
|
|
105
|
-
await INSERT.into(
|
|
106
|
+
async _createHourlyStats(Metrics, stats, hourlyId, hourlyTimestamp) {
|
|
107
|
+
await INSERT.into(Metrics).entries([{
|
|
106
108
|
ID: hourlyId,
|
|
107
109
|
cache: this.cacheName,
|
|
108
110
|
timestamp: hourlyTimestamp,
|
|
@@ -153,10 +155,10 @@ class StatisticsPersistenceManager {
|
|
|
153
155
|
* Update existing hourly stats record
|
|
154
156
|
* @private
|
|
155
157
|
*/
|
|
156
|
-
async _updateHourlyStats(stats, hourlyId, existingHourly) {
|
|
158
|
+
async _updateHourlyStats(Metrics, stats, hourlyId, existingHourly) {
|
|
157
159
|
const updatedStats = this._calculateUpdatedStats(stats, existingHourly);
|
|
158
160
|
|
|
159
|
-
await UPDATE(
|
|
161
|
+
await UPDATE(Metrics)
|
|
160
162
|
.set(updatedStats)
|
|
161
163
|
.where({ ID: hourlyId, cache: this.cacheName });
|
|
162
164
|
this.log.debug(`Updated existing hourly stats for cache ${this.cacheName}`);
|
|
@@ -169,19 +171,19 @@ class StatisticsPersistenceManager {
|
|
|
169
171
|
_calculateUpdatedStats(stats, existingHourly) {
|
|
170
172
|
const updatedStats = {
|
|
171
173
|
// Read-through metrics
|
|
172
|
-
hits: existingHourly.hits + stats.hits,
|
|
173
|
-
misses: existingHourly.misses + stats.misses,
|
|
174
|
-
errors: existingHourly.errors + stats.errors,
|
|
175
|
-
totalRequests: existingHourly.totalRequests + stats.totalRequests,
|
|
174
|
+
hits: (Number(existingHourly.hits) || 0) + stats.hits,
|
|
175
|
+
misses: (Number(existingHourly.misses) || 0) + stats.misses,
|
|
176
|
+
errors: (Number(existingHourly.errors) || 0) + stats.errors,
|
|
177
|
+
totalRequests: (Number(existingHourly.totalRequests) || 0) + stats.totalRequests,
|
|
176
178
|
|
|
177
179
|
// Native function metrics
|
|
178
|
-
nativeSets: existingHourly.nativeSets + stats.nativeSets,
|
|
179
|
-
nativeGets: existingHourly.nativeGets + stats.nativeGets,
|
|
180
|
-
nativeDeletes: existingHourly.nativeDeletes + stats.nativeDeletes,
|
|
181
|
-
nativeClears: existingHourly.nativeClears + stats.nativeClears,
|
|
182
|
-
nativeDeleteByTags: existingHourly.nativeDeleteByTags + stats.nativeDeleteByTags,
|
|
183
|
-
nativeErrors: existingHourly.nativeErrors + stats.nativeErrors,
|
|
184
|
-
totalNativeOperations: existingHourly.totalNativeOperations + stats.totalNativeOperations,
|
|
180
|
+
nativeSets: (Number(existingHourly.nativeSets) || 0) + stats.nativeSets,
|
|
181
|
+
nativeGets: (Number(existingHourly.nativeGets) || 0) + stats.nativeGets,
|
|
182
|
+
nativeDeletes: (Number(existingHourly.nativeDeletes) || 0) + stats.nativeDeletes,
|
|
183
|
+
nativeClears: (Number(existingHourly.nativeClears) || 0) + stats.nativeClears,
|
|
184
|
+
nativeDeleteByTags: (Number(existingHourly.nativeDeleteByTags) || 0) + stats.nativeDeleteByTags,
|
|
185
|
+
nativeErrors: (Number(existingHourly.nativeErrors) || 0) + stats.nativeErrors,
|
|
186
|
+
totalNativeOperations: (Number(existingHourly.totalNativeOperations) || 0) + stats.totalNativeOperations,
|
|
185
187
|
|
|
186
188
|
// Common metrics
|
|
187
189
|
memoryUsage: stats.memoryUsage,
|
|
@@ -194,7 +196,7 @@ class StatisticsPersistenceManager {
|
|
|
194
196
|
|
|
195
197
|
// Weighted average for hit latency
|
|
196
198
|
if (updatedStats.hits > 0) {
|
|
197
|
-
const existingHitLatencySum = existingHourly.avgHitLatency * existingHourly.hits;
|
|
199
|
+
const existingHitLatencySum = (Number(existingHourly.avgHitLatency) || 0) * (Number(existingHourly.hits) || 0);
|
|
198
200
|
const newHitLatencySum = stats.avgHitLatency * stats.hits;
|
|
199
201
|
updatedStats.avgHitLatency = (existingHitLatencySum + newHitLatencySum) / updatedStats.hits;
|
|
200
202
|
} else {
|
|
@@ -203,7 +205,7 @@ class StatisticsPersistenceManager {
|
|
|
203
205
|
|
|
204
206
|
// Weighted average for miss latency
|
|
205
207
|
if (updatedStats.misses > 0) {
|
|
206
|
-
const existingMissLatencySum = existingHourly.avgMissLatency * existingHourly.misses;
|
|
208
|
+
const existingMissLatencySum = (Number(existingHourly.avgMissLatency) || 0) * (Number(existingHourly.misses) || 0);
|
|
207
209
|
const newMissLatencySum = stats.avgMissLatency * stats.misses;
|
|
208
210
|
updatedStats.avgMissLatency = (existingMissLatencySum + newMissLatencySum) / updatedStats.misses;
|
|
209
211
|
} else {
|
|
@@ -211,12 +213,12 @@ class StatisticsPersistenceManager {
|
|
|
211
213
|
}
|
|
212
214
|
|
|
213
215
|
// Weighted average for read-through latency (combined hits and misses)
|
|
214
|
-
const existingCount = (existingHourly.hits || 0) + (existingHourly.misses || 0);
|
|
216
|
+
const existingCount = (Number(existingHourly.hits) || 0) + (Number(existingHourly.misses) || 0);
|
|
215
217
|
const newCount = (stats.hits || 0) + (stats.misses || 0);
|
|
216
218
|
const totalCount = existingCount + newCount;
|
|
217
219
|
if (totalCount > 0) {
|
|
218
220
|
updatedStats.avgReadThroughLatency =
|
|
219
|
-
((existingHourly.avgReadThroughLatency || 0) * existingCount +
|
|
221
|
+
((Number(existingHourly.avgReadThroughLatency) || 0) * existingCount +
|
|
220
222
|
(stats.avgReadThroughLatency || 0) * newCount) / totalCount;
|
|
221
223
|
} else {
|
|
222
224
|
updatedStats.avgReadThroughLatency = 0;
|
|
@@ -242,10 +244,10 @@ class StatisticsPersistenceManager {
|
|
|
242
244
|
}
|
|
243
245
|
|
|
244
246
|
// Update percentiles (use max/min of existing and current, with min using mergeMin)
|
|
245
|
-
updatedStats.maxHitLatency = Math.max(existingHourly.maxHitLatency || 0, stats.maxHitLatency || 0);
|
|
246
|
-
updatedStats.maxMissLatency = Math.max(existingHourly.maxMissLatency || 0, stats.maxMissLatency || 0);
|
|
247
|
-
updatedStats.minHitLatency = mergeMin(existingHourly.minHitLatency, stats.minHitLatency);
|
|
248
|
-
updatedStats.minMissLatency = mergeMin(existingHourly.minMissLatency, stats.minMissLatency);
|
|
247
|
+
updatedStats.maxHitLatency = Math.max(Number(existingHourly.maxHitLatency) || 0, stats.maxHitLatency || 0);
|
|
248
|
+
updatedStats.maxMissLatency = Math.max(Number(existingHourly.maxMissLatency) || 0, stats.maxMissLatency || 0);
|
|
249
|
+
updatedStats.minHitLatency = mergeMin(Number(existingHourly.minHitLatency) || 0, stats.minHitLatency);
|
|
250
|
+
updatedStats.minMissLatency = mergeMin(Number(existingHourly.minMissLatency) || 0, stats.minMissLatency);
|
|
249
251
|
|
|
250
252
|
return updatedStats;
|
|
251
253
|
}
|
|
@@ -254,16 +256,16 @@ class StatisticsPersistenceManager {
|
|
|
254
256
|
* Persist a single key metric
|
|
255
257
|
* @private
|
|
256
258
|
*/
|
|
257
|
-
async _persistKeyMetric(key, keyStats) {
|
|
259
|
+
async _persistKeyMetric(KeyMetrics, key, keyStats) {
|
|
258
260
|
const keyId = `key:${this.cacheName}:${key}`;
|
|
259
261
|
|
|
260
|
-
const existingKey = await SELECT.one.from(
|
|
262
|
+
const existingKey = await SELECT.one.from(KeyMetrics)
|
|
261
263
|
.where({ ID: keyId, cache: this.cacheName, keyName: key });
|
|
262
264
|
|
|
263
265
|
if (!existingKey) {
|
|
264
|
-
await this._createKeyMetric(key, keyStats, keyId);
|
|
266
|
+
await this._createKeyMetric(KeyMetrics, key, keyStats, keyId);
|
|
265
267
|
} else {
|
|
266
|
-
await this._updateKeyMetric(key, keyStats, keyId, existingKey);
|
|
268
|
+
await this._updateKeyMetric(KeyMetrics, key, keyStats, keyId, existingKey);
|
|
267
269
|
}
|
|
268
270
|
}
|
|
269
271
|
|
|
@@ -271,9 +273,9 @@ class StatisticsPersistenceManager {
|
|
|
271
273
|
* Create new key metric record
|
|
272
274
|
* @private
|
|
273
275
|
*/
|
|
274
|
-
async _createKeyMetric(key, keyStats, keyId) {
|
|
276
|
+
async _createKeyMetric(KeyMetrics, key, keyStats, keyId) {
|
|
275
277
|
|
|
276
|
-
await INSERT.into(
|
|
278
|
+
await INSERT.into(KeyMetrics).entries([{
|
|
277
279
|
ID: keyId,
|
|
278
280
|
cache: this.cacheName,
|
|
279
281
|
keyName: key,
|
|
@@ -339,41 +341,41 @@ class StatisticsPersistenceManager {
|
|
|
339
341
|
* Update existing key metric record
|
|
340
342
|
* @private
|
|
341
343
|
*/
|
|
342
|
-
async _updateKeyMetric(key, keyStats, keyId, existingKey) {
|
|
343
|
-
const totalHits = existingKey.hits + keyStats.hits;
|
|
344
|
-
const totalMisses = existingKey.misses + keyStats.misses;
|
|
344
|
+
async _updateKeyMetric(KeyMetrics, key, keyStats, keyId, existingKey) {
|
|
345
|
+
const totalHits = (Number(existingKey.hits) || 0) + keyStats.hits;
|
|
346
|
+
const totalMisses = (Number(existingKey.misses) || 0) + keyStats.misses;
|
|
345
347
|
|
|
346
348
|
const updatedKeyStats = {
|
|
347
349
|
hits: totalHits,
|
|
348
350
|
misses: totalMisses,
|
|
349
|
-
errors: (existingKey.errors || 0) + (keyStats.errors || 0),
|
|
351
|
+
errors: (Number(existingKey.errors) || 0) + (keyStats.errors || 0),
|
|
350
352
|
totalRequests: totalHits + totalMisses,
|
|
351
353
|
lastAccess: new Date(keyStats.lastAccess).toISOString(),
|
|
352
354
|
|
|
353
355
|
// Native function metrics
|
|
354
|
-
nativeHits: (existingKey.nativeHits || 0) + (keyStats.nativeHits || 0),
|
|
355
|
-
nativeMisses: (existingKey.nativeMisses || 0) + (keyStats.nativeMisses || 0),
|
|
356
|
-
nativeSets: (existingKey.nativeSets || 0) + (keyStats.nativeSets || 0),
|
|
357
|
-
nativeDeletes: (existingKey.nativeDeletes || 0) + (keyStats.nativeDeletes || 0),
|
|
358
|
-
nativeClears: (existingKey.nativeClears || 0) + (keyStats.nativeClears || 0),
|
|
359
|
-
nativeDeleteByTags: (existingKey.nativeDeleteByTags || 0) + (keyStats.nativeDeleteByTags || 0),
|
|
360
|
-
nativeErrors: (existingKey.nativeErrors || 0) + (keyStats.nativeErrors || 0),
|
|
361
|
-
totalNativeOperations: (existingKey.totalNativeOperations || 0) + (keyStats.totalNativeOperations || 0),
|
|
356
|
+
nativeHits: (Number(existingKey.nativeHits) || 0) + (keyStats.nativeHits || 0),
|
|
357
|
+
nativeMisses: (Number(existingKey.nativeMisses) || 0) + (keyStats.nativeMisses || 0),
|
|
358
|
+
nativeSets: (Number(existingKey.nativeSets) || 0) + (keyStats.nativeSets || 0),
|
|
359
|
+
nativeDeletes: (Number(existingKey.nativeDeletes) || 0) + (keyStats.nativeDeletes || 0),
|
|
360
|
+
nativeClears: (Number(existingKey.nativeClears) || 0) + (keyStats.nativeClears || 0),
|
|
361
|
+
nativeDeleteByTags: (Number(existingKey.nativeDeleteByTags) || 0) + (keyStats.nativeDeleteByTags || 0),
|
|
362
|
+
nativeErrors: (Number(existingKey.nativeErrors) || 0) + (keyStats.nativeErrors || 0),
|
|
363
|
+
totalNativeOperations: (Number(existingKey.totalNativeOperations) || 0) + (keyStats.totalNativeOperations || 0),
|
|
362
364
|
|
|
363
365
|
// Weighted average for hit latency
|
|
364
366
|
avgHitLatency: totalHits > 0
|
|
365
|
-
? ((existingKey.avgHitLatency * existingKey.hits) + (keyStats.avgHitLatency * keyStats.hits)) / totalHits
|
|
367
|
+
? (((Number(existingKey.avgHitLatency) || 0) * (Number(existingKey.hits) || 0)) + (keyStats.avgHitLatency * keyStats.hits)) / totalHits
|
|
366
368
|
: 0,
|
|
367
369
|
// Weighted average for miss latency
|
|
368
370
|
avgMissLatency: totalMisses > 0
|
|
369
|
-
? ((existingKey.avgMissLatency * existingKey.misses) + (keyStats.avgMissLatency * keyStats.misses)) / totalMisses
|
|
371
|
+
? (((Number(existingKey.avgMissLatency) || 0) * (Number(existingKey.misses) || 0)) + (keyStats.avgMissLatency * keyStats.misses)) / totalMisses
|
|
370
372
|
: 0,
|
|
371
373
|
|
|
372
374
|
// Update percentiles (use max/min of existing and current, with min using mergeMin)
|
|
373
|
-
minHitLatency: mergeMin(existingKey.minHitLatency, keyStats.minHitLatency),
|
|
374
|
-
maxHitLatency: Math.max(existingKey.maxHitLatency || 0, keyStats.maxHitLatency || 0),
|
|
375
|
-
minMissLatency: mergeMin(existingKey.minMissLatency, keyStats.minMissLatency),
|
|
376
|
-
maxMissLatency: Math.max(existingKey.maxMissLatency || 0, keyStats.maxMissLatency || 0)
|
|
375
|
+
minHitLatency: mergeMin(Number(existingKey.minHitLatency) || 0, keyStats.minHitLatency),
|
|
376
|
+
maxHitLatency: Math.max(Number(existingKey.maxHitLatency) || 0, keyStats.maxHitLatency || 0),
|
|
377
|
+
minMissLatency: mergeMin(Number(existingKey.minMissLatency) || 0, keyStats.minMissLatency),
|
|
378
|
+
maxMissLatency: Math.max(Number(existingKey.maxMissLatency) || 0, keyStats.maxMissLatency || 0)
|
|
377
379
|
};
|
|
378
380
|
|
|
379
381
|
// Update context information if new data is available
|
|
@@ -385,7 +387,7 @@ class StatisticsPersistenceManager {
|
|
|
385
387
|
if (keyStats.user) updatedKeyStats.user = keyStats.user;
|
|
386
388
|
if (keyStats.locale) updatedKeyStats.locale = keyStats.locale;
|
|
387
389
|
if (keyStats.cacheOptions) updatedKeyStats.cacheOptions = keyStats.cacheOptions;
|
|
388
|
-
await UPDATE(
|
|
390
|
+
await UPDATE(KeyMetrics)
|
|
389
391
|
.set(updatedKeyStats)
|
|
390
392
|
.where({ ID: keyId, cache: this.cacheName, keyName: key });
|
|
391
393
|
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sanitizing helpers for data that gets persisted into the KeyMetrics entity.
|
|
3
|
+
*
|
|
4
|
+
* KeyMetrics rows are long-lived and readable through the management API, so
|
|
5
|
+
* credentials must never reach them and single fields must not grow unbounded.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Header names whose values are credentials or session material. */
|
|
9
|
+
const SENSITIVE_HEADERS = new Set([
|
|
10
|
+
'authorization',
|
|
11
|
+
'proxy-authorization',
|
|
12
|
+
'cookie',
|
|
13
|
+
'set-cookie',
|
|
14
|
+
'x-api-key',
|
|
15
|
+
'apikey',
|
|
16
|
+
'x-csrf-token',
|
|
17
|
+
'x-xsrf-token',
|
|
18
|
+
'sap-passport',
|
|
19
|
+
]);
|
|
20
|
+
|
|
21
|
+
const REDACTED = '[redacted]';
|
|
22
|
+
|
|
23
|
+
/** Default cap for free-text metric fields (metadata, query, subject, context). */
|
|
24
|
+
const MAX_FIELD_LENGTH = 8192;
|
|
25
|
+
|
|
26
|
+
/** Fields that carry serialized request/query detail and can grow without bound. */
|
|
27
|
+
const TRUNCATED_FIELDS = ['metadata', 'query', 'subject', 'context', 'cacheOptions'];
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Copy headers with credential-bearing values replaced.
|
|
31
|
+
* @param {object} headers - Raw header map (case-insensitive keys)
|
|
32
|
+
* @returns {object|undefined} Redacted copy, or undefined if there was nothing to copy
|
|
33
|
+
*/
|
|
34
|
+
function redactHeaders(headers) {
|
|
35
|
+
if (!headers || typeof headers !== 'object') return undefined;
|
|
36
|
+
|
|
37
|
+
const safe = {};
|
|
38
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
39
|
+
safe[name] = SENSITIVE_HEADERS.has(name.toLowerCase()) ? REDACTED : value;
|
|
40
|
+
}
|
|
41
|
+
return safe;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Truncate a single field to `max` characters, marking where it was cut.
|
|
46
|
+
* @param {any} value - Field value; non-strings are returned unchanged
|
|
47
|
+
* @param {number} [max=MAX_FIELD_LENGTH] - Maximum length to keep
|
|
48
|
+
* @returns {any}
|
|
49
|
+
*/
|
|
50
|
+
function truncateField(value, max = MAX_FIELD_LENGTH) {
|
|
51
|
+
if (typeof value !== 'string' || value.length <= max) return value;
|
|
52
|
+
return `${value.slice(0, max)}…[truncated ${value.length - max} chars]`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Bound the free-text fields of a key-metrics metadata object.
|
|
57
|
+
* Returns the input unchanged when nothing needs truncating, so the common
|
|
58
|
+
* path allocates nothing.
|
|
59
|
+
* @param {object} metadata - Metadata as assembled by the operation layers
|
|
60
|
+
* @param {number} [max=MAX_FIELD_LENGTH] - Maximum length per field
|
|
61
|
+
* @returns {object}
|
|
62
|
+
*/
|
|
63
|
+
function truncateMetadataFields(metadata, max = MAX_FIELD_LENGTH) {
|
|
64
|
+
if (!metadata || typeof metadata !== 'object') return metadata;
|
|
65
|
+
|
|
66
|
+
let copy = null;
|
|
67
|
+
for (const field of TRUNCATED_FIELDS) {
|
|
68
|
+
const value = metadata[field];
|
|
69
|
+
if (typeof value === 'string' && value.length > max) {
|
|
70
|
+
copy ??= { ...metadata };
|
|
71
|
+
copy[field] = truncateField(value, max);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return copy ?? metadata;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
module.exports = {
|
|
78
|
+
SENSITIVE_HEADERS,
|
|
79
|
+
REDACTED,
|
|
80
|
+
MAX_FIELD_LENGTH,
|
|
81
|
+
redactHeaders,
|
|
82
|
+
truncateField,
|
|
83
|
+
truncateMetadataFields,
|
|
84
|
+
};
|
package/package.json
CHANGED
|
@@ -4,6 +4,13 @@ const { isPluginModelAvailable } = require('../lib/util')
|
|
|
4
4
|
|
|
5
5
|
const DEFAULT_TENANT = '_default';
|
|
6
6
|
|
|
7
|
+
/** Upper bound for `getEntries`, regardless of the requested `top`. */
|
|
8
|
+
const MAX_ENTRIES_PER_PAGE = 1000;
|
|
9
|
+
const DEFAULT_ENTRIES_PER_PAGE = 100;
|
|
10
|
+
|
|
11
|
+
/** Upper bound for a single `setEntry` value, in bytes. */
|
|
12
|
+
const MAX_ENTRY_VALUE_BYTES = 1024 * 1024;
|
|
13
|
+
|
|
7
14
|
class CachingApiService extends cds.ApplicationService {
|
|
8
15
|
log = cds.log('cds-caching');
|
|
9
16
|
|
|
@@ -20,8 +27,8 @@ class CachingApiService extends cds.ApplicationService {
|
|
|
20
27
|
// Handle setMetricsEnabled action
|
|
21
28
|
this.on('setMetricsEnabled', async (req) => {
|
|
22
29
|
const { enabled } = req.data
|
|
23
|
-
const
|
|
24
|
-
const
|
|
30
|
+
const cacheService = await this._connectToCache(req);
|
|
31
|
+
const cache = this._cacheName(req);
|
|
25
32
|
try {
|
|
26
33
|
await cacheService.setMetricsEnabled(enabled)
|
|
27
34
|
req.info(`Metrics ${enabled ? 'enabled' : 'disabled'} for cache ${cache}`);
|
|
@@ -35,8 +42,8 @@ class CachingApiService extends cds.ApplicationService {
|
|
|
35
42
|
// Handle setKeyMetricsEnabled action
|
|
36
43
|
this.on('setKeyMetricsEnabled', async (req) => {
|
|
37
44
|
const { enabled } = req.data
|
|
38
|
-
const
|
|
39
|
-
const
|
|
45
|
+
const cacheService = await this._connectToCache(req);
|
|
46
|
+
const cache = this._cacheName(req);
|
|
40
47
|
try {
|
|
41
48
|
await cacheService.setKeyMetricsEnabled(enabled)
|
|
42
49
|
req.info(`Key metrics ${enabled ? 'enabled' : 'disabled'} for cache ${cache}`);
|
|
@@ -49,17 +56,27 @@ class CachingApiService extends cds.ApplicationService {
|
|
|
49
56
|
|
|
50
57
|
// Handle getCacheEntries function
|
|
51
58
|
this.on('getEntries', async (req) => {
|
|
52
|
-
const
|
|
59
|
+
const cacheService = await this._connectToCache(req);
|
|
60
|
+
const { top, skip } = req.data;
|
|
61
|
+
|
|
62
|
+
// Bounded on purpose: an unbounded iteration would pull the whole
|
|
63
|
+
// cache into memory and can be used to exhaust the server.
|
|
64
|
+
const requested = Number.isInteger(top) && top > 0 ? top : DEFAULT_ENTRIES_PER_PAGE;
|
|
65
|
+
const limit = Math.min(requested, MAX_ENTRIES_PER_PAGE);
|
|
66
|
+
const offset = Number.isInteger(skip) && skip > 0 ? skip : 0;
|
|
67
|
+
|
|
53
68
|
try {
|
|
54
|
-
const cacheService = await cds.connect.to(cache);
|
|
55
69
|
const entries = [];
|
|
70
|
+
let seen = 0;
|
|
56
71
|
for await (const [key, value] of cacheService.iterator()) {
|
|
72
|
+
if (seen++ < offset) continue;
|
|
57
73
|
entries.push({
|
|
58
74
|
entryKey: key,
|
|
59
75
|
value: JSON.stringify(value.value),
|
|
60
76
|
timestamp: value.timestamp,
|
|
61
77
|
tags: value.tags,
|
|
62
78
|
});
|
|
79
|
+
if (entries.length >= limit) break;
|
|
63
80
|
}
|
|
64
81
|
return entries;
|
|
65
82
|
} catch (error) {
|
|
@@ -71,8 +88,7 @@ class CachingApiService extends cds.ApplicationService {
|
|
|
71
88
|
// Handle getCacheEntry function
|
|
72
89
|
this.on('getEntry', async (req) => {
|
|
73
90
|
const { key } = req.data;
|
|
74
|
-
const
|
|
75
|
-
const cacheService = await cds.connect.to(cache);
|
|
91
|
+
const cacheService = await this._connectToCache(req);
|
|
76
92
|
const value = await cacheService.get(key);
|
|
77
93
|
return {
|
|
78
94
|
value: value,
|
|
@@ -82,8 +98,13 @@ class CachingApiService extends cds.ApplicationService {
|
|
|
82
98
|
// Handle setCacheEntry action
|
|
83
99
|
this.on('setEntry', async (req) => {
|
|
84
100
|
const { key, value, ttl } = req.data;
|
|
85
|
-
const
|
|
86
|
-
|
|
101
|
+
const cacheService = await this._connectToCache(req);
|
|
102
|
+
|
|
103
|
+
const size = Buffer.byteLength(String(value ?? ''), 'utf8');
|
|
104
|
+
if (size > MAX_ENTRY_VALUE_BYTES) {
|
|
105
|
+
return req.reject(400, `Value exceeds the maximum of ${MAX_ENTRY_VALUE_BYTES} bytes (got ${size})`);
|
|
106
|
+
}
|
|
107
|
+
|
|
87
108
|
await cacheService.set(key, value, { ttl: ttl });
|
|
88
109
|
req.info(`Cache entry set successfully: ${key}`);
|
|
89
110
|
return true;
|
|
@@ -92,8 +113,7 @@ class CachingApiService extends cds.ApplicationService {
|
|
|
92
113
|
// Handle deleteCacheEntry action
|
|
93
114
|
this.on('deleteEntry', async (req) => {
|
|
94
115
|
const { key } = req.data;
|
|
95
|
-
const
|
|
96
|
-
const cacheService = await cds.connect.to(cache);
|
|
116
|
+
const cacheService = await this._connectToCache(req);
|
|
97
117
|
await cacheService.delete(key);
|
|
98
118
|
req.info(`Cache entry deleted successfully: ${key}`);
|
|
99
119
|
return true;
|
|
@@ -101,8 +121,8 @@ class CachingApiService extends cds.ApplicationService {
|
|
|
101
121
|
|
|
102
122
|
// Handle clearCache action
|
|
103
123
|
this.on('clear', async (req) => {
|
|
104
|
-
const
|
|
105
|
-
const
|
|
124
|
+
const cacheService = await this._connectToCache(req);
|
|
125
|
+
const cache = this._cacheName(req);
|
|
106
126
|
await cacheService.clear();
|
|
107
127
|
req.info(`Cache cleared successfully: ${cache}`);
|
|
108
128
|
return true;
|
|
@@ -110,8 +130,8 @@ class CachingApiService extends cds.ApplicationService {
|
|
|
110
130
|
|
|
111
131
|
// Handle clearKeyMetrics action
|
|
112
132
|
this.on('clearKeyMetrics', async (req) => {
|
|
113
|
-
const
|
|
114
|
-
const
|
|
133
|
+
const cacheService = await this._connectToCache(req);
|
|
134
|
+
const cache = this._cacheName(req);
|
|
115
135
|
await cacheService.clearKeyMetrics();
|
|
116
136
|
req.info(`Key metrics cleared successfully: ${cache}`);
|
|
117
137
|
return true;
|
|
@@ -119,8 +139,8 @@ class CachingApiService extends cds.ApplicationService {
|
|
|
119
139
|
|
|
120
140
|
// Handle clearMetrics action
|
|
121
141
|
this.on('clearMetrics', async (req) => {
|
|
122
|
-
const
|
|
123
|
-
const
|
|
142
|
+
const cacheService = await this._connectToCache(req);
|
|
143
|
+
const cache = this._cacheName(req);
|
|
124
144
|
await cacheService.clearMetrics();
|
|
125
145
|
req.info(`Metrics cleared successfully: ${cache}`);
|
|
126
146
|
return true;
|
|
@@ -129,6 +149,46 @@ class CachingApiService extends cds.ApplicationService {
|
|
|
129
149
|
await super.init()
|
|
130
150
|
}
|
|
131
151
|
|
|
152
|
+
/**
|
|
153
|
+
* Names of all configured cds-caching services. Used as an allow-list so a
|
|
154
|
+
* caller cannot coerce `cds.connect.to()` into connecting to an unrelated
|
|
155
|
+
* business service by passing its name as the cache key.
|
|
156
|
+
* @returns {Set<string>}
|
|
157
|
+
*/
|
|
158
|
+
_allowedCaches() {
|
|
159
|
+
return new Set(
|
|
160
|
+
Object.entries(cds.env.requires || {})
|
|
161
|
+
.filter(([, config]) => config?.impl === 'cds-caching')
|
|
162
|
+
.map(([name]) => name)
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Name of the cache addressed by the request key.
|
|
168
|
+
* @param {object} req - Inbound request carrying the Caches key
|
|
169
|
+
* @returns {string|undefined}
|
|
170
|
+
*/
|
|
171
|
+
_cacheName(req) {
|
|
172
|
+
const param = req.params?.[0];
|
|
173
|
+
return typeof param === 'string' ? param : param?.name;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Resolve the cache addressed by the request key and connect to it.
|
|
178
|
+
* Rejects with 404 for anything that is not a configured cache.
|
|
179
|
+
* @param {object} req - Inbound request carrying the Caches key
|
|
180
|
+
* @returns {Promise<object>} The connected CachingService
|
|
181
|
+
*/
|
|
182
|
+
async _connectToCache(req) {
|
|
183
|
+
const name = this._cacheName(req);
|
|
184
|
+
|
|
185
|
+
if (!name || !this._allowedCaches().has(name)) {
|
|
186
|
+
return req.reject(404, `Unknown cache: ${name}`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return cds.connect.to(name);
|
|
190
|
+
}
|
|
191
|
+
|
|
132
192
|
/**
|
|
133
193
|
* Ensure cache entries exist in the current tenant's DB.
|
|
134
194
|
* Called lazily in MTX mode on first dashboard access.
|