@friggframework/core 2.0.0-next.112 → 2.0.0-next.113

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.
Files changed (64) hide show
  1. package/.eslintrc.json +18 -1
  2. package/CLAUDE.md +51 -12
  3. package/README.md +19 -22
  4. package/core/CLAUDE.md +28 -13
  5. package/core/Worker.js +32 -37
  6. package/core/create-handler.js +97 -197
  7. package/core/index.js +8 -1
  8. package/core/invocation-scope.js +225 -0
  9. package/core/secrets-to-env.js +7 -6
  10. package/database/config.js +0 -7
  11. package/database/encryption/README.md +5 -1
  12. package/database/encryption/encryption-schema-registry.js +3 -0
  13. package/database/encryption/logger.js +5 -2
  14. package/errors/fetch-error.js +48 -65
  15. package/handlers/app-definition-loader.js +38 -2
  16. package/handlers/app-handler-helpers.js +18 -4
  17. package/handlers/backend-utils.js +10 -13
  18. package/handlers/database-migration-handler.js +10 -1
  19. package/handlers/integration-event-dispatcher.js +2 -2
  20. package/handlers/routers/integration-webhook-routers.js +13 -6
  21. package/handlers/routers/websocket.js +27 -7
  22. package/handlers/workers/db-migration.js +12 -1
  23. package/handlers/workers/dlq-processor.js +64 -38
  24. package/index.js +19 -1
  25. package/integrations/integration-base.js +56 -38
  26. package/integrations/integration-router.js +21 -20
  27. package/integrations/use-cases/delete-integration-for-user.js +24 -13
  28. package/jest.config.js +2 -0
  29. package/logs/.eslintrc.json +26 -1
  30. package/logs/__fixtures__/events.js +82 -0
  31. package/logs/__fixtures__/matchers.js +45 -0
  32. package/logs/__fixtures__/secrets.js +59 -0
  33. package/logs/__fixtures__/vectors.js +329 -0
  34. package/logs/__fixtures__/with-env.js +45 -0
  35. package/logs/context.js +109 -0
  36. package/logs/contract-keys.js +67 -0
  37. package/logs/debug-shims.js +46 -0
  38. package/logs/denied-keys.js +21 -0
  39. package/logs/index.js +14 -3
  40. package/logs/jest-logger-setup.js +25 -0
  41. package/logs/levels.js +71 -0
  42. package/logs/logger-runtime.js +137 -0
  43. package/logs/logger.js +134 -42
  44. package/logs/record.js +276 -0
  45. package/logs/redact.js +328 -0
  46. package/logs/serialize.js +329 -0
  47. package/logs/sinks.js +93 -0
  48. package/logs/summarize-event.js +163 -0
  49. package/modules/module.js +32 -19
  50. package/modules/requester/oauth-2.js +28 -22
  51. package/modules/requester/requester.js +69 -6
  52. package/package.json +5 -6
  53. package/queues/queue-delivery.js +41 -0
  54. package/queues/queuer-util.js +1 -15
  55. package/telemetry/bind-telemetry-context.js +4 -0
  56. package/telemetry/instrument-handler.js +19 -2
  57. package/telemetry/no-op-telemetry.js +4 -0
  58. package/telemetry/otel-telemetry.js +25 -6
  59. package/telemetry/telemetry-context.js +19 -8
  60. package/telemetry/telemetry-runtime.js +17 -0
  61. package/telemetry/telemetry-service.js +1 -0
  62. package/types/errors/index.d.ts +24 -10
  63. package/types/logs/index.d.ts +61 -2
  64. package/user/repositories/user-repository-documentdb.js +10 -42
@@ -1,5 +1,6 @@
1
1
  const { v4: uuid } = require('uuid');
2
2
  const { SQSClient, SendMessageCommand, SendMessageBatchCommand } = require('@aws-sdk/client-sqs');
3
+ const { summarizeMessageBody } = require('../logs/summarize-event');
3
4
 
4
5
  const awsConfigOptions = () => {
5
6
  const config = {};
@@ -18,21 +19,6 @@ const awsConfigOptions = () => {
18
19
 
19
20
  const sqs = new SQSClient(awsConfigOptions());
20
21
 
21
- // Best-effort extraction of the logical event/processId/integrationId from a
22
- // JSON message body. Used only for log correlation — never throws.
23
- const summarizeMessageBody = (bodyStr) => {
24
- try {
25
- const parsed = JSON.parse(bodyStr);
26
- return {
27
- event: parsed?.event,
28
- processId: parsed?.data?.processId,
29
- integrationId: parsed?.data?.integrationId,
30
- };
31
- } catch {
32
- return {};
33
- }
34
- };
35
-
36
22
  // Inspect SendMessageBatchResult for partial failures and log them.
37
23
  // AWS SendMessageBatch can succeed at the HTTP level while individual entries
38
24
  // are rejected (KMS errors, per-entry throttling, service errors). Callers that
@@ -67,6 +67,10 @@ function bindTelemetryContext(base, getContext) {
67
67
  ? base.shutdown(...args)
68
68
  : undefined,
69
69
  isEnabled: (...args) => base.isEnabled(...args),
70
+ getActiveSpanContext: () =>
71
+ typeof base.getActiveSpanContext === 'function'
72
+ ? base.getActiveSpanContext()
73
+ : null,
70
74
  };
71
75
  }
72
76
 
@@ -1,3 +1,5 @@
1
+ const { getLoggerScope, runInContext } = require('../logs/context');
2
+
1
3
  /**
2
4
  * Wraps a single integration handler invocation with a span + the
3
5
  * `frigg.handler.invocations` counter. Shared by the two
@@ -16,6 +18,15 @@
16
18
  * @param {{event: string, eventType: string}} descriptor Event name + bounded type.
17
19
  * @param {Function} fn The handler invocation.
18
20
  */
21
+ // The trace link to the logs (ADR-048 §7): span attributes only, never labels.
22
+ function scopeIdAttributes() {
23
+ const { requestId, messageId } = getLoggerScope();
24
+ const attributes = {};
25
+ if (requestId !== undefined && requestId !== null) attributes.requestId = requestId;
26
+ if (messageId !== undefined && messageId !== null) attributes.messageId = messageId;
27
+ return attributes;
28
+ }
29
+
19
30
  async function instrumentHandler(telemetry, descriptor = {}, fn) {
20
31
  if (!telemetry || typeof telemetry.span !== 'function') {
21
32
  return fn();
@@ -36,6 +47,7 @@ async function instrumentHandler(telemetry, descriptor = {}, fn) {
36
47
  integration_type: integrationType,
37
48
  event: eventType,
38
49
  event_name: eventName,
50
+ ...scopeIdAttributes(),
39
51
  });
40
52
  }
41
53
  // `event_name` (potentially high-cardinality action id) rides the
@@ -70,6 +82,11 @@ async function instrumentHandler(telemetry, descriptor = {}, fn) {
70
82
  }
71
83
  });
72
84
 
85
+ // The logger scope goes straight into the one store, so a custom
86
+ // telemetry service never sees the logger sub-object.
87
+ const runInLoggerScope = () =>
88
+ runInContext({ log: { integrationEvent: eventName } }, runInstrumented);
89
+
73
90
  if (typeof telemetry.withContext === 'function') {
74
91
  return telemetry.withContext(
75
92
  {
@@ -78,10 +95,10 @@ async function instrumentHandler(telemetry, descriptor = {}, fn) {
78
95
  integrationType: context.integrationType,
79
96
  version: context.version,
80
97
  },
81
- runInstrumented
98
+ runInLoggerScope
82
99
  );
83
100
  }
84
- return runInstrumented();
101
+ return runInLoggerScope();
85
102
  }
86
103
 
87
104
  module.exports = { instrumentHandler };
@@ -62,6 +62,10 @@ class NoOpTelemetry {
62
62
  isEnabled() {
63
63
  return false;
64
64
  }
65
+
66
+ getActiveSpanContext() {
67
+ return null;
68
+ }
65
69
  }
66
70
 
67
71
  module.exports = { NoOpTelemetry };
@@ -17,6 +17,11 @@ const {
17
17
  runWithTelemetryContext,
18
18
  mergeTelemetryContext,
19
19
  } = require('./telemetry-context');
20
+ const { serializeError } = require('../logs/serialize');
21
+ const { LOGGER_SCOPE_KEY } = require('../logs/context');
22
+
23
+ const BAGGAGE_TYPES = new Set(['string', 'number', 'boolean', 'bigint']);
24
+ const isBaggageValue = (value) => BAGGAGE_TYPES.has(typeof value);
20
25
 
21
26
  const TRACER_NAME = 'frigg';
22
27
  const METRIC_EXPORT_INTERVAL_MS =
@@ -138,6 +143,15 @@ class OtelTelemetry {
138
143
  this._bus.emit('event', payload);
139
144
  }
140
145
 
146
+ getActiveSpanContext() {
147
+ const spanContext = otelApi.trace.getActiveSpan()?.spanContext();
148
+ if (!spanContext || !otelApi.isSpanContextValid(spanContext)) {
149
+ return null;
150
+ }
151
+ const { traceId, spanId, traceFlags } = spanContext;
152
+ return { traceId, spanId, traceFlags };
153
+ }
154
+
141
155
  startSpan(name, options) {
142
156
  return this._tracer.startSpan(name, options);
143
157
  }
@@ -150,10 +164,16 @@ class OtelTelemetry {
150
164
  span.setStatus({ code: otelApi.SpanStatusCode.OK });
151
165
  return result;
152
166
  } catch (err) {
153
- span.recordException(err);
167
+ const serialized = serializeError(err);
168
+ span.recordException({
169
+ name: serialized.type,
170
+ message: serialized.message,
171
+ stack: serialized.stack,
172
+ code: serialized.code,
173
+ });
154
174
  span.setStatus({
155
175
  code: otelApi.SpanStatusCode.ERROR,
156
- message: err && err.message,
176
+ message: serialized.message,
157
177
  });
158
178
  throw err;
159
179
  } finally {
@@ -170,10 +190,9 @@ class OtelTelemetry {
170
190
  */
171
191
  async withContext(attributes = {}, fn) {
172
192
  const entries = {};
173
- for (const [key, value] of Object.entries(attributes)) {
174
- if (value !== undefined && value !== null) {
175
- entries[key] = { value: String(value) };
176
- }
193
+ for (const [key, value] of Object.entries(attributes || {})) {
194
+ if (key === LOGGER_SCOPE_KEY || !isBaggageValue(value)) continue;
195
+ entries[key] = { value: String(value) };
177
196
  }
178
197
  const baggage = otelApi.propagation.createBaggage(entries);
179
198
  const ctx = otelApi.propagation.setBaggage(
@@ -1,8 +1,13 @@
1
- const { AsyncLocalStorage } = require('node:async_hooks');
1
+ const {
2
+ runInContext,
3
+ getContext,
4
+ hasOnlyLoggerKeys,
5
+ LOGGER_SCOPE_KEY,
6
+ } = require('../logs/context');
2
7
 
3
8
  /**
4
- * Ambient telemetry context. A single process-wide AsyncLocalStorage
5
- * holds the standard identifiers ({integrationId, integrationType, userId,
9
+ * Ambient telemetry context. The one process-wide AsyncLocalStorage
10
+ * (owned by logs/context.js, shared with the logger scope) holds the standard identifiers ({integrationId, integrationType, userId,
6
11
  * version, ...}) for the duration of a handler, set once at the handler seam via
7
12
  * `telemetry.withContext(...)`. Any emission during that async scope — including
8
13
  * deep calls like an API-module request — reads the ambient context, so the
@@ -11,24 +16,30 @@ const { AsyncLocalStorage } = require('node:async_hooks');
11
16
  * on the no-op path). This is the propagation mechanism the plan intended.
12
17
  *
13
18
  * Only bus-payload `context` is populated from here; high-cardinality ids never
14
- * touch OTel metric labels.
19
+ * touch OTel metric labels. A nested run merges: an undefined key keeps the
20
+ * outer value, an explicit null is stored as null.
15
21
  */
16
- const store = new AsyncLocalStorage();
22
+ function withoutLoggerKeys(store) {
23
+ if (!store || hasOnlyLoggerKeys(store)) return null;
24
+ const { [LOGGER_SCOPE_KEY]: _logger, ...rest } = store;
25
+ return rest;
26
+ }
17
27
 
18
28
  function runWithTelemetryContext(context, fn) {
19
- return store.run(context || {}, fn);
29
+ return runInContext(context, fn);
20
30
  }
21
31
 
22
32
  function getTelemetryContextStore() {
23
- return store.getStore() || null;
33
+ return withoutLoggerKeys(getContext());
24
34
  }
25
35
 
26
36
  /**
27
37
  * Merge the ambient context with an explicit per-call context (explicit keys
28
38
  * win, e.g. a request `url`). Returns undefined when there is nothing to attach.
39
+ * The logger sub-object never reaches the bus payload.
29
40
  */
30
41
  function mergeTelemetryContext(explicit) {
31
- const ambient = store.getStore();
42
+ const ambient = withoutLoggerKeys(getContext());
32
43
  if (!ambient && !explicit) return undefined;
33
44
  return { ...(ambient || {}), ...(explicit || {}) };
34
45
  }
@@ -7,6 +7,7 @@
7
7
 
8
8
  const { createTelemetry } = require('./telemetry-service');
9
9
  const { NoOpTelemetry } = require('./no-op-telemetry');
10
+ const loggerRuntime = require('../logs/logger-runtime');
10
11
 
11
12
  const UNSET = Symbol('unset');
12
13
 
@@ -21,12 +22,27 @@ function loadTelemetryConfig() {
21
22
  return loadAppDefinition();
22
23
  }
23
24
 
25
+ function activeSpanContext() {
26
+ const telemetry = state.telemetry;
27
+ return telemetry && typeof telemetry.getActiveSpanContext === 'function'
28
+ ? telemetry.getActiveSpanContext()
29
+ : null;
30
+ }
31
+
32
+ // resetLoggerForTests clears the provider, so check on every call.
33
+ function ensureSpanContextProvider() {
34
+ if (loggerRuntime.getSpanContextProvider() !== activeSpanContext) {
35
+ loggerRuntime.setSpanContextProvider(activeSpanContext);
36
+ }
37
+ }
38
+
24
39
  /**
25
40
  * The process telemetry service. Created lazily on first use and reused for the
26
41
  * life of the Lambda container, so the OTel SDK initialises at most once per cold
27
42
  * start. Never throws — any failure falls back to the no-op.
28
43
  */
29
44
  function getTelemetry() {
45
+ ensureSpanContextProvider();
30
46
  if (state.telemetry) return state.telemetry;
31
47
  try {
32
48
  const { telemetry = {} } = loadTelemetryConfig();
@@ -136,6 +152,7 @@ function resetTelemetryRuntimeForTests() {
136
152
  function setTelemetryForTests(telemetry) {
137
153
  state = newState();
138
154
  state.telemetry = telemetry;
155
+ ensureSpanContextProvider();
139
156
  }
140
157
 
141
158
  module.exports = {
@@ -8,6 +8,7 @@ const { createTelemetryEventBus } = require('./telemetry-event-bus');
8
8
  * count(name, value?, attributes?, context?) · event(name, attributes?, context?)
9
9
  * span(name, fn) · startSpan(name?) · withContext(context, fn)
10
10
  * on(eventType, cb) · forceFlush() · shutdown() · isEnabled()
11
+ * getActiveSpanContext() → { traceId, spanId, traceFlags } | null
11
12
  */
12
13
 
13
14
  /**
@@ -6,11 +6,30 @@ declare module "@friggframework/errors" {
6
6
  export class FetchError extends BaseError {
7
7
  constructor(options?: FetchErrorConstructor);
8
8
 
9
+ statusCode?: number;
10
+ method: string;
11
+ /** Request URL without userinfo; each query value is `REDACTED`. */
12
+ url: string;
13
+ /** Non-enumerable. The raw response, or `null`. */
14
+ readonly response: FetchErrorResponse | null;
15
+ /** Non-enumerable. The raw response body; never part of `message`. */
16
+ readonly body: any;
17
+ isTimeout?: boolean;
18
+ timeoutMs?: number;
19
+
9
20
  static create(options?: CreateFetchErrorParams): Promise<FetchError>;
10
21
  }
11
22
 
23
+ type FetchErrorResponse = {
24
+ headers?: object;
25
+ status?: number;
26
+ statusText?: string;
27
+ bodyUsed?: boolean;
28
+ text?: () => Promise<string>;
29
+ };
30
+
12
31
  type FetchErrorConstructor = {
13
- resource?: string;
32
+ resource?: string | URL | { url: string };
14
33
  init?: Partial<{
15
34
  method: string;
16
35
  credentials: string;
@@ -19,18 +38,13 @@ declare module "@friggframework/errors" {
19
38
  body: URLSearchParams | any;
20
39
  returnFullRes: false;
21
40
  }>;
22
- response?: {
23
- headers?: object;
24
- status?: number;
25
- statusText?: string;
26
- text?: () => Promise<string>;
27
- };
41
+ response?: FetchErrorResponse | null;
42
+ cause?: unknown;
28
43
  responseBody?: any;
44
+ body?: any;
29
45
  };
30
46
 
31
- type CreateFetchErrorParams = Omit<FetchErrorConstructor, "responseBody"> & {
32
- body: any;
33
- };
47
+ type CreateFetchErrorParams = FetchErrorConstructor;
34
48
 
35
49
  export class HaltError extends BaseError {
36
50
  isHaltError: boolean;
@@ -1,5 +1,64 @@
1
- declare module "@friggframework/logs/logger" {
1
+ declare module "@friggframework/core/logs" {
2
+ export type LogLevel = "TRACE" | "DEBUG" | "INFO" | "WARN" | "ERROR" | "FATAL";
3
+
4
+ export type LogFields = Record<string, unknown>;
5
+
6
+ export interface LogRecord {
7
+ readonly timestamp: string;
8
+ readonly level: LogLevel;
9
+ readonly message: string;
10
+ readonly logger: string;
11
+ readonly [key: string]: unknown;
12
+ }
13
+
14
+ export interface Logger {
15
+ readonly name: string;
16
+ trace(message: unknown, fields?: LogFields): void;
17
+ debug(message: unknown, fields?: LogFields): void;
18
+ info(message: unknown, fields?: LogFields): void;
19
+ warn(message: unknown, fields?: LogFields): void;
20
+ error(message: unknown, fields?: LogFields): void;
21
+ fatal(message: unknown, fields?: LogFields): void;
22
+ child(bindings: LogFields | (() => LogFields)): Logger;
23
+ isLevelEnabled(level: string): boolean;
24
+ }
25
+
26
+ export interface LogSink {
27
+ name: string;
28
+ write(record: LogRecord): void;
29
+ flush?(options: { signal?: AbortSignal }): Promise<void> | void;
30
+ }
31
+
32
+ export interface MemorySink extends LogSink {
33
+ readonly records: LogRecord[];
34
+ clear(): void;
35
+ }
36
+
37
+ export interface SerializedError {
38
+ type: string;
39
+ message: string;
40
+ code?: string | number;
41
+ status?: number;
42
+ stack?: string;
43
+ cause?: SerializedError | string;
44
+ }
45
+
46
+ export function getLogger(name?: string): Logger;
47
+ export function createMemorySink(options?: { install?: boolean }): MemorySink;
48
+ export function resetLoggerForTests(options?: {
49
+ level?: LogLevel | Lowercase<LogLevel>;
50
+ sinks?: LogSink[];
51
+ /** Record frigg.* WARN+ records without eventName. Kept across resets that omit it. */
52
+ trackViolations?: boolean;
53
+ }): void;
54
+ export function serializeError(error: unknown): SerializedError;
55
+ export function redactValue(value: unknown): unknown;
56
+ export function toSanitizedSurrogate(error: unknown): Error;
57
+
58
+ /** @deprecated Use getLogger or this.logger. */
2
59
  export function debug(...messages: any[]): void;
60
+ /** @deprecated Use getLogger or this.logger. */
3
61
  export function initDebugLog(...initMessages: any[]): void;
4
- export function flushDebugLog(error: any): void;
62
+ /** @deprecated Use getLogger or this.logger. */
63
+ export function flushDebugLog(error?: any): void;
5
64
  }
@@ -17,6 +17,9 @@ const { ClientSafeError } = require('../../errors');
17
17
  const {
18
18
  DocumentDBEncryptionService,
19
19
  } = require('../../database/documentdb-encryption-service');
20
+ const { getLogger } = require('../../logs');
21
+
22
+ const log = getLogger('frigg.user');
20
23
 
21
24
  /**
22
25
  * User repository for DocumentDB.
@@ -145,19 +148,8 @@ class UserRepositoryDocumentDB extends UserRepositoryInterface {
145
148
 
146
149
  // Defensive check: verify document was found after insert
147
150
  if (!created) {
148
- console.error(
149
- '[UserRepositoryDocumentDB] User not found after insert',
150
- {
151
- insertedId: fromObjectId(insertedId),
152
- params: {
153
- username: params.username,
154
- appUserId: params.appUserId,
155
- email: params.email,
156
- },
157
- }
158
- );
159
151
  throw new Error(
160
- 'Failed to create individual user: Document not found after insert. ' +
152
+ `Failed to create individual user: Document not found after insert (insertedId ${fromObjectId(insertedId)}). ` +
161
153
  'This indicates a database consistency issue.'
162
154
  );
163
155
  }
@@ -195,18 +187,8 @@ class UserRepositoryDocumentDB extends UserRepositoryInterface {
195
187
 
196
188
  // Defensive check: verify document was found after insert
197
189
  if (!created) {
198
- console.error(
199
- '[UserRepositoryDocumentDB] Organization user not found after insert',
200
- {
201
- insertedId: fromObjectId(insertedId),
202
- params: {
203
- appOrgId: params.appOrgId,
204
- name: params.name,
205
- },
206
- }
207
- );
208
190
  throw new Error(
209
- 'Failed to create organization user: Document not found after insert. ' +
191
+ `Failed to create organization user: Document not found after insert (insertedId ${fromObjectId(insertedId)}). ` +
210
192
  'This indicates a database consistency issue.'
211
193
  );
212
194
  }
@@ -291,15 +273,8 @@ class UserRepositoryDocumentDB extends UserRepositoryInterface {
291
273
 
292
274
  // Defensive check: verify document was found after update
293
275
  if (!updated) {
294
- console.error(
295
- '[UserRepositoryDocumentDB] Individual user not found after update',
296
- {
297
- userId: fromObjectId(objectId),
298
- updates,
299
- }
300
- );
301
276
  throw new Error(
302
- 'Failed to update individual user: Document not found after update. ' +
277
+ `Failed to update individual user: Document not found after update (userId ${userId}). ` +
303
278
  'This indicates a database consistency issue.'
304
279
  );
305
280
  }
@@ -332,15 +307,8 @@ class UserRepositoryDocumentDB extends UserRepositoryInterface {
332
307
  const updated = await findOne(this.prisma, 'User', { _id: objectId });
333
308
 
334
309
  if (!updated) {
335
- console.error(
336
- '[UserRepositoryDocumentDB] Organization user not found after update',
337
- {
338
- userId: fromObjectId(objectId),
339
- updates,
340
- }
341
- );
342
310
  throw new Error(
343
- 'Failed to update organization user: Document not found after update. ' +
311
+ `Failed to update organization user: Document not found after update (userId ${userId}). ` +
344
312
  'This indicates a database consistency issue.'
345
313
  );
346
314
  }
@@ -363,9 +331,9 @@ class UserRepositoryDocumentDB extends UserRepositoryInterface {
363
331
 
364
332
  _mapUser(doc) {
365
333
  if (!doc) {
366
- console.warn(
367
- '[UserRepositoryDocumentDB] _mapUser received null/undefined document'
368
- );
334
+ log.warn('_mapUser received a null document', {
335
+ eventName: 'frigg.user.map_user_null',
336
+ });
369
337
  return null;
370
338
  }
371
339