@friggframework/core 2.0.0--canary.654.5821f44.0 → 2.0.0--canary.654.0c31bb1.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/core/CLAUDE.md +3 -2
- package/core/create-handler.js +7 -6
- package/core/invocation-scope.js +26 -25
- package/database/encryption/encryption-schema-registry.js +3 -3
- package/database/encryption/logger.js +5 -2
- package/handlers/app-definition-loader.js +3 -18
- package/handlers/routers/websocket.js +1 -1
- package/logs/context.js +17 -7
- package/logs/contract-keys.js +67 -0
- package/logs/denied-keys.js +21 -0
- package/logs/logger-runtime.js +6 -11
- package/logs/logger.js +15 -13
- package/logs/record.js +115 -95
- package/logs/redact.js +30 -35
- package/logs/serialize.js +21 -14
- package/logs/sinks.js +16 -22
- package/package.json +5 -5
- package/user/repositories/user-repository-documentdb.js +2 -2
package/core/CLAUDE.md
CHANGED
|
@@ -158,8 +158,9 @@ class MyIntegration extends Delegate {
|
|
|
158
158
|
|
|
159
159
|
5. **Flush** (in `runInvocationScope`'s `finally`):
|
|
160
160
|
```javascript
|
|
161
|
-
//
|
|
162
|
-
//
|
|
161
|
+
// Telemetry + log sinks under one deadline, fixed at flush start:
|
|
162
|
+
// min(flushTimeoutMs, remaining time - 50 ms). The usage rollup runs in
|
|
163
|
+
// parallel with no bound of its own.
|
|
163
164
|
```
|
|
164
165
|
|
|
165
166
|
### SQS Job Processing Lifecycle
|
package/core/create-handler.js
CHANGED
|
@@ -6,7 +6,7 @@ const { getLogger, toSanitizedSurrogate } = require('../logs');
|
|
|
6
6
|
const {
|
|
7
7
|
summarizeLambdaEvent,
|
|
8
8
|
toScopeInvocation,
|
|
9
|
-
|
|
9
|
+
toRequestDetails,
|
|
10
10
|
} = require('../logs/summarize-event');
|
|
11
11
|
const {
|
|
12
12
|
runInvocationScope,
|
|
@@ -58,8 +58,9 @@ const createHandler = (optionByName = {}) => {
|
|
|
58
58
|
routeKey: eventSummary.routeKey,
|
|
59
59
|
invocation: toScopeInvocation(eventSummary),
|
|
60
60
|
};
|
|
61
|
-
//
|
|
62
|
-
|
|
61
|
+
// Path, query keys and header names go only on the entry and failure
|
|
62
|
+
// records; the logger adds them to the scope's invocation.
|
|
63
|
+
const requestDetails = toRequestDetails(eventSummary);
|
|
63
64
|
|
|
64
65
|
return runInvocationScope(
|
|
65
66
|
scopeFields,
|
|
@@ -67,7 +68,7 @@ const createHandler = (optionByName = {}) => {
|
|
|
67
68
|
try {
|
|
68
69
|
log.info('Handler invoked', {
|
|
69
70
|
eventName: 'frigg.handler.invoked',
|
|
70
|
-
invocation:
|
|
71
|
+
invocation: requestDetails,
|
|
71
72
|
});
|
|
72
73
|
|
|
73
74
|
// If enabled (i.e. if SECRET_ARN is set in process.env) Fetch secrets from AWS Secrets Manager, and set them as environment variables.
|
|
@@ -109,7 +110,7 @@ const createHandler = (optionByName = {}) => {
|
|
|
109
110
|
if (!isUserFacingResponse && error.isHaltError === true) {
|
|
110
111
|
log.error('Handler halted', {
|
|
111
112
|
eventName: 'frigg.handler.halted',
|
|
112
|
-
invocation:
|
|
113
|
+
invocation: requestDetails,
|
|
113
114
|
error,
|
|
114
115
|
});
|
|
115
116
|
return;
|
|
@@ -117,7 +118,7 @@ const createHandler = (optionByName = {}) => {
|
|
|
117
118
|
|
|
118
119
|
log.error('Handler failed', {
|
|
119
120
|
eventName: 'frigg.handler.failed',
|
|
120
|
-
invocation:
|
|
121
|
+
invocation: requestDetails,
|
|
121
122
|
error,
|
|
122
123
|
});
|
|
123
124
|
|
package/core/invocation-scope.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const { runInContext } = require('../logs/context');
|
|
1
|
+
const { runInContext, LOGGER_SCOPE_KEY } = require('../logs/context');
|
|
2
2
|
const { summarizeMessageBody } = require('../logs/summarize-event');
|
|
3
3
|
const { flushSinks, hasFlushableSinks } = require('../logs/logger-runtime');
|
|
4
4
|
|
|
@@ -128,9 +128,19 @@ async function flushInvocation({
|
|
|
128
128
|
flushTimeoutMs,
|
|
129
129
|
context,
|
|
130
130
|
}) {
|
|
131
|
-
// ADR-048 §11:
|
|
132
|
-
|
|
131
|
+
// ADR-048 §11: the deadline is fixed at flush start. The usage rollup
|
|
132
|
+
// emits no telemetry, so it runs in parallel with no bound of its own.
|
|
133
|
+
const deadline = Math.min(
|
|
134
|
+
flushTimeoutMs,
|
|
135
|
+
remainingTimeMs(context) - FLUSH_MARGIN_MS
|
|
136
|
+
);
|
|
137
|
+
await Promise.all([
|
|
138
|
+
flushUsageRollup(usageRollup, eventSummary, shouldUseDatabase),
|
|
139
|
+
flushBounded(telemetry, deadline),
|
|
140
|
+
]);
|
|
141
|
+
}
|
|
133
142
|
|
|
143
|
+
function flushBounded(telemetry, deadline) {
|
|
134
144
|
const flushes = [];
|
|
135
145
|
if (isTelemetryEnabled(telemetry)) {
|
|
136
146
|
flushes.push((signal) => flushTelemetry(telemetry, { signal }));
|
|
@@ -138,14 +148,8 @@ async function flushInvocation({
|
|
|
138
148
|
if (hasFlushableSinks()) {
|
|
139
149
|
flushes.push((signal) => flushSinks({ signal }));
|
|
140
150
|
}
|
|
141
|
-
if (!flushes.length) return;
|
|
142
|
-
|
|
143
|
-
const deadline = Math.min(
|
|
144
|
-
flushTimeoutMs,
|
|
145
|
-
remainingTimeMs(context) - FLUSH_MARGIN_MS
|
|
146
|
-
);
|
|
147
|
-
if (!(deadline > 0)) return;
|
|
148
|
-
await withDeadline(deadline, (signal) =>
|
|
151
|
+
if (!flushes.length || !(deadline > 0)) return undefined;
|
|
152
|
+
return withDeadline(deadline, (signal) =>
|
|
149
153
|
Promise.allSettled(flushes.map((flush) => flush(signal)))
|
|
150
154
|
);
|
|
151
155
|
}
|
|
@@ -164,22 +168,19 @@ async function runInvocationScope(fields, fn, opts = {}) {
|
|
|
164
168
|
flushTimeoutMs = DEFAULT_FLUSH_TIMEOUT_MS,
|
|
165
169
|
context,
|
|
166
170
|
} = opts;
|
|
167
|
-
return runInContext({
|
|
171
|
+
return runInContext({ [LOGGER_SCOPE_KEY]: { ...(fields || {}) } }, async () => {
|
|
168
172
|
try {
|
|
169
173
|
return await fn();
|
|
170
174
|
} finally {
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
} catch (_) {
|
|
181
|
-
// A flush must never change the handler result.
|
|
182
|
-
}
|
|
175
|
+
// Every step is guarded, so a flush never changes the result.
|
|
176
|
+
await flushInvocation({
|
|
177
|
+
telemetry,
|
|
178
|
+
usageRollup,
|
|
179
|
+
eventSummary,
|
|
180
|
+
shouldUseDatabase,
|
|
181
|
+
flushTimeoutMs,
|
|
182
|
+
context,
|
|
183
|
+
});
|
|
183
184
|
}
|
|
184
185
|
});
|
|
185
186
|
}
|
|
@@ -201,7 +202,7 @@ function runMessageScope(record, fn) {
|
|
|
201
202
|
);
|
|
202
203
|
return runInContext(
|
|
203
204
|
{
|
|
204
|
-
|
|
205
|
+
[LOGGER_SCOPE_KEY]: {
|
|
205
206
|
messageId: record?.messageId,
|
|
206
207
|
receiveCount: toCount(record?.attributes?.ApproximateReceiveCount),
|
|
207
208
|
integrationEvent: event,
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
const { logger } = require('./logger');
|
|
12
|
-
const {
|
|
12
|
+
const { registerDeniedKeys } = require('../../logs/denied-keys');
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
15
|
* Core encryption schema (immutable - cannot be overridden by custom schemas)
|
|
@@ -121,7 +121,7 @@ function registerCustomSchema(schema) {
|
|
|
121
121
|
}
|
|
122
122
|
|
|
123
123
|
customSchema = { ...schema };
|
|
124
|
-
|
|
124
|
+
registerDeniedKeys(Object.values(schema).flatMap((config) => config.fields));
|
|
125
125
|
logger.info(
|
|
126
126
|
`Registered custom encryption schema for models: ${Object.keys(customSchema).join(', ')}`
|
|
127
127
|
);
|
|
@@ -151,7 +151,7 @@ function extractCredentialFieldsFromModules(moduleDefinitions) {
|
|
|
151
151
|
}
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
-
|
|
154
|
+
registerDeniedKeys(fields);
|
|
155
155
|
return [...new Set(fields)];
|
|
156
156
|
}
|
|
157
157
|
|
|
@@ -17,9 +17,12 @@ class EncryptionLogger {
|
|
|
17
17
|
this.minLevel = this._getMinLevel();
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
// FRIGG_LOG_LEVEL also takes TRACE and FATAL (ADR-048); this logger has
|
|
21
|
+
// no such levels, so they map to its nearest ones.
|
|
20
22
|
_getMinLevel() {
|
|
21
|
-
const level = process.env.FRIGG_LOG_LEVEL || 'INFO';
|
|
22
|
-
|
|
23
|
+
const level = (process.env.FRIGG_LOG_LEVEL || 'INFO').trim().toUpperCase();
|
|
24
|
+
const mapped = { TRACE: 'DEBUG', FATAL: 'ERROR' }[level] ?? level;
|
|
25
|
+
return LOG_LEVELS[mapped] ?? LOG_LEVELS.INFO;
|
|
23
26
|
}
|
|
24
27
|
|
|
25
28
|
_shouldLog(level) {
|
|
@@ -2,8 +2,7 @@ const { findNearestBackendPackageJson } = require('@friggframework/core/utils');
|
|
|
2
2
|
const path = require('node:path');
|
|
3
3
|
const fs = require('fs-extra');
|
|
4
4
|
const { resolveTelemetryConfig } = require('../telemetry/telemetry-config');
|
|
5
|
-
const {
|
|
6
|
-
const { getLogger } = require('../logs');
|
|
5
|
+
const { registerDeniedKeys } = require('../logs/denied-keys');
|
|
7
6
|
const {
|
|
8
7
|
extractCredentialFieldsFromModules,
|
|
9
8
|
} = require('../database/encryption/encryption-schema-registry');
|
|
@@ -13,32 +12,18 @@ const {
|
|
|
13
12
|
|
|
14
13
|
// Handlers without a database never load the encryption registry, so the
|
|
15
14
|
// credential leaf keys are registered for redaction here too.
|
|
16
|
-
const warnedIgnoredKeys = new Set();
|
|
17
|
-
|
|
18
|
-
function warnIgnoredKeys(ignored) {
|
|
19
|
-
for (const key of ignored) {
|
|
20
|
-
if (warnedIgnoredKeys.has(key)) continue;
|
|
21
|
-
warnedIgnoredKeys.add(key);
|
|
22
|
-
getLogger('frigg.logger').warn(
|
|
23
|
-
'Credential field name is a record field; it stays visible in logs',
|
|
24
|
-
{ eventName: 'frigg.logger.denied_key_ignored', key }
|
|
25
|
-
);
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
|
|
29
15
|
function registerCredentialLogKeys(appDefinition, integrations) {
|
|
30
16
|
try {
|
|
31
|
-
|
|
17
|
+
extractCredentialFieldsFromModules(
|
|
32
18
|
getModulesDefinitionFromIntegrationClasses(integrations)
|
|
33
19
|
);
|
|
34
|
-
warnIgnoredKeys(addDeniedKeys(fields));
|
|
35
20
|
} catch {
|
|
36
21
|
// An odd integration shape must not stop the app from loading.
|
|
37
22
|
}
|
|
38
23
|
const schema = appDefinition.encryption?.schema;
|
|
39
24
|
if (schema && typeof schema === 'object') {
|
|
40
25
|
for (const config of Object.values(schema)) {
|
|
41
|
-
|
|
26
|
+
registerDeniedKeys(config?.fields);
|
|
42
27
|
}
|
|
43
28
|
}
|
|
44
29
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
const { createHandler } = require('@friggframework/core');
|
|
2
|
-
const { createWebsocketConnectionRepository } = require('../../
|
|
2
|
+
const { createWebsocketConnectionRepository } = require('../../websocket/repositories/websocket-connection-repository-factory');
|
|
3
3
|
const { getLogger } = require('../../logs');
|
|
4
4
|
|
|
5
5
|
const log = getLogger('frigg.websocket');
|
package/logs/context.js
CHANGED
|
@@ -64,15 +64,24 @@ function getContext() {
|
|
|
64
64
|
return als().getStore() ?? null;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
// The store is frozen per scope, so one scope object per store is enough.
|
|
68
|
+
const EMPTY_SCOPE = Object.freeze({});
|
|
69
|
+
const scopeByStore = new WeakMap();
|
|
70
|
+
|
|
67
71
|
function getLoggerScope() {
|
|
68
72
|
const store = getContext();
|
|
69
|
-
if (!store) return
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
73
|
+
if (!store) return EMPTY_SCOPE;
|
|
74
|
+
let scope = scopeByStore.get(store);
|
|
75
|
+
if (!scope) {
|
|
76
|
+
scope = {};
|
|
77
|
+
for (const key of INTEGRATION_KEYS) {
|
|
78
|
+
if (store[key] !== undefined) scope[key] = store[key];
|
|
79
|
+
}
|
|
80
|
+
if (isPlainObject(store[LOGGER_SCOPE_KEY])) {
|
|
81
|
+
Object.assign(scope, store[LOGGER_SCOPE_KEY]);
|
|
82
|
+
}
|
|
83
|
+
scope = Object.freeze(scope);
|
|
84
|
+
scopeByStore.set(store, scope);
|
|
76
85
|
}
|
|
77
86
|
return scope;
|
|
78
87
|
}
|
|
@@ -89,4 +98,5 @@ module.exports = {
|
|
|
89
98
|
getContext,
|
|
90
99
|
getLoggerScope,
|
|
91
100
|
hasOnlyLoggerKeys,
|
|
101
|
+
isPlainObject,
|
|
92
102
|
};
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Field names of the record contract (ADR-048 §3). A leaf: no requires.
|
|
2
|
+
|
|
3
|
+
const REQUIRED_KEYS = ['timestamp', 'level', 'message', 'logger'];
|
|
4
|
+
const RESOURCE_KEYS = ['appName', 'stage'];
|
|
5
|
+
const TRACE_KEYS = ['trace_id', 'span_id', 'trace_flags'];
|
|
6
|
+
const DROPPED_KEYS_FIELD = 'droppedKeys';
|
|
7
|
+
const OWNED_KEYS = new Set([
|
|
8
|
+
...REQUIRED_KEYS,
|
|
9
|
+
...RESOURCE_KEYS,
|
|
10
|
+
...TRACE_KEYS,
|
|
11
|
+
DROPPED_KEYS_FIELD,
|
|
12
|
+
]);
|
|
13
|
+
|
|
14
|
+
// Set by the scope builders (logs/context.js, core/invocation-scope.js,
|
|
15
|
+
// core/create-handler.js) and the Module and IntegrationBase bindings.
|
|
16
|
+
const SCOPE_KEYS = [
|
|
17
|
+
'integrationId',
|
|
18
|
+
'integrationType',
|
|
19
|
+
'userId',
|
|
20
|
+
'version',
|
|
21
|
+
'entityId',
|
|
22
|
+
'credentialId',
|
|
23
|
+
'requestId',
|
|
24
|
+
'messageId',
|
|
25
|
+
'receiveCount',
|
|
26
|
+
'processId',
|
|
27
|
+
'integrationEvent',
|
|
28
|
+
'handlerName',
|
|
29
|
+
'method',
|
|
30
|
+
'route',
|
|
31
|
+
'routeKey',
|
|
32
|
+
'invocation',
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
const CONTRACT_KEYS = [
|
|
36
|
+
...new Set([...OWNED_KEYS, ...SCOPE_KEYS, 'eventName', 'statusCode', 'error']),
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
const RESERVED_KEYS = new Set([
|
|
40
|
+
'tenantId',
|
|
41
|
+
'type',
|
|
42
|
+
'time',
|
|
43
|
+
'record',
|
|
44
|
+
'errorType',
|
|
45
|
+
'errorMessage',
|
|
46
|
+
'stackTrace',
|
|
47
|
+
'service',
|
|
48
|
+
'env',
|
|
49
|
+
'host',
|
|
50
|
+
'source',
|
|
51
|
+
'status',
|
|
52
|
+
'severity',
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
const PAYLOAD_KEYS = new Set(['body', 'rawBody', 'payload', 'response']);
|
|
56
|
+
|
|
57
|
+
module.exports = {
|
|
58
|
+
REQUIRED_KEYS,
|
|
59
|
+
RESOURCE_KEYS,
|
|
60
|
+
TRACE_KEYS,
|
|
61
|
+
DROPPED_KEYS_FIELD,
|
|
62
|
+
OWNED_KEYS,
|
|
63
|
+
SCOPE_KEYS,
|
|
64
|
+
CONTRACT_KEYS,
|
|
65
|
+
RESERVED_KEYS,
|
|
66
|
+
PAYLOAD_KEYS,
|
|
67
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const { addDeniedKeys } = require('./redact');
|
|
2
|
+
const { getLogger } = require('./logger');
|
|
3
|
+
|
|
4
|
+
const warnedKeys = new Set();
|
|
5
|
+
|
|
6
|
+
// addDeniedKeys plus one WARN per record-contract key it had to skip, so a
|
|
7
|
+
// credential field named like a record field is visible wherever it is set.
|
|
8
|
+
function registerDeniedKeys(keys) {
|
|
9
|
+
const ignored = addDeniedKeys(keys);
|
|
10
|
+
for (const key of ignored) {
|
|
11
|
+
if (warnedKeys.has(key)) continue;
|
|
12
|
+
warnedKeys.add(key);
|
|
13
|
+
getLogger('frigg.logger').warn(
|
|
14
|
+
'Credential field name is a record field; it stays visible in logs',
|
|
15
|
+
{ eventName: 'frigg.logger.denied_key_ignored', key }
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
return ignored;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
module.exports = { registerDeniedKeys };
|
package/logs/logger-runtime.js
CHANGED
|
@@ -69,16 +69,11 @@ function hasFlushableSinks() {
|
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
async function flushSinks({ signal } = {}) {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
} catch (error) {
|
|
78
|
-
return Promise.reject(error);
|
|
79
|
-
}
|
|
80
|
-
});
|
|
81
|
-
await Promise.allSettled(flushes);
|
|
72
|
+
await Promise.allSettled(
|
|
73
|
+
getSinks()
|
|
74
|
+
.filter((sink) => typeof sink.flush === 'function')
|
|
75
|
+
.map(async (sink) => sink.flush({ signal }))
|
|
76
|
+
);
|
|
82
77
|
}
|
|
83
78
|
|
|
84
79
|
function setSpanContextProvider(provider) {
|
|
@@ -117,7 +112,7 @@ function resetLoggerForTests({ level, sinks, trackViolations } = {}) {
|
|
|
117
112
|
const s = state();
|
|
118
113
|
s.config = null;
|
|
119
114
|
s.levelOverride = parseLevel(level);
|
|
120
|
-
|
|
115
|
+
setSinks(sinks);
|
|
121
116
|
s.spanContextProvider = null;
|
|
122
117
|
s.warned.clear();
|
|
123
118
|
s.violations.length = 0;
|
package/logs/logger.js
CHANGED
|
@@ -5,31 +5,33 @@ const { buildRecord, DEFAULT_LOGGER_NAME } = require('./record');
|
|
|
5
5
|
|
|
6
6
|
const LOGGER_OWN_NAME = 'frigg.logger';
|
|
7
7
|
|
|
8
|
-
function copyBindings(bindings) {
|
|
9
|
-
|
|
10
|
-
if (!bindings || typeof bindings !== 'object') return copy;
|
|
8
|
+
function copyBindings(bindings, into = {}) {
|
|
9
|
+
if (!bindings || typeof bindings !== 'object') return into;
|
|
11
10
|
for (const key of Object.keys(bindings)) {
|
|
12
11
|
try {
|
|
13
|
-
|
|
12
|
+
into[key] = bindings[key];
|
|
14
13
|
} catch {
|
|
15
14
|
// A throwing getter drops only that binding.
|
|
16
15
|
}
|
|
17
16
|
}
|
|
18
|
-
return
|
|
17
|
+
return into;
|
|
19
18
|
}
|
|
20
19
|
|
|
20
|
+
// One plain object per record. Static sources were copied at child() time.
|
|
21
21
|
function evaluateBindings(sources) {
|
|
22
22
|
const merged = {};
|
|
23
23
|
for (const source of sources) {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
24
|
+
if (typeof source !== 'function') {
|
|
25
|
+
Object.assign(merged, source);
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
let value;
|
|
29
|
+
try {
|
|
30
|
+
value = source();
|
|
31
|
+
} catch {
|
|
32
|
+
continue;
|
|
31
33
|
}
|
|
32
|
-
|
|
34
|
+
copyBindings(value, merged);
|
|
33
35
|
}
|
|
34
36
|
return merged;
|
|
35
37
|
}
|
package/logs/record.js
CHANGED
|
@@ -1,42 +1,17 @@
|
|
|
1
1
|
const { LEVELS } = require('./levels');
|
|
2
|
-
const { serializeValue, serializeError } = require('./serialize');
|
|
2
|
+
const { serializeValue, serializeError, isError } = require('./serialize');
|
|
3
|
+
const { isPlainObject } = require('./context');
|
|
4
|
+
const {
|
|
5
|
+
RESOURCE_KEYS,
|
|
6
|
+
DROPPED_KEYS_FIELD,
|
|
7
|
+
OWNED_KEYS,
|
|
8
|
+
RESERVED_KEYS,
|
|
9
|
+
PAYLOAD_KEYS,
|
|
10
|
+
} = require('./contract-keys');
|
|
3
11
|
|
|
4
12
|
const MAX_RECORD_BYTES = 16 * 1024;
|
|
5
|
-
const REQUIRED_KEYS = ['timestamp', 'level', 'message', 'logger'];
|
|
6
|
-
const RESOURCE_KEYS = ['appName', 'stage'];
|
|
7
|
-
const TRACE_KEYS = ['trace_id', 'span_id', 'trace_flags'];
|
|
8
|
-
const DROPPED_KEYS_FIELD = 'droppedKeys';
|
|
9
|
-
const OWNED_KEYS = new Set([
|
|
10
|
-
...REQUIRED_KEYS,
|
|
11
|
-
...RESOURCE_KEYS,
|
|
12
|
-
...TRACE_KEYS,
|
|
13
|
-
DROPPED_KEYS_FIELD,
|
|
14
|
-
]);
|
|
15
|
-
const RESERVED_KEYS = new Set([
|
|
16
|
-
'tenantId',
|
|
17
|
-
'type',
|
|
18
|
-
'time',
|
|
19
|
-
'record',
|
|
20
|
-
'errorType',
|
|
21
|
-
'errorMessage',
|
|
22
|
-
'stackTrace',
|
|
23
|
-
'service',
|
|
24
|
-
'env',
|
|
25
|
-
'host',
|
|
26
|
-
'source',
|
|
27
|
-
'status',
|
|
28
|
-
'severity',
|
|
29
|
-
]);
|
|
30
|
-
const PAYLOAD_KEYS = new Set(['body', 'rawBody', 'payload', 'response']);
|
|
31
13
|
const DEFAULT_LOGGER_NAME = 'frigg.unknown';
|
|
32
14
|
|
|
33
|
-
function isError(value) {
|
|
34
|
-
return (
|
|
35
|
-
value instanceof Error ||
|
|
36
|
-
Object.prototype.toString.call(value) === '[object Error]'
|
|
37
|
-
);
|
|
38
|
-
}
|
|
39
|
-
|
|
40
15
|
function deepFreeze(value) {
|
|
41
16
|
if (value && typeof value === 'object' && !Object.isFrozen(value)) {
|
|
42
17
|
for (const key of Object.keys(value)) deepFreeze(value[key]);
|
|
@@ -64,39 +39,31 @@ function readFields(source) {
|
|
|
64
39
|
return result;
|
|
65
40
|
}
|
|
66
41
|
|
|
42
|
+
// Returns the message text. An Error message is serialized once; when the
|
|
43
|
+
// call site has no `error`, that result becomes the record's `error`.
|
|
67
44
|
function buildMessage(message, callSite) {
|
|
68
45
|
if (typeof message === 'string') {
|
|
69
|
-
return serializeValue(message);
|
|
46
|
+
return { text: serializeValue(message) };
|
|
70
47
|
}
|
|
71
|
-
if (message === undefined || message === null) return '';
|
|
48
|
+
if (message === undefined || message === null) return { text: '' };
|
|
72
49
|
if (isError(message)) {
|
|
73
50
|
const error = serializeError(message);
|
|
74
|
-
|
|
75
|
-
|
|
51
|
+
const text = typeof error?.message === 'string' ? error.message : '';
|
|
52
|
+
if (callSite.error !== undefined) return { text };
|
|
53
|
+
callSite.error = message;
|
|
54
|
+
return { text, error };
|
|
76
55
|
}
|
|
77
56
|
if (callSite.value === undefined) callSite.value = message;
|
|
78
|
-
return '[non-string message]';
|
|
57
|
+
return { text: '[non-string message]' };
|
|
79
58
|
}
|
|
80
59
|
|
|
81
60
|
const INVOCATION_KEY = 'invocation';
|
|
82
61
|
|
|
83
|
-
function isPlainObject(value) {
|
|
84
|
-
return (
|
|
85
|
-
value !== null &&
|
|
86
|
-
typeof value === 'object' &&
|
|
87
|
-
Object.getPrototypeOf(value) === Object.prototype
|
|
88
|
-
);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
62
|
// A boundary adds request detail to the scope's invocation; scope keys win.
|
|
92
|
-
function extendInvocation(scoped, detail
|
|
63
|
+
function extendInvocation(scoped, detail) {
|
|
93
64
|
const merged = { ...scoped };
|
|
94
|
-
for (const
|
|
95
|
-
if (!
|
|
96
|
-
merged[key] = value;
|
|
97
|
-
} else if (JSON.stringify(scoped[key]) !== JSON.stringify(value)) {
|
|
98
|
-
dropped.add(`${INVOCATION_KEY}.${key}`);
|
|
99
|
-
}
|
|
65
|
+
for (const key of Object.keys(detail)) {
|
|
66
|
+
if (!(key in merged)) merged[key] = detail[key];
|
|
100
67
|
}
|
|
101
68
|
return merged;
|
|
102
69
|
}
|
|
@@ -117,10 +84,73 @@ function traceFields(spanContext) {
|
|
|
117
84
|
};
|
|
118
85
|
}
|
|
119
86
|
|
|
87
|
+
function sameValue(a, b) {
|
|
88
|
+
if (a === b) return true;
|
|
89
|
+
return (
|
|
90
|
+
typeof a === 'object' &&
|
|
91
|
+
typeof b === 'object' &&
|
|
92
|
+
JSON.stringify(a) === JSON.stringify(b)
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
120
96
|
function byteLength(record) {
|
|
121
97
|
return Buffer.byteLength(JSON.stringify(record));
|
|
122
98
|
}
|
|
123
99
|
|
|
100
|
+
function isDroppedKey(key, dropPayloads) {
|
|
101
|
+
return (
|
|
102
|
+
OWNED_KEYS.has(key) ||
|
|
103
|
+
RESERVED_KEYS.has(key) ||
|
|
104
|
+
(dropPayloads && PAYLOAD_KEYS.has(key))
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Filters and serializes one source. `error` is serialized on its own, once
|
|
109
|
+
// (or taken from `preSerializedError`), and never walked by serializeValue.
|
|
110
|
+
function prepareSource(source, dropPayloads, preSerializedError) {
|
|
111
|
+
const accepted = {};
|
|
112
|
+
const keys = [];
|
|
113
|
+
const dropped = [];
|
|
114
|
+
let rawError;
|
|
115
|
+
for (const [key, value] of Object.entries(source)) {
|
|
116
|
+
if (value === undefined || value === null) continue;
|
|
117
|
+
if (isDroppedKey(key, dropPayloads)) {
|
|
118
|
+
dropped.push(key);
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
keys.push(key);
|
|
122
|
+
if (key === 'error') rawError = value;
|
|
123
|
+
else accepted[key] = value;
|
|
124
|
+
}
|
|
125
|
+
// One pass over the object, so key rules (denied keys, digests,
|
|
126
|
+
// headers, OAuth shapes) apply to top-level fields too.
|
|
127
|
+
const safe = serializeValue(accepted);
|
|
128
|
+
const entries = [];
|
|
129
|
+
for (const key of keys) {
|
|
130
|
+
const value =
|
|
131
|
+
key === 'error'
|
|
132
|
+
? preSerializedError ?? serializeError(rawError)
|
|
133
|
+
: safe?.[key];
|
|
134
|
+
if (value !== undefined && value !== null) entries.push([key, value]);
|
|
135
|
+
}
|
|
136
|
+
return { entries, dropped };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// The scope object is frozen and shared by every record of one scope.
|
|
140
|
+
const preparedScopes = new WeakMap();
|
|
141
|
+
|
|
142
|
+
function prepareScope(scope, dropPayloads) {
|
|
143
|
+
if (!scope || typeof scope !== 'object') return { entries: [], dropped: [] };
|
|
144
|
+
let byLevel = preparedScopes.get(scope);
|
|
145
|
+
if (!byLevel) {
|
|
146
|
+
byLevel = {};
|
|
147
|
+
preparedScopes.set(scope, byLevel);
|
|
148
|
+
}
|
|
149
|
+
const slot = dropPayloads ? 'info' : 'debug';
|
|
150
|
+
if (!byLevel[slot]) byLevel[slot] = prepareSource(scope, dropPayloads);
|
|
151
|
+
return byLevel[slot];
|
|
152
|
+
}
|
|
153
|
+
|
|
124
154
|
function buildRecord({
|
|
125
155
|
level,
|
|
126
156
|
logger,
|
|
@@ -135,10 +165,11 @@ function buildRecord({
|
|
|
135
165
|
const dropPayloads = LEVELS[level] >= LEVELS.INFO;
|
|
136
166
|
// log.error('msg', err): own props of an Error (axios config, request) must not become fields.
|
|
137
167
|
const callSite = readFields(isError(fields) ? { error: fields } : fields);
|
|
168
|
+
const built = buildMessage(message, callSite);
|
|
138
169
|
const record = {
|
|
139
170
|
timestamp: now.toISOString(),
|
|
140
171
|
level,
|
|
141
|
-
message:
|
|
172
|
+
message: built.text,
|
|
142
173
|
logger: typeof logger === 'string' && logger ? logger : DEFAULT_LOGGER_NAME,
|
|
143
174
|
};
|
|
144
175
|
for (const key of RESOURCE_KEYS) {
|
|
@@ -150,52 +181,38 @@ function buildRecord({
|
|
|
150
181
|
|
|
151
182
|
const dropped = new Set();
|
|
152
183
|
const callSiteKeys = [];
|
|
153
|
-
const place = (
|
|
154
|
-
const
|
|
155
|
-
for (const [key, value] of
|
|
156
|
-
if (
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
accepted[key] = value;
|
|
168
|
-
}
|
|
169
|
-
// One pass over the object, so key rules (denied keys, digests,
|
|
170
|
-
// headers, OAuth shapes) apply to top-level fields too.
|
|
171
|
-
const safe = serializeValue(accepted);
|
|
172
|
-
for (const key of Object.keys(accepted)) {
|
|
173
|
-
const value =
|
|
174
|
-
key === 'error' ? serializeError(accepted[key]) : safe?.[key];
|
|
175
|
-
if (value === undefined || value === null) continue;
|
|
176
|
-
if (key === INVOCATION_KEY && isPlainObject(record[key])) {
|
|
177
|
-
record[key] = extendInvocation(record[key], value, dropped);
|
|
184
|
+
const place = ({ entries, dropped: droppedHere }, isCallSite) => {
|
|
185
|
+
for (const key of droppedHere) dropped.add(key);
|
|
186
|
+
for (const [key, value] of entries) {
|
|
187
|
+
if (Object.prototype.hasOwnProperty.call(record, key)) {
|
|
188
|
+
if (
|
|
189
|
+
key === INVOCATION_KEY &&
|
|
190
|
+
isPlainObject(record[key]) &&
|
|
191
|
+
isPlainObject(value)
|
|
192
|
+
) {
|
|
193
|
+
record[key] = extendInvocation(record[key], value);
|
|
194
|
+
} else if (!sameValue(record[key], value)) {
|
|
195
|
+
// A repeat of the winning value is no conflict.
|
|
196
|
+
dropped.add(key);
|
|
197
|
+
}
|
|
178
198
|
continue;
|
|
179
199
|
}
|
|
180
200
|
record[key] = value;
|
|
181
201
|
if (isCallSite) callSiteKeys.push(key);
|
|
182
202
|
}
|
|
183
203
|
};
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
place(readFields(bindings), false);
|
|
190
|
-
place(callSite, true);
|
|
191
|
-
|
|
192
|
-
const measure = () => {
|
|
204
|
+
place(prepareScope(scope, dropPayloads), false);
|
|
205
|
+
place(prepareSource(bindings || {}, dropPayloads), false);
|
|
206
|
+
place(prepareSource(callSite, dropPayloads, built.error), true);
|
|
207
|
+
|
|
208
|
+
const setDroppedKeys = () => {
|
|
193
209
|
delete record[DROPPED_KEYS_FIELD];
|
|
194
210
|
if (dropped.size) record[DROPPED_KEYS_FIELD] = [...dropped];
|
|
195
|
-
return byteLength(record);
|
|
196
211
|
};
|
|
212
|
+
setDroppedKeys();
|
|
213
|
+
let size = byteLength(record);
|
|
197
214
|
|
|
198
|
-
if (
|
|
215
|
+
if (size > MAX_RECORD_BYTES) {
|
|
199
216
|
const bySize = callSiteKeys
|
|
200
217
|
.filter((key) => key !== 'error')
|
|
201
218
|
.map((key) => [key, Buffer.byteLength(JSON.stringify(record[key]))])
|
|
@@ -203,27 +220,30 @@ function buildRecord({
|
|
|
203
220
|
for (const [key] of bySize) {
|
|
204
221
|
delete record[key];
|
|
205
222
|
dropped.add(key);
|
|
206
|
-
|
|
223
|
+
setDroppedKeys();
|
|
224
|
+
size = byteLength(record);
|
|
225
|
+
if (size <= MAX_RECORD_BYTES) break;
|
|
207
226
|
}
|
|
208
227
|
}
|
|
209
228
|
for (const part of ['stack', 'cause']) {
|
|
210
229
|
if (
|
|
230
|
+
size > MAX_RECORD_BYTES &&
|
|
211
231
|
record.error &&
|
|
212
232
|
typeof record.error === 'object' &&
|
|
213
|
-
record.error[part] !== undefined
|
|
214
|
-
measure() > MAX_RECORD_BYTES
|
|
233
|
+
record.error[part] !== undefined
|
|
215
234
|
) {
|
|
216
235
|
const { [part]: _removed, ...rest } = record.error;
|
|
217
236
|
record.error = rest;
|
|
237
|
+
size = byteLength(record);
|
|
218
238
|
}
|
|
219
239
|
}
|
|
220
|
-
measure();
|
|
221
240
|
|
|
222
241
|
return deepFreeze(record);
|
|
223
242
|
}
|
|
224
243
|
|
|
225
244
|
module.exports = {
|
|
226
245
|
buildRecord,
|
|
246
|
+
deepFreeze,
|
|
227
247
|
MAX_RECORD_BYTES,
|
|
228
248
|
RESERVED_KEYS,
|
|
229
249
|
PAYLOAD_KEYS,
|
package/logs/redact.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
const { CONTRACT_KEYS } = require('./contract-keys');
|
|
2
|
+
|
|
1
3
|
const DENIED_SUFFIXES = [
|
|
2
4
|
'token',
|
|
3
5
|
'secret',
|
|
@@ -27,27 +29,9 @@ const deniedKeys = new Set([
|
|
|
27
29
|
|
|
28
30
|
// Record-contract fields. A credential field with one of these names must not
|
|
29
31
|
// blank the field in every record.
|
|
30
|
-
const PROTECTED_KEYS = new Set(
|
|
31
|
-
[
|
|
32
|
-
'integrationId',
|
|
33
|
-
'integrationType',
|
|
34
|
-
'userId',
|
|
35
|
-
'version',
|
|
36
|
-
'entityId',
|
|
37
|
-
'credentialId',
|
|
38
|
-
'requestId',
|
|
39
|
-
'messageId',
|
|
40
|
-
'processId',
|
|
41
|
-
'integrationEvent',
|
|
42
|
-
'eventName',
|
|
43
|
-
'handlerName',
|
|
44
|
-
'method',
|
|
45
|
-
'route',
|
|
46
|
-
'routeKey',
|
|
47
|
-
'statusCode',
|
|
48
|
-
].map((key) => key.toLowerCase())
|
|
49
|
-
);
|
|
32
|
+
const PROTECTED_KEYS = new Set(CONTRACT_KEYS.map(normalizeKey));
|
|
50
33
|
|
|
34
|
+
const DENIED_SUFFIX = new RegExp(`(?:${DENIED_SUFFIXES.join('|')})$`);
|
|
51
35
|
const PAIR_KEYS = new Set(['code', 'codeverifier']);
|
|
52
36
|
const DIGEST_KEY = /(sha(1|256)|hash|digest|checksum)$/;
|
|
53
37
|
|
|
@@ -71,7 +55,6 @@ const JSON_PAIR = /"([^"\\]{1,100})"(\s*:\s*)"((?:[^"\\]|\\.)*)"/g;
|
|
|
71
55
|
const TEXT_PAIR = /(?<![A-Za-z0-9_.-])([A-Za-z0-9_.-]{1,100})=([^\s&"'<>,;)]+)/g;
|
|
72
56
|
const HEX_RUN = /(?<![A-Za-z0-9])[0-9a-fA-F]{40,}(?![A-Za-z0-9])/g;
|
|
73
57
|
const BASE64_RUN = /(?<![A-Za-z0-9+/=_-])[A-Za-z0-9+/_-]{40,}={0,2}(?![A-Za-z0-9+/=_-])/g;
|
|
74
|
-
const BASE64_SEGMENT_RUN = /(?<![A-Za-z0-9+=_-])[A-Za-z0-9+_-]{40,}={0,2}(?![A-Za-z0-9+=_-])/g;
|
|
75
58
|
const FORM_TEXT = /^[A-Za-z0-9_.%[\]-]+=[^&\s]*(?:&[A-Za-z0-9_.%[\]-]+=[^&\s]*)+$/;
|
|
76
59
|
const PATH_SEGMENT = /^[A-Za-z0-9_-]*$/;
|
|
77
60
|
|
|
@@ -87,15 +70,23 @@ function normalizeKey(key) {
|
|
|
87
70
|
return key.toLowerCase().replace(/[-_\s]/g, '');
|
|
88
71
|
}
|
|
89
72
|
|
|
90
|
-
|
|
91
|
-
|
|
73
|
+
// The *Normalized variants take a normalizeKey() result, so a walk
|
|
74
|
+
// normalizes each key once.
|
|
75
|
+
function isDeniedNormalized(normalized) {
|
|
92
76
|
if (!normalized || PROTECTED_KEYS.has(normalized)) return false;
|
|
93
|
-
|
|
94
|
-
|
|
77
|
+
return deniedKeys.has(normalized) || DENIED_SUFFIX.test(normalized);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function isDeniedKey(key) {
|
|
81
|
+
return isDeniedNormalized(normalizeKey(key));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function isDigestNormalized(normalized) {
|
|
85
|
+
return DIGEST_KEY.test(normalized);
|
|
95
86
|
}
|
|
96
87
|
|
|
97
88
|
function isDigestKey(key) {
|
|
98
|
-
return
|
|
89
|
+
return isDigestNormalized(normalizeKey(key));
|
|
99
90
|
}
|
|
100
91
|
|
|
101
92
|
function isPairKey(key, { withState = false } = {}) {
|
|
@@ -139,7 +130,7 @@ function isRedactedValue(value) {
|
|
|
139
130
|
return value === 'REDACTED' || value.startsWith('[REDACTED');
|
|
140
131
|
}
|
|
141
132
|
|
|
142
|
-
function scrubTokens(text, { allowHex = false
|
|
133
|
+
function scrubTokens(text, { allowHex = false } = {}) {
|
|
143
134
|
let out = text.replace(PREFIXED_TOKEN, redacted);
|
|
144
135
|
out = out.replace(JWT, redacted);
|
|
145
136
|
out = out.replace(AUTH_SCHEME, (match, scheme, space, credential) => {
|
|
@@ -170,8 +161,8 @@ function scrubTokens(text, { allowHex = false, segment = false } = {}) {
|
|
|
170
161
|
: match
|
|
171
162
|
);
|
|
172
163
|
if (!allowHex) out = out.replace(HEX_RUN, redacted);
|
|
173
|
-
out = out.replace(
|
|
174
|
-
hasMixedClasses(run) &&
|
|
164
|
+
out = out.replace(BASE64_RUN, (run) =>
|
|
165
|
+
hasMixedClasses(run) && !isPathLike(run)
|
|
175
166
|
? redacted(run)
|
|
176
167
|
: run
|
|
177
168
|
);
|
|
@@ -204,7 +195,7 @@ function redactPathSegment(segment) {
|
|
|
204
195
|
if (PATH_TOKEN_SEGMENT.test(segment) && hasMixedClasses(segment)) {
|
|
205
196
|
return redacted(segment);
|
|
206
197
|
}
|
|
207
|
-
return scrubTokens(segment
|
|
198
|
+
return scrubTokens(segment);
|
|
208
199
|
}
|
|
209
200
|
|
|
210
201
|
// The raw path, not URL#pathname, which percent-encodes `{proxy+}`.
|
|
@@ -308,12 +299,13 @@ function scrubString(str, { allowHex = false } = {}) {
|
|
|
308
299
|
}
|
|
309
300
|
}
|
|
310
301
|
|
|
302
|
+
function isOAuthCallbackShapeNormalized(normalizedKeys) {
|
|
303
|
+
const keys = new Set(normalizedKeys);
|
|
304
|
+
return keys.has('code') && (keys.has('state') || keys.has('codeverifier'));
|
|
305
|
+
}
|
|
306
|
+
|
|
311
307
|
function isOAuthCallbackShape(keys) {
|
|
312
|
-
|
|
313
|
-
return (
|
|
314
|
-
normalized.has('code') &&
|
|
315
|
-
(normalized.has('state') || normalized.has('codeverifier'))
|
|
316
|
-
);
|
|
308
|
+
return isOAuthCallbackShapeNormalized(keys.map(normalizeKey));
|
|
317
309
|
}
|
|
318
310
|
|
|
319
311
|
function redactValue(value) {
|
|
@@ -324,8 +316,11 @@ function redactValue(value) {
|
|
|
324
316
|
module.exports = {
|
|
325
317
|
normalizeKey,
|
|
326
318
|
isDeniedKey,
|
|
319
|
+
isDeniedNormalized,
|
|
327
320
|
isDigestKey,
|
|
321
|
+
isDigestNormalized,
|
|
328
322
|
isOAuthCallbackShape,
|
|
323
|
+
isOAuthCallbackShapeNormalized,
|
|
329
324
|
addDeniedKeys,
|
|
330
325
|
redactUrl,
|
|
331
326
|
scrubString,
|
package/logs/serialize.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
const {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
isDeniedNormalized,
|
|
3
|
+
isDigestNormalized,
|
|
4
|
+
isOAuthCallbackShapeNormalized,
|
|
5
5
|
normalizeKey,
|
|
6
6
|
redactUrl,
|
|
7
7
|
scrubString,
|
|
@@ -27,8 +27,10 @@ function cutString(value) {
|
|
|
27
27
|
return value.slice(0, MAX_STRING - suffix.length) + suffix;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
function cleanString(value,
|
|
31
|
-
return cutString(
|
|
30
|
+
function cleanString(value, normalizedKey = '') {
|
|
31
|
+
return cutString(
|
|
32
|
+
scrubString(value, { allowHex: isDigestNormalized(normalizedKey) })
|
|
33
|
+
);
|
|
32
34
|
}
|
|
33
35
|
|
|
34
36
|
function isError(value) {
|
|
@@ -73,10 +75,13 @@ function headerNames(value) {
|
|
|
73
75
|
|
|
74
76
|
function walkObject(value, depth, seen) {
|
|
75
77
|
const keys = Object.keys(value);
|
|
76
|
-
const
|
|
78
|
+
const normalizedKeys = keys.map(normalizeKey);
|
|
79
|
+
const dropAll = isOAuthCallbackShapeNormalized(normalizedKeys);
|
|
77
80
|
const out = {};
|
|
78
|
-
for (
|
|
79
|
-
|
|
81
|
+
for (let i = 0; i < keys.length; i += 1) {
|
|
82
|
+
const key = keys[i];
|
|
83
|
+
const normalized = normalizedKeys[i];
|
|
84
|
+
if (dropAll || isDeniedNormalized(normalized)) {
|
|
80
85
|
out[key] = '[REDACTED]';
|
|
81
86
|
continue;
|
|
82
87
|
}
|
|
@@ -87,11 +92,11 @@ function walkObject(value, depth, seen) {
|
|
|
87
92
|
out[key] = '[Getter threw]';
|
|
88
93
|
continue;
|
|
89
94
|
}
|
|
90
|
-
if (HEADER_KEYS.has(
|
|
95
|
+
if (HEADER_KEYS.has(normalized)) {
|
|
91
96
|
out[key] = attempt(() => headerNames(child), '[Unserializable]');
|
|
92
97
|
continue;
|
|
93
98
|
}
|
|
94
|
-
const result = walk(child, depth + 1, seen,
|
|
99
|
+
const result = walk(child, depth + 1, seen, normalized);
|
|
95
100
|
if (result !== undefined) out[key] = result;
|
|
96
101
|
}
|
|
97
102
|
return out;
|
|
@@ -102,11 +107,12 @@ function walkContainer(value, depth, seen) {
|
|
|
102
107
|
const out = {};
|
|
103
108
|
for (const [k, v] of value) {
|
|
104
109
|
const key = String(k);
|
|
105
|
-
|
|
110
|
+
const normalized = normalizeKey(key);
|
|
111
|
+
if (isDeniedNormalized(normalized)) {
|
|
106
112
|
out[key] = '[REDACTED]';
|
|
107
113
|
continue;
|
|
108
114
|
}
|
|
109
|
-
const result = walk(v, depth + 1, seen,
|
|
115
|
+
const result = walk(v, depth + 1, seen, normalized);
|
|
110
116
|
if (result !== undefined) out[key] = result;
|
|
111
117
|
}
|
|
112
118
|
return out;
|
|
@@ -151,10 +157,10 @@ function walkSpecial(value) {
|
|
|
151
157
|
return undefined;
|
|
152
158
|
}
|
|
153
159
|
|
|
154
|
-
function walk(value, depth, seen,
|
|
160
|
+
function walk(value, depth, seen, normalizedKey) {
|
|
155
161
|
switch (typeof value) {
|
|
156
162
|
case 'string':
|
|
157
|
-
return cleanString(value,
|
|
163
|
+
return cleanString(value, normalizedKey);
|
|
158
164
|
case 'number':
|
|
159
165
|
return Number.isFinite(value) ? value : String(value);
|
|
160
166
|
case 'boolean':
|
|
@@ -313,6 +319,7 @@ function toSanitizedSurrogate(err) {
|
|
|
313
319
|
}
|
|
314
320
|
|
|
315
321
|
module.exports = {
|
|
322
|
+
isError,
|
|
316
323
|
serializeValue,
|
|
317
324
|
serializeError,
|
|
318
325
|
toSanitizedSurrogate,
|
package/logs/sinks.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
const fs = require('node:fs');
|
|
2
|
+
const { deepFreeze } = require('./record');
|
|
2
3
|
|
|
3
4
|
const DEFAULT_MAX_RETRIES = 10;
|
|
4
5
|
const MAX_SLEEP_MS = 20;
|
|
@@ -42,42 +43,35 @@ function createStdoutSink({
|
|
|
42
43
|
}
|
|
43
44
|
let offset = 0;
|
|
44
45
|
let retries = 0;
|
|
46
|
+
// EAGAIN and a zero-byte write share one bounded retry budget.
|
|
47
|
+
const retry = () => {
|
|
48
|
+
if (retries >= maxRetries) {
|
|
49
|
+
reportFailure('EAGAIN');
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
retries += 1;
|
|
53
|
+
sleep(Math.min(retries, MAX_SLEEP_MS));
|
|
54
|
+
return true;
|
|
55
|
+
};
|
|
45
56
|
while (offset < buffer.length) {
|
|
46
57
|
let written;
|
|
47
58
|
try {
|
|
48
59
|
written = writeSync(fd, buffer, offset, buffer.length - offset);
|
|
49
60
|
} catch (error) {
|
|
50
|
-
if (error?.code
|
|
51
|
-
|
|
52
|
-
sleep(Math.min(retries, MAX_SLEEP_MS));
|
|
53
|
-
continue;
|
|
54
|
-
}
|
|
55
|
-
reportFailure(error?.code);
|
|
56
|
-
return;
|
|
57
|
-
}
|
|
58
|
-
if (!(written > 0)) {
|
|
59
|
-
if (retries >= maxRetries) {
|
|
60
|
-
reportFailure('EAGAIN');
|
|
61
|
+
if (error?.code !== 'EAGAIN') {
|
|
62
|
+
reportFailure(error?.code);
|
|
61
63
|
return;
|
|
62
64
|
}
|
|
63
|
-
|
|
64
|
-
sleep(Math.min(retries, MAX_SLEEP_MS));
|
|
65
|
+
if (!retry()) return;
|
|
65
66
|
continue;
|
|
66
67
|
}
|
|
67
|
-
offset += written;
|
|
68
|
+
if (written > 0) offset += written;
|
|
69
|
+
else if (!retry()) return;
|
|
68
70
|
}
|
|
69
71
|
},
|
|
70
72
|
};
|
|
71
73
|
}
|
|
72
74
|
|
|
73
|
-
function deepFreeze(value) {
|
|
74
|
-
if (value && typeof value === 'object') {
|
|
75
|
-
for (const key of Object.keys(value)) deepFreeze(value[key]);
|
|
76
|
-
Object.freeze(value);
|
|
77
|
-
}
|
|
78
|
-
return value;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
75
|
function createMemorySink({ install = true } = {}) {
|
|
82
76
|
const records = [];
|
|
83
77
|
const sink = {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@friggframework/core",
|
|
3
3
|
"prettier": "@friggframework/prettier-config",
|
|
4
|
-
"version": "2.0.0--canary.654.
|
|
4
|
+
"version": "2.0.0--canary.654.0c31bb1.0",
|
|
5
5
|
"dependencies": {
|
|
6
6
|
"@aws-sdk/client-apigatewaymanagementapi": "^3.588.0",
|
|
7
7
|
"@aws-sdk/client-kms": "^3.588.0",
|
|
@@ -47,9 +47,9 @@
|
|
|
47
47
|
}
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
|
50
|
-
"@friggframework/eslint-config": "2.0.0--canary.654.
|
|
51
|
-
"@friggframework/prettier-config": "2.0.0--canary.654.
|
|
52
|
-
"@friggframework/test": "2.0.0--canary.654.
|
|
50
|
+
"@friggframework/eslint-config": "2.0.0--canary.654.0c31bb1.0",
|
|
51
|
+
"@friggframework/prettier-config": "2.0.0--canary.654.0c31bb1.0",
|
|
52
|
+
"@friggframework/test": "2.0.0--canary.654.0c31bb1.0",
|
|
53
53
|
"@prisma/client": "^6.19.3",
|
|
54
54
|
"@types/lodash": "4.17.15",
|
|
55
55
|
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
|
@@ -89,5 +89,5 @@
|
|
|
89
89
|
"publishConfig": {
|
|
90
90
|
"access": "public"
|
|
91
91
|
},
|
|
92
|
-
"gitHead": "
|
|
92
|
+
"gitHead": "0c31bb15ef0497cc321c4d2103a752de6449d234"
|
|
93
93
|
}
|
|
@@ -274,7 +274,7 @@ class UserRepositoryDocumentDB extends UserRepositoryInterface {
|
|
|
274
274
|
// Defensive check: verify document was found after update
|
|
275
275
|
if (!updated) {
|
|
276
276
|
throw new Error(
|
|
277
|
-
|
|
277
|
+
`Failed to update individual user: Document not found after update (userId ${userId}). ` +
|
|
278
278
|
'This indicates a database consistency issue.'
|
|
279
279
|
);
|
|
280
280
|
}
|
|
@@ -308,7 +308,7 @@ class UserRepositoryDocumentDB extends UserRepositoryInterface {
|
|
|
308
308
|
|
|
309
309
|
if (!updated) {
|
|
310
310
|
throw new Error(
|
|
311
|
-
|
|
311
|
+
`Failed to update organization user: Document not found after update (userId ${userId}). ` +
|
|
312
312
|
'This indicates a database consistency issue.'
|
|
313
313
|
);
|
|
314
314
|
}
|