@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 CHANGED
@@ -158,8 +158,9 @@ class MyIntegration extends Delegate {
158
158
 
159
159
  5. **Flush** (in `runInvocationScope`'s `finally`):
160
160
  ```javascript
161
- // Usage rollup first (unbounded), then telemetry + log sinks in parallel
162
- // under one deadline: min(flushTimeoutMs, remaining time - 50 ms).
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
@@ -6,7 +6,7 @@ const { getLogger, toSanitizedSurrogate } = require('../logs');
6
6
  const {
7
7
  summarizeLambdaEvent,
8
8
  toScopeInvocation,
9
- toRequestInvocation,
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
- // The full redacted summary goes only on the entry and failure records.
62
- const requestInvocation = toRequestInvocation(eventSummary);
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: requestInvocation,
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: requestInvocation,
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: requestInvocation,
121
+ invocation: requestDetails,
121
122
  error,
122
123
  });
123
124
 
@@ -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: usage first and unbounded, then one deadline for the rest.
132
- await flushUsageRollup(usageRollup, eventSummary, shouldUseDatabase);
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({ log: { ...(fields || {}) } }, async () => {
171
+ return runInContext({ [LOGGER_SCOPE_KEY]: { ...(fields || {}) } }, async () => {
168
172
  try {
169
173
  return await fn();
170
174
  } finally {
171
- try {
172
- await flushInvocation({
173
- telemetry,
174
- usageRollup,
175
- eventSummary,
176
- shouldUseDatabase,
177
- flushTimeoutMs,
178
- context,
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
- log: {
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 { addDeniedKeys } = require('../../logs/redact');
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
- addDeniedKeys(Object.values(schema).flatMap((config) => config.fields));
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
- addDeniedKeys(fields);
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
- return LOG_LEVELS[level.toUpperCase()] ?? LOG_LEVELS.INFO;
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 { addDeniedKeys } = require('../logs/redact');
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
- const fields = extractCredentialFieldsFromModules(
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
- warnIgnoredKeys(addDeniedKeys(config?.fields));
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('../../database/websocket-connection-repository-factory');
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
- const scope = {};
71
- for (const key of INTEGRATION_KEYS) {
72
- if (store[key] !== undefined) scope[key] = store[key];
73
- }
74
- if (isPlainObject(store[LOGGER_SCOPE_KEY])) {
75
- Object.assign(scope, store[LOGGER_SCOPE_KEY]);
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 };
@@ -69,16 +69,11 @@ function hasFlushableSinks() {
69
69
  }
70
70
 
71
71
  async function flushSinks({ signal } = {}) {
72
- const flushes = getSinks()
73
- .filter((sink) => typeof sink.flush === 'function')
74
- .map((sink) => {
75
- try {
76
- return Promise.resolve(sink.flush({ signal }));
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
- s.sinks = Array.isArray(sinks) ? [...sinks] : [require('./sinks').createStdoutSink()];
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
- const copy = {};
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
- copy[key] = bindings[key];
12
+ into[key] = bindings[key];
14
13
  } catch {
15
14
  // A throwing getter drops only that binding.
16
15
  }
17
16
  }
18
- return copy;
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
- let value = source;
25
- if (typeof source === 'function') {
26
- try {
27
- value = source();
28
- } catch {
29
- continue;
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
- Object.assign(merged, copyBindings(value));
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
- if (callSite.error === undefined) callSite.error = message;
75
- return typeof error?.message === 'string' ? error.message : '';
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, dropped) {
63
+ function extendInvocation(scoped, detail) {
93
64
  const merged = { ...scoped };
94
- for (const [key, value] of Object.entries(detail)) {
95
- if (!Object.prototype.hasOwnProperty.call(scoped, key)) {
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: buildMessage(message, callSite),
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 = (source, isCallSite) => {
154
- const accepted = {};
155
- for (const [key, value] of Object.entries(source)) {
156
- if (value === undefined || value === null) continue;
157
- if (
158
- OWNED_KEYS.has(key) ||
159
- RESERVED_KEYS.has(key) ||
160
- (dropPayloads && PAYLOAD_KEYS.has(key)) ||
161
- (Object.prototype.hasOwnProperty.call(record, key) &&
162
- !canExtendInvocation(key, value))
163
- ) {
164
- dropped.add(key);
165
- continue;
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
- const canExtendInvocation = (key, value) =>
185
- key === INVOCATION_KEY &&
186
- isPlainObject(record[key]) &&
187
- isPlainObject(value);
188
- place(readFields(scope), false);
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 (measure() > MAX_RECORD_BYTES) {
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
- if (measure() <= MAX_RECORD_BYTES) break;
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
- function isDeniedKey(key) {
91
- const normalized = normalizeKey(key);
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
- if (deniedKeys.has(normalized)) return true;
94
- return DENIED_SUFFIXES.some((suffix) => normalized.endsWith(suffix));
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 DIGEST_KEY.test(normalizeKey(key));
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, segment = 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(segment ? BASE64_SEGMENT_RUN : BASE64_RUN, (run) =>
174
- hasMixedClasses(run) && (segment || !isPathLike(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, { segment: true });
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
- const normalized = new Set(keys.map(normalizeKey));
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
- isDeniedKey,
3
- isDigestKey,
4
- isOAuthCallbackShape,
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, key) {
31
- return cutString(scrubString(value, { allowHex: isDigestKey(key) }));
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 dropAll = isOAuthCallbackShape(keys);
78
+ const normalizedKeys = keys.map(normalizeKey);
79
+ const dropAll = isOAuthCallbackShapeNormalized(normalizedKeys);
77
80
  const out = {};
78
- for (const key of keys) {
79
- if (dropAll || isDeniedKey(key)) {
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(normalizeKey(key))) {
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, key);
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
- if (isDeniedKey(key)) {
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, key);
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, key) {
160
+ function walk(value, depth, seen, normalizedKey) {
155
161
  switch (typeof value) {
156
162
  case 'string':
157
- return cleanString(value, key);
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 === 'EAGAIN' && retries < maxRetries) {
51
- retries += 1;
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
- retries += 1;
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.5821f44.0",
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.5821f44.0",
51
- "@friggframework/prettier-config": "2.0.0--canary.654.5821f44.0",
52
- "@friggframework/test": "2.0.0--canary.654.5821f44.0",
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": "5821f44da7972564eb82727042b9760556114670"
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
- 'Failed to update individual user: Document not found after update. ' +
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
- 'Failed to update organization user: Document not found after update. ' +
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
  }