cds-error-outbox 1.0.1 → 1.0.2
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/.github/workflows/publish.yml +29 -0
- package/README.md +311 -311
- package/config/defaults.js +46 -46
- package/db/model.cds +18 -18
- package/index.cds +1 -1
- package/index.js +14 -14
- package/lib/bootstrap.js +71 -71
- package/lib/config.js +67 -67
- package/lib/dedup.js +91 -91
- package/lib/formatter.js +214 -214
- package/lib/interceptor.js +59 -59
- package/lib/scheduler.js +125 -125
- package/package.json +40 -34
- package/providers/index.js +40 -40
- package/providers/mock.js +23 -23
- package/providers/o365.js +164 -164
- package/providers/smtp.js +71 -71
- package/tests/config.test.js +92 -0
- package/tests/dedup.test.js +159 -0
- package/tests/formatter.test.js +90 -0
- package/tests/scheduler.test.js +147 -0
package/config/defaults.js
CHANGED
|
@@ -1,46 +1,46 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
module.exports = {
|
|
4
|
-
enabled: true,
|
|
5
|
-
|
|
6
|
-
/** How often (ms) the batch job runs to send unsent errors. */
|
|
7
|
-
interval: 300000,
|
|
8
|
-
|
|
9
|
-
/** Maximum number of unsent errors to include per email batch. */
|
|
10
|
-
batchSize: 50,
|
|
11
|
-
|
|
12
|
-
dedup: {
|
|
13
|
-
/** Whether to deduplicate errors by hash within the rolling window. */
|
|
14
|
-
enabled: true,
|
|
15
|
-
|
|
16
|
-
/** Only deduplicate within this time window (minutes). */
|
|
17
|
-
windowMinutes: 10
|
|
18
|
-
},
|
|
19
|
-
|
|
20
|
-
mail: {
|
|
21
|
-
/** Email provider: 'o365' | 'smtp' | 'mock' */
|
|
22
|
-
provider: 'mock',
|
|
23
|
-
|
|
24
|
-
/** Sender address (required for o365 and smtp). */
|
|
25
|
-
from: '',
|
|
26
|
-
|
|
27
|
-
/** Recipient address(es) — comma-separated for multiple (required for o365 and smtp). */
|
|
28
|
-
to: '',
|
|
29
|
-
|
|
30
|
-
/** === O365 / Microsoft Graph API options === */
|
|
31
|
-
tenantId: '',
|
|
32
|
-
clientId: '',
|
|
33
|
-
clientSecret: '',
|
|
34
|
-
|
|
35
|
-
/** === SMTP options (used only when provider = 'smtp') === */
|
|
36
|
-
smtp: {
|
|
37
|
-
host: '',
|
|
38
|
-
port: 587,
|
|
39
|
-
secure: false,
|
|
40
|
-
auth: {
|
|
41
|
-
user: '',
|
|
42
|
-
pass: ''
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
};
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
module.exports = {
|
|
4
|
+
enabled: true,
|
|
5
|
+
|
|
6
|
+
/** How often (ms) the batch job runs to send unsent errors. */
|
|
7
|
+
interval: 300000,
|
|
8
|
+
|
|
9
|
+
/** Maximum number of unsent errors to include per email batch. */
|
|
10
|
+
batchSize: 50,
|
|
11
|
+
|
|
12
|
+
dedup: {
|
|
13
|
+
/** Whether to deduplicate errors by hash within the rolling window. */
|
|
14
|
+
enabled: true,
|
|
15
|
+
|
|
16
|
+
/** Only deduplicate within this time window (minutes). */
|
|
17
|
+
windowMinutes: 10
|
|
18
|
+
},
|
|
19
|
+
|
|
20
|
+
mail: {
|
|
21
|
+
/** Email provider: 'o365' | 'smtp' | 'mock' */
|
|
22
|
+
provider: 'mock',
|
|
23
|
+
|
|
24
|
+
/** Sender address (required for o365 and smtp). */
|
|
25
|
+
from: '',
|
|
26
|
+
|
|
27
|
+
/** Recipient address(es) — comma-separated for multiple (required for o365 and smtp). */
|
|
28
|
+
to: '',
|
|
29
|
+
|
|
30
|
+
/** === O365 / Microsoft Graph API options === */
|
|
31
|
+
tenantId: '',
|
|
32
|
+
clientId: '',
|
|
33
|
+
clientSecret: '',
|
|
34
|
+
|
|
35
|
+
/** === SMTP options (used only when provider = 'smtp') === */
|
|
36
|
+
smtp: {
|
|
37
|
+
host: '',
|
|
38
|
+
port: 587,
|
|
39
|
+
secure: false,
|
|
40
|
+
auth: {
|
|
41
|
+
user: '',
|
|
42
|
+
pass: ''
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
};
|
package/db/model.cds
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
|
-
namespace error.outbox;
|
|
2
|
-
|
|
3
|
-
entity Errors {
|
|
4
|
-
key ID : UUID;
|
|
5
|
-
|
|
6
|
-
hash : String(64);
|
|
7
|
-
service : String;
|
|
8
|
-
action : String;
|
|
9
|
-
|
|
10
|
-
message : LargeString;
|
|
11
|
-
stack : LargeString;
|
|
12
|
-
|
|
13
|
-
count : Integer;
|
|
14
|
-
firstSeen : Timestamp;
|
|
15
|
-
lastSeen : Timestamp;
|
|
16
|
-
|
|
17
|
-
sent : Boolean default false;
|
|
18
|
-
}
|
|
1
|
+
namespace error.outbox;
|
|
2
|
+
|
|
3
|
+
entity Errors {
|
|
4
|
+
key ID : UUID;
|
|
5
|
+
|
|
6
|
+
hash : String(64);
|
|
7
|
+
service : String;
|
|
8
|
+
action : String;
|
|
9
|
+
|
|
10
|
+
message : LargeString;
|
|
11
|
+
stack : LargeString;
|
|
12
|
+
|
|
13
|
+
count : Integer;
|
|
14
|
+
firstSeen : Timestamp;
|
|
15
|
+
lastSeen : Timestamp;
|
|
16
|
+
|
|
17
|
+
sent : Boolean default false;
|
|
18
|
+
}
|
package/index.cds
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
using from './db/model';
|
|
1
|
+
using from './db/model';
|
package/index.js
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* cds-error-outbox — CAP plugin entry point.
|
|
5
|
-
*
|
|
6
|
-
* CAP automatically requires this file when the package is installed,
|
|
7
|
-
* because package.json declares `"cds": { "plugin": true }`.
|
|
8
|
-
*
|
|
9
|
-
* The bootstrap.initialize() call registers CAP lifecycle listeners
|
|
10
|
-
* (cds.on('serving') and cds.on('served')) so no blocking work happens here.
|
|
11
|
-
*/
|
|
12
|
-
const { initialize } = require('./lib/bootstrap');
|
|
13
|
-
|
|
14
|
-
initialize();
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* cds-error-outbox — CAP plugin entry point.
|
|
5
|
+
*
|
|
6
|
+
* CAP automatically requires this file when the package is installed,
|
|
7
|
+
* because package.json declares `"cds": { "plugin": true }`.
|
|
8
|
+
*
|
|
9
|
+
* The bootstrap.initialize() call registers CAP lifecycle listeners
|
|
10
|
+
* (cds.on('serving') and cds.on('served')) so no blocking work happens here.
|
|
11
|
+
*/
|
|
12
|
+
const { initialize } = require('./lib/bootstrap');
|
|
13
|
+
|
|
14
|
+
initialize();
|
package/lib/bootstrap.js
CHANGED
|
@@ -1,71 +1,71 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const cds = require('@sap/cds');
|
|
4
|
-
const { loadConfig } = require('./config');
|
|
5
|
-
const { attach } = require('./interceptor');
|
|
6
|
-
const { start } = require('./scheduler');
|
|
7
|
-
const { getProvider } = require('../providers');
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Initialize the cds-error-outbox plugin.
|
|
11
|
-
*
|
|
12
|
-
* Called once at plugin load time (from index.js).
|
|
13
|
-
* Uses CAP lifecycle events to ensure initialization order:
|
|
14
|
-
*
|
|
15
|
-
* cds.on('serving') → attach error interceptor to each served service
|
|
16
|
-
* cds.on('served') → start scheduler (DB is fully connected at this point)
|
|
17
|
-
*/
|
|
18
|
-
function initialize() {
|
|
19
|
-
// ── Load and validate config ─────────────────────────────────────────────
|
|
20
|
-
let config;
|
|
21
|
-
try {
|
|
22
|
-
config = loadConfig();
|
|
23
|
-
} catch (err) {
|
|
24
|
-
console.error('[cds-error-outbox] Failed to load config — plugin disabled:', err.message);
|
|
25
|
-
return;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
if (!config.enabled) {
|
|
29
|
-
console.info('[cds-error-outbox] Plugin is disabled (config.enabled = false).');
|
|
30
|
-
return;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
// ── Resolve email provider ───────────────────────────────────────────────
|
|
34
|
-
let provider;
|
|
35
|
-
try {
|
|
36
|
-
provider = getProvider(config);
|
|
37
|
-
} catch (err) {
|
|
38
|
-
console.error('[cds-error-outbox] Failed to load email provider — plugin disabled:', err.message);
|
|
39
|
-
return;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
// ── Attach interceptor to every served service ───────────────────────────
|
|
43
|
-
cds.on('serving', (srv) => {
|
|
44
|
-
try {
|
|
45
|
-
attach(srv, config);
|
|
46
|
-
} catch (err) {
|
|
47
|
-
console.error(
|
|
48
|
-
`[cds-error-outbox] Failed to attach interceptor to service "${srv.name}":`,
|
|
49
|
-
err.message
|
|
50
|
-
);
|
|
51
|
-
}
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
// ── Start scheduler after all services + DB are ready ───────────────────
|
|
55
|
-
// cds.on('served') fires once after ALL services have been bootstrapped
|
|
56
|
-
// and cds.db is guaranteed to be available.
|
|
57
|
-
cds.on('served', () => {
|
|
58
|
-
try {
|
|
59
|
-
start(config, provider);
|
|
60
|
-
} catch (err) {
|
|
61
|
-
console.error('[cds-error-outbox] Failed to start scheduler:', err.message);
|
|
62
|
-
}
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
console.info(
|
|
66
|
-
`[cds-error-outbox] Plugin initialized — ` +
|
|
67
|
-
`provider: ${config.mail.provider}, interval: ${config.interval}ms`
|
|
68
|
-
);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
module.exports = { initialize };
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const cds = require('@sap/cds');
|
|
4
|
+
const { loadConfig } = require('./config');
|
|
5
|
+
const { attach } = require('./interceptor');
|
|
6
|
+
const { start } = require('./scheduler');
|
|
7
|
+
const { getProvider } = require('../providers');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Initialize the cds-error-outbox plugin.
|
|
11
|
+
*
|
|
12
|
+
* Called once at plugin load time (from index.js).
|
|
13
|
+
* Uses CAP lifecycle events to ensure initialization order:
|
|
14
|
+
*
|
|
15
|
+
* cds.on('serving') → attach error interceptor to each served service
|
|
16
|
+
* cds.on('served') → start scheduler (DB is fully connected at this point)
|
|
17
|
+
*/
|
|
18
|
+
function initialize() {
|
|
19
|
+
// ── Load and validate config ─────────────────────────────────────────────
|
|
20
|
+
let config;
|
|
21
|
+
try {
|
|
22
|
+
config = loadConfig();
|
|
23
|
+
} catch (err) {
|
|
24
|
+
console.error('[cds-error-outbox] Failed to load config — plugin disabled:', err.message);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (!config.enabled) {
|
|
29
|
+
console.info('[cds-error-outbox] Plugin is disabled (config.enabled = false).');
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ── Resolve email provider ───────────────────────────────────────────────
|
|
34
|
+
let provider;
|
|
35
|
+
try {
|
|
36
|
+
provider = getProvider(config);
|
|
37
|
+
} catch (err) {
|
|
38
|
+
console.error('[cds-error-outbox] Failed to load email provider — plugin disabled:', err.message);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ── Attach interceptor to every served service ───────────────────────────
|
|
43
|
+
cds.on('serving', (srv) => {
|
|
44
|
+
try {
|
|
45
|
+
attach(srv, config);
|
|
46
|
+
} catch (err) {
|
|
47
|
+
console.error(
|
|
48
|
+
`[cds-error-outbox] Failed to attach interceptor to service "${srv.name}":`,
|
|
49
|
+
err.message
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// ── Start scheduler after all services + DB are ready ───────────────────
|
|
55
|
+
// cds.on('served') fires once after ALL services have been bootstrapped
|
|
56
|
+
// and cds.db is guaranteed to be available.
|
|
57
|
+
cds.on('served', () => {
|
|
58
|
+
try {
|
|
59
|
+
start(config, provider);
|
|
60
|
+
} catch (err) {
|
|
61
|
+
console.error('[cds-error-outbox] Failed to start scheduler:', err.message);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
console.info(
|
|
66
|
+
`[cds-error-outbox] Plugin initialized — ` +
|
|
67
|
+
`provider: ${config.mail.provider}, interval: ${config.interval}ms`
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
module.exports = { initialize };
|
package/lib/config.js
CHANGED
|
@@ -1,67 +1,67 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const cds = require('@sap/cds');
|
|
4
|
-
const defaults = require('../config/defaults');
|
|
5
|
-
|
|
6
|
-
let _config = null;
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Recursively deep-merge source into target.
|
|
10
|
-
* Arrays in source replace arrays in target (no merge).
|
|
11
|
-
*
|
|
12
|
-
* @param {Object} target
|
|
13
|
-
* @param {Object} source
|
|
14
|
-
* @returns {Object}
|
|
15
|
-
*/
|
|
16
|
-
function deepMerge(target, source) {
|
|
17
|
-
if (!source || typeof source !== 'object') return target;
|
|
18
|
-
|
|
19
|
-
const result = Object.assign({}, target);
|
|
20
|
-
|
|
21
|
-
for (const key of Object.keys(source)) {
|
|
22
|
-
const srcVal = source[key];
|
|
23
|
-
const tgtVal = result[key];
|
|
24
|
-
|
|
25
|
-
if (
|
|
26
|
-
srcVal !== null &&
|
|
27
|
-
typeof srcVal === 'object' &&
|
|
28
|
-
!Array.isArray(srcVal) &&
|
|
29
|
-
tgtVal !== null &&
|
|
30
|
-
typeof tgtVal === 'object' &&
|
|
31
|
-
!Array.isArray(tgtVal)
|
|
32
|
-
) {
|
|
33
|
-
result[key] = deepMerge(tgtVal, srcVal);
|
|
34
|
-
} else if (srcVal !== undefined) {
|
|
35
|
-
result[key] = srcVal;
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
return result;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Load and cache the merged config (defaults + cds.env.requires.errorOutbox).
|
|
44
|
-
* @returns {Object}
|
|
45
|
-
*/
|
|
46
|
-
function loadConfig() {
|
|
47
|
-
if (_config) return _config;
|
|
48
|
-
|
|
49
|
-
const userConfig =
|
|
50
|
-
(cds.env &&
|
|
51
|
-
cds.env.requires &&
|
|
52
|
-
cds.env.requires.errorOutbox) ||
|
|
53
|
-
{};
|
|
54
|
-
|
|
55
|
-
_config = deepMerge(defaults, userConfig);
|
|
56
|
-
return _config;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Reset the cached config singleton.
|
|
61
|
-
* Useful in tests to reload config between test cases.
|
|
62
|
-
*/
|
|
63
|
-
function resetConfig() {
|
|
64
|
-
_config = null;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
module.exports = { loadConfig, resetConfig, deepMerge };
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const cds = require('@sap/cds');
|
|
4
|
+
const defaults = require('../config/defaults');
|
|
5
|
+
|
|
6
|
+
let _config = null;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Recursively deep-merge source into target.
|
|
10
|
+
* Arrays in source replace arrays in target (no merge).
|
|
11
|
+
*
|
|
12
|
+
* @param {Object} target
|
|
13
|
+
* @param {Object} source
|
|
14
|
+
* @returns {Object}
|
|
15
|
+
*/
|
|
16
|
+
function deepMerge(target, source) {
|
|
17
|
+
if (!source || typeof source !== 'object') return target;
|
|
18
|
+
|
|
19
|
+
const result = Object.assign({}, target);
|
|
20
|
+
|
|
21
|
+
for (const key of Object.keys(source)) {
|
|
22
|
+
const srcVal = source[key];
|
|
23
|
+
const tgtVal = result[key];
|
|
24
|
+
|
|
25
|
+
if (
|
|
26
|
+
srcVal !== null &&
|
|
27
|
+
typeof srcVal === 'object' &&
|
|
28
|
+
!Array.isArray(srcVal) &&
|
|
29
|
+
tgtVal !== null &&
|
|
30
|
+
typeof tgtVal === 'object' &&
|
|
31
|
+
!Array.isArray(tgtVal)
|
|
32
|
+
) {
|
|
33
|
+
result[key] = deepMerge(tgtVal, srcVal);
|
|
34
|
+
} else if (srcVal !== undefined) {
|
|
35
|
+
result[key] = srcVal;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Load and cache the merged config (defaults + cds.env.requires.errorOutbox).
|
|
44
|
+
* @returns {Object}
|
|
45
|
+
*/
|
|
46
|
+
function loadConfig() {
|
|
47
|
+
if (_config) return _config;
|
|
48
|
+
|
|
49
|
+
const userConfig =
|
|
50
|
+
(cds.env &&
|
|
51
|
+
cds.env.requires &&
|
|
52
|
+
cds.env.requires.errorOutbox) ||
|
|
53
|
+
{};
|
|
54
|
+
|
|
55
|
+
_config = deepMerge(defaults, userConfig);
|
|
56
|
+
return _config;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Reset the cached config singleton.
|
|
61
|
+
* Useful in tests to reload config between test cases.
|
|
62
|
+
*/
|
|
63
|
+
function resetConfig() {
|
|
64
|
+
_config = null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = { loadConfig, resetConfig, deepMerge };
|
package/lib/dedup.js
CHANGED
|
@@ -1,91 +1,91 @@
|
|
|
1
|
-
'use strict';
|
|
2
|
-
|
|
3
|
-
const crypto = require('crypto');
|
|
4
|
-
|
|
5
|
-
const ENTITY = 'error.outbox.Errors';
|
|
6
|
-
|
|
7
|
-
// Truncation limits to prevent oversized DB entries
|
|
8
|
-
const MAX_MESSAGE_LEN = 5000;
|
|
9
|
-
const MAX_STACK_LEN = 10000;
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Creates a deterministic SHA-256 hash from the error's message, service, and action.
|
|
13
|
-
* Used as the dedup key.
|
|
14
|
-
*
|
|
15
|
-
* @param {string} message
|
|
16
|
-
* @param {string} service
|
|
17
|
-
* @param {string} action
|
|
18
|
-
* @returns {string} 64-character hex string
|
|
19
|
-
*/
|
|
20
|
-
function createHash(message, service, action) {
|
|
21
|
-
return crypto
|
|
22
|
-
.createHash('sha256')
|
|
23
|
-
.update(`${message}|${service}|${action}`)
|
|
24
|
-
.digest('hex');
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* Find an existing unsent error record matching the given hash within the dedup window.
|
|
29
|
-
*
|
|
30
|
-
* @param {object} db - connected cds.db instance
|
|
31
|
-
* @param {string} hash
|
|
32
|
-
* @param {number} windowMinutes
|
|
33
|
-
* @returns {Promise<object|null>}
|
|
34
|
-
*/
|
|
35
|
-
async function findExistingError(db, hash, windowMinutes) {
|
|
36
|
-
const cutoff = new Date(Date.now() - windowMinutes * 60 * 1000).toISOString();
|
|
37
|
-
|
|
38
|
-
const results = await db.run(
|
|
39
|
-
SELECT.from(ENTITY)
|
|
40
|
-
.where({ hash })
|
|
41
|
-
.and('lastSeen >', cutoff)
|
|
42
|
-
.and({ sent: false })
|
|
43
|
-
.limit(1)
|
|
44
|
-
);
|
|
45
|
-
|
|
46
|
-
return results && results.length > 0 ? results[0] : null;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Insert a new error row or increment the count on an existing one.
|
|
51
|
-
* Deduplication is based on the hash + dedup window + sent=false.
|
|
52
|
-
*
|
|
53
|
-
* @param {object} db - connected cds.db instance
|
|
54
|
-
* @param {object} errorData - { message, stack, service, action }
|
|
55
|
-
* @param {object} config - merged plugin config
|
|
56
|
-
*/
|
|
57
|
-
async function upsertError(db, errorData, config) {
|
|
58
|
-
const { message, stack, service, action } = errorData;
|
|
59
|
-
|
|
60
|
-
const hash = createHash(message, service, action);
|
|
61
|
-
const now = new Date().toISOString();
|
|
62
|
-
|
|
63
|
-
const existing = config.dedup.enabled
|
|
64
|
-
? await findExistingError(db, hash, config.dedup.windowMinutes)
|
|
65
|
-
: null;
|
|
66
|
-
|
|
67
|
-
if (existing) {
|
|
68
|
-
await db.run(
|
|
69
|
-
UPDATE(ENTITY)
|
|
70
|
-
.set({ count: existing.count + 1, lastSeen: now })
|
|
71
|
-
.where({ ID: existing.ID })
|
|
72
|
-
);
|
|
73
|
-
} else {
|
|
74
|
-
await db.run(
|
|
75
|
-
INSERT.into(ENTITY).entries({
|
|
76
|
-
ID: crypto.randomUUID(),
|
|
77
|
-
hash,
|
|
78
|
-
service: String(service || 'unknown'),
|
|
79
|
-
action: String(action || 'unknown'),
|
|
80
|
-
message: message ? String(message).substring(0, MAX_MESSAGE_LEN) : '',
|
|
81
|
-
stack: stack ? String(stack).substring(0, MAX_STACK_LEN) : '',
|
|
82
|
-
count: 1,
|
|
83
|
-
firstSeen: now,
|
|
84
|
-
lastSeen: now,
|
|
85
|
-
sent: false
|
|
86
|
-
})
|
|
87
|
-
);
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
module.exports = { createHash, findExistingError, upsertError };
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
|
|
5
|
+
const ENTITY = 'error.outbox.Errors';
|
|
6
|
+
|
|
7
|
+
// Truncation limits to prevent oversized DB entries
|
|
8
|
+
const MAX_MESSAGE_LEN = 5000;
|
|
9
|
+
const MAX_STACK_LEN = 10000;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Creates a deterministic SHA-256 hash from the error's message, service, and action.
|
|
13
|
+
* Used as the dedup key.
|
|
14
|
+
*
|
|
15
|
+
* @param {string} message
|
|
16
|
+
* @param {string} service
|
|
17
|
+
* @param {string} action
|
|
18
|
+
* @returns {string} 64-character hex string
|
|
19
|
+
*/
|
|
20
|
+
function createHash(message, service, action) {
|
|
21
|
+
return crypto
|
|
22
|
+
.createHash('sha256')
|
|
23
|
+
.update(`${message}|${service}|${action}`)
|
|
24
|
+
.digest('hex');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Find an existing unsent error record matching the given hash within the dedup window.
|
|
29
|
+
*
|
|
30
|
+
* @param {object} db - connected cds.db instance
|
|
31
|
+
* @param {string} hash
|
|
32
|
+
* @param {number} windowMinutes
|
|
33
|
+
* @returns {Promise<object|null>}
|
|
34
|
+
*/
|
|
35
|
+
async function findExistingError(db, hash, windowMinutes) {
|
|
36
|
+
const cutoff = new Date(Date.now() - windowMinutes * 60 * 1000).toISOString();
|
|
37
|
+
|
|
38
|
+
const results = await db.run(
|
|
39
|
+
SELECT.from(ENTITY)
|
|
40
|
+
.where({ hash })
|
|
41
|
+
.and('lastSeen >', cutoff)
|
|
42
|
+
.and({ sent: false })
|
|
43
|
+
.limit(1)
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
return results && results.length > 0 ? results[0] : null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Insert a new error row or increment the count on an existing one.
|
|
51
|
+
* Deduplication is based on the hash + dedup window + sent=false.
|
|
52
|
+
*
|
|
53
|
+
* @param {object} db - connected cds.db instance
|
|
54
|
+
* @param {object} errorData - { message, stack, service, action }
|
|
55
|
+
* @param {object} config - merged plugin config
|
|
56
|
+
*/
|
|
57
|
+
async function upsertError(db, errorData, config) {
|
|
58
|
+
const { message, stack, service, action } = errorData;
|
|
59
|
+
|
|
60
|
+
const hash = createHash(message, service, action);
|
|
61
|
+
const now = new Date().toISOString();
|
|
62
|
+
|
|
63
|
+
const existing = config.dedup.enabled
|
|
64
|
+
? await findExistingError(db, hash, config.dedup.windowMinutes)
|
|
65
|
+
: null;
|
|
66
|
+
|
|
67
|
+
if (existing) {
|
|
68
|
+
await db.run(
|
|
69
|
+
UPDATE(ENTITY)
|
|
70
|
+
.set({ count: existing.count + 1, lastSeen: now })
|
|
71
|
+
.where({ ID: existing.ID })
|
|
72
|
+
);
|
|
73
|
+
} else {
|
|
74
|
+
await db.run(
|
|
75
|
+
INSERT.into(ENTITY).entries({
|
|
76
|
+
ID: crypto.randomUUID(),
|
|
77
|
+
hash,
|
|
78
|
+
service: String(service || 'unknown'),
|
|
79
|
+
action: String(action || 'unknown'),
|
|
80
|
+
message: message ? String(message).substring(0, MAX_MESSAGE_LEN) : '',
|
|
81
|
+
stack: stack ? String(stack).substring(0, MAX_STACK_LEN) : '',
|
|
82
|
+
count: 1,
|
|
83
|
+
firstSeen: now,
|
|
84
|
+
lastSeen: now,
|
|
85
|
+
sent: false
|
|
86
|
+
})
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
module.exports = { createHash, findExistingError, upsertError };
|