@friggframework/core 2.0.0-next.79 → 2.0.0-next.80

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/Worker.js CHANGED
@@ -20,15 +20,45 @@ class Worker {
20
20
  const records = get(params, 'Records');
21
21
  const batchItemFailures = [];
22
22
 
23
+ console.log(
24
+ `[Worker] run: processing ${records.length} record(s)`
25
+ );
26
+
23
27
  for (const record of records) {
28
+ // Log record entry with SQS-provided attributes useful for tracing
29
+ // delivery history (ApproximateReceiveCount for retries, etc.).
30
+ let parsedEvent;
31
+ try {
32
+ parsedEvent = JSON.parse(record.body)?.event;
33
+ } catch {
34
+ parsedEvent = undefined;
35
+ }
36
+ console.log(`[Worker] record begin`, {
37
+ messageId: record.messageId,
38
+ event: parsedEvent,
39
+ receiveCount: record.attributes?.ApproximateReceiveCount,
40
+ });
41
+
24
42
  try {
25
43
  const runParams = JSON.parse(record.body);
26
44
  this._validateParams(runParams);
27
45
  await this._run(runParams, context);
46
+ console.log(`[Worker] record success`, {
47
+ messageId: record.messageId,
48
+ event: runParams?.event,
49
+ });
28
50
  } catch (error) {
29
51
  if (error.isHaltError) {
30
52
  // HaltError means "discard this message, don't retry".
31
53
  // Treat as success so SQS deletes it from the queue.
54
+ // Logged explicitly — silent discards made prod debugging
55
+ // extremely hard; keep this visible.
56
+ console.warn(`[Worker] record halted (discarded, no retry)`, {
57
+ messageId: record.messageId,
58
+ event: parsedEvent,
59
+ reason: error.message,
60
+ statusCode: error.statusCode,
61
+ });
32
62
  continue;
33
63
  }
34
64
  console.error(`[Worker] Failed to process record ${record.messageId}:`, error);
@@ -36,6 +66,12 @@ class Worker {
36
66
  }
37
67
  }
38
68
 
69
+ if (batchItemFailures.length > 0) {
70
+ console.warn(
71
+ `[Worker] run: returning ${batchItemFailures.length} batchItemFailure(s) of ${records.length}`
72
+ );
73
+ }
74
+
39
75
  return { batchItemFailures };
40
76
  }
41
77
 
@@ -5,6 +5,45 @@
5
5
  const { initDebugLog, flushDebugLog } = require('../logs');
6
6
  const { secretsToEnv } = require('./secrets-to-env');
7
7
 
8
+ // Best-effort extraction of correlation identifiers from a Lambda event.
9
+ // For SQS: pulls messageIds + parsed event/processId/integrationId from each
10
+ // record body. For HTTP: pulls method+path. Never throws.
11
+ const summarizeLambdaEvent = (event) => {
12
+ if (!event) return {};
13
+ if (Array.isArray(event.Records)) {
14
+ return {
15
+ source: 'sqs',
16
+ records: event.Records.map((r) => {
17
+ let parsed = {};
18
+ try {
19
+ const body = JSON.parse(r.body);
20
+ parsed = {
21
+ event: body?.event,
22
+ processId: body?.data?.processId,
23
+ integrationId: body?.data?.integrationId,
24
+ };
25
+ } catch {
26
+ // ignore unparseable bodies
27
+ }
28
+ return {
29
+ messageId: r.messageId,
30
+ receiveCount: r.attributes?.ApproximateReceiveCount,
31
+ ...parsed,
32
+ };
33
+ }),
34
+ };
35
+ }
36
+ if (event.httpMethod || event.requestContext?.http) {
37
+ return {
38
+ source: 'http',
39
+ method:
40
+ event.httpMethod || event.requestContext?.http?.method,
41
+ path: event.path || event.rawPath,
42
+ };
43
+ }
44
+ return { source: 'other' };
45
+ };
46
+
8
47
  const createHandler = (optionByName = {}) => {
9
48
  const {
10
49
  eventName = 'Event',
@@ -17,7 +56,18 @@ const createHandler = (optionByName = {}) => {
17
56
  }
18
57
 
19
58
  return async (event, context) => {
59
+ const eventSummary = summarizeLambdaEvent(event);
60
+
20
61
  try {
62
+ console.info(
63
+ `[createHandler] ${eventName}: handler entry`,
64
+ {
65
+ eventName,
66
+ awsRequestId: context?.awsRequestId,
67
+ ...eventSummary,
68
+ }
69
+ );
70
+
21
71
  initDebugLog(eventName, event);
22
72
 
23
73
  const requestMethod = event.httpMethod;
@@ -62,7 +112,21 @@ const createHandler = (optionByName = {}) => {
62
112
  // Handle server-to-server responses.
63
113
 
64
114
  // Halt errors are logged but suceed and won't be retried.
115
+ // Log explicitly — silent suppression here previously made stuck
116
+ // messages invisible to observability tooling. Include
117
+ // eventSummary so operators can correlate across concurrent
118
+ // invocations (processId / messageIds / HTTP path).
65
119
  if (error.isHaltError === true) {
120
+ console.warn(
121
+ `[createHandler] ${eventName}: halt error suppressed (no retry)`,
122
+ {
123
+ eventName,
124
+ errorName: error.name,
125
+ errorMessage: error.message,
126
+ statusCode: error.statusCode,
127
+ ...eventSummary,
128
+ }
129
+ );
66
130
  return;
67
131
  }
68
132
 
@@ -141,8 +141,17 @@ const loadIntegrationForProcess = async (processId, integrationClass) => {
141
141
  };
142
142
 
143
143
  const createQueueWorker = (integrationClass) => {
144
+ const integrationName = integrationClass.Definition.name;
145
+
144
146
  class QueueWorker extends Worker {
145
147
  async _run(params, context) {
148
+ const logCtx = {
149
+ integration: integrationName,
150
+ event: params.event,
151
+ processId: params.data?.processId,
152
+ integrationId: params.data?.integrationId,
153
+ };
154
+
146
155
  try {
147
156
  let integrationInstance;
148
157
 
@@ -150,29 +159,46 @@ const createQueueWorker = (integrationClass) => {
150
159
  // then integrationId (for ANY event type that needs hydration),
151
160
  // fallback to unhydrated instance
152
161
  if (params.data?.processId) {
162
+ console.log(
163
+ `[QueueWorker] hydrating by processId`,
164
+ logCtx
165
+ );
153
166
  integrationInstance = await loadIntegrationForProcess(
154
167
  params.data.processId,
155
168
  integrationClass
156
169
  );
170
+ console.log(`[QueueWorker] hydrated`, {
171
+ ...logCtx,
172
+ integrationStatus: integrationInstance?.status,
173
+ hydratedIntegrationId: integrationInstance?.id,
174
+ });
157
175
  if (['DISABLED', 'ERROR'].includes(integrationInstance?.status)) {
158
176
  console.warn(
159
- `[${integrationClass.Definition.name}] Integration for process ${params.data.processId} is ${integrationInstance.status}. Discarding ${params.event} message.`
177
+ `[${integrationName}] Integration for process ${params.data.processId} is ${integrationInstance.status}. Discarding ${params.event} message.`
160
178
  );
161
179
  return;
162
180
  }
163
181
  } else if (params.data?.integrationId) {
182
+ console.log(
183
+ `[QueueWorker] hydrating by integrationId`,
184
+ logCtx
185
+ );
164
186
  integrationInstance = await loadIntegrationForWebhook(
165
187
  params.data.integrationId
166
188
  );
167
189
  if (!integrationInstance) {
168
190
  console.warn(
169
- `[${integrationClass.Definition.name}] Integration ${params.data.integrationId} no longer exists. Discarding ${params.event} message.`
191
+ `[${integrationName}] Integration ${params.data.integrationId} no longer exists. Discarding ${params.event} message.`
170
192
  );
171
193
  return;
172
194
  }
195
+ console.log(`[QueueWorker] hydrated`, {
196
+ ...logCtx,
197
+ integrationStatus: integrationInstance?.status,
198
+ });
173
199
  if (['DISABLED', 'ERROR'].includes(integrationInstance.status)) {
174
200
  console.warn(
175
- `[${integrationClass.Definition.name}] Integration ${params.data.integrationId} is ${integrationInstance.status}. Discarding ${params.event} message.`
201
+ `[${integrationName}] Integration ${params.data.integrationId} is ${integrationInstance.status}. Discarding ${params.event} message.`
176
202
  );
177
203
  return;
178
204
  }
@@ -181,6 +207,10 @@ const createQueueWorker = (integrationClass) => {
181
207
  // There will be cases where we need to use helpers that the api modules can export.
182
208
  // Like for HubSpot, the answer is to do a reverse lookup for the integration by the entity external ID (HubSpot Portal ID),
183
209
  // and then you'll have the integration ID available to hydrate from.
210
+ console.log(
211
+ `[QueueWorker] no processId/integrationId — running dry instance`,
212
+ logCtx
213
+ );
184
214
  integrationInstance = new integrationClass();
185
215
  }
186
216
 
@@ -188,14 +218,17 @@ const createQueueWorker = (integrationClass) => {
188
218
  integrationInstance
189
219
  );
190
220
 
191
- return await dispatcher.dispatchJob({
221
+ console.log(`[QueueWorker] dispatching ${params.event}`, logCtx);
222
+ const result = await dispatcher.dispatchJob({
192
223
  event: params.event,
193
224
  data: params.data,
194
225
  context: context,
195
226
  });
227
+ console.log(`[QueueWorker] ${params.event} dispatched ok`, logCtx);
228
+ return result;
196
229
  } catch (error) {
197
230
  console.error(
198
- `Error in ${params.event} for ${integrationClass.Definition.name}:`,
231
+ `Error in ${params.event} for ${integrationName}:`,
199
232
  error
200
233
  );
201
234
 
@@ -207,7 +240,12 @@ const createQueueWorker = (integrationClass) => {
207
240
  if (status && status >= 400 && status < 500 && status !== 408 && status !== 429) {
208
241
  error.isHaltError = true;
209
242
  console.warn(
210
- `[${integrationClass.Definition.name}] Permanent ${status} error for ${params.event} — message will be discarded (no retry)`
243
+ `[${integrationName}] Permanent ${status} error for ${params.event} — message will be discarded (no retry)`,
244
+ {
245
+ ...logCtx,
246
+ errorName: error.name,
247
+ errorMessage: error.message,
248
+ }
211
249
  );
212
250
  }
213
251
 
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-next.79",
4
+ "version": "2.0.0-next.80",
5
5
  "dependencies": {
6
6
  "@aws-sdk/client-apigatewaymanagementapi": "^3.588.0",
7
7
  "@aws-sdk/client-kms": "^3.588.0",
@@ -38,9 +38,9 @@
38
38
  }
39
39
  },
40
40
  "devDependencies": {
41
- "@friggframework/eslint-config": "2.0.0-next.79",
42
- "@friggframework/prettier-config": "2.0.0-next.79",
43
- "@friggframework/test": "2.0.0-next.79",
41
+ "@friggframework/eslint-config": "2.0.0-next.80",
42
+ "@friggframework/prettier-config": "2.0.0-next.80",
43
+ "@friggframework/test": "2.0.0-next.80",
44
44
  "@prisma/client": "^6.17.0",
45
45
  "@types/lodash": "4.17.15",
46
46
  "@typescript-eslint/eslint-plugin": "^8.0.0",
@@ -80,5 +80,5 @@
80
80
  "publishConfig": {
81
81
  "access": "public"
82
82
  },
83
- "gitHead": "b2a04b537ad3efb6206d14862f5dff5053829c73"
83
+ "gitHead": "7a66dfcb02143941411ff26aaee866afc1473df8"
84
84
  }
@@ -18,13 +18,88 @@ const awsConfigOptions = () => {
18
18
 
19
19
  const sqs = new SQSClient(awsConfigOptions());
20
20
 
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
+ // Inspect SendMessageBatchResult for partial failures and log them.
37
+ // AWS SendMessageBatch can succeed at the HTTP level while individual entries
38
+ // are rejected (KMS errors, per-entry throttling, service errors). Callers that
39
+ // don't inspect result.Failed silently lose those messages. This logs the
40
+ // details — including the logical event/processId of the failed entry — so
41
+ // the loss is visible and correlatable in CloudWatch.
42
+ const inspectBatchResult = (result, queueUrl, buffer) => {
43
+ const bufferSize = buffer.length;
44
+ const failedCount = result?.Failed?.length ?? 0;
45
+ const successCount = result?.Successful?.length ?? 0;
46
+
47
+ // Index buffer by Id so we can attach event/processId to failures.
48
+ const bufferById = new Map(buffer.map((b) => [b.Id, b]));
49
+
50
+ if (failedCount > 0) {
51
+ console.error(
52
+ `[QueuerUtil] SendMessageBatch partial failure: ${failedCount}/${bufferSize} failed`,
53
+ {
54
+ queueUrl,
55
+ bufferSize,
56
+ successCount,
57
+ failedCount,
58
+ failed: result.Failed.map((f) => {
59
+ const bufEntry = bufferById.get(f.Id);
60
+ const summary = bufEntry
61
+ ? summarizeMessageBody(bufEntry.MessageBody)
62
+ : {};
63
+ return {
64
+ Id: f.Id,
65
+ Code: f.Code,
66
+ SenderFault: f.SenderFault,
67
+ Message: f.Message,
68
+ ...summary,
69
+ };
70
+ }),
71
+ }
72
+ );
73
+ } else if (successCount > 0) {
74
+ // Include a compact per-entry summary so operators can correlate
75
+ // "which send contained which logical message" during incident triage.
76
+ const entries = result.Successful.map((s) => {
77
+ const bufEntry = bufferById.get(s.Id);
78
+ const summary = bufEntry
79
+ ? summarizeMessageBody(bufEntry.MessageBody)
80
+ : {};
81
+ return { MessageId: s.MessageId, ...summary };
82
+ });
83
+ console.log(
84
+ `[QueuerUtil] SendMessageBatch ok: ${successCount}/${bufferSize} to ${queueUrl}`,
85
+ { entries }
86
+ );
87
+ }
88
+
89
+ return result;
90
+ };
91
+
21
92
  const QueuerUtil = {
22
93
  send: async (message, queueUrl) => {
23
94
  const command = new SendMessageCommand({
24
95
  MessageBody: JSON.stringify(message),
25
96
  QueueUrl: queueUrl,
26
97
  });
27
- return sqs.send(command);
98
+ const result = await sqs.send(command);
99
+ console.log(
100
+ `[QueuerUtil] SendMessage ok: MessageId=${result?.MessageId} to ${queueUrl}`
101
+ );
102
+ return result;
28
103
  },
29
104
 
30
105
  batchSend: async (entries = [], queueUrl) => {
@@ -42,7 +117,8 @@ const QueuerUtil = {
42
117
  Entries: buffer,
43
118
  QueueUrl: queueUrl,
44
119
  });
45
- await sqs.send(command);
120
+ const result = await sqs.send(command);
121
+ inspectBatchResult(result, queueUrl, buffer);
46
122
  // Purge the buffer
47
123
  buffer.splice(0, buffer.length);
48
124
  }
@@ -54,7 +130,8 @@ const QueuerUtil = {
54
130
  Entries: buffer,
55
131
  QueueUrl: queueUrl,
56
132
  });
57
- return sqs.send(command);
133
+ const result = await sqs.send(command);
134
+ return inspectBatchResult(result, queueUrl, buffer);
58
135
  }
59
136
 
60
137
  // If we're exact... just return an empty object for now