@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.
- package/.eslintrc.json +18 -1
- package/CLAUDE.md +51 -12
- package/README.md +19 -22
- package/core/CLAUDE.md +28 -13
- package/core/Worker.js +32 -37
- package/core/create-handler.js +97 -197
- package/core/index.js +8 -1
- package/core/invocation-scope.js +225 -0
- package/core/secrets-to-env.js +7 -6
- package/database/config.js +0 -7
- package/database/encryption/README.md +5 -1
- package/database/encryption/encryption-schema-registry.js +3 -0
- package/database/encryption/logger.js +5 -2
- package/errors/fetch-error.js +48 -65
- package/handlers/app-definition-loader.js +38 -2
- package/handlers/app-handler-helpers.js +18 -4
- package/handlers/backend-utils.js +10 -13
- package/handlers/database-migration-handler.js +10 -1
- package/handlers/integration-event-dispatcher.js +2 -2
- package/handlers/routers/integration-webhook-routers.js +13 -6
- package/handlers/routers/websocket.js +27 -7
- package/handlers/workers/db-migration.js +12 -1
- package/handlers/workers/dlq-processor.js +64 -38
- package/index.js +19 -1
- package/integrations/integration-base.js +56 -38
- package/integrations/integration-router.js +21 -20
- package/integrations/use-cases/delete-integration-for-user.js +24 -13
- package/jest.config.js +2 -0
- package/logs/.eslintrc.json +26 -1
- package/logs/__fixtures__/events.js +82 -0
- package/logs/__fixtures__/matchers.js +45 -0
- package/logs/__fixtures__/secrets.js +59 -0
- package/logs/__fixtures__/vectors.js +329 -0
- package/logs/__fixtures__/with-env.js +45 -0
- package/logs/context.js +109 -0
- package/logs/contract-keys.js +67 -0
- package/logs/debug-shims.js +46 -0
- package/logs/denied-keys.js +21 -0
- package/logs/index.js +14 -3
- package/logs/jest-logger-setup.js +25 -0
- package/logs/levels.js +71 -0
- package/logs/logger-runtime.js +137 -0
- package/logs/logger.js +134 -42
- package/logs/record.js +276 -0
- package/logs/redact.js +328 -0
- package/logs/serialize.js +329 -0
- package/logs/sinks.js +93 -0
- package/logs/summarize-event.js +163 -0
- package/modules/module.js +32 -19
- package/modules/requester/oauth-2.js +28 -22
- package/modules/requester/requester.js +69 -6
- package/package.json +5 -6
- package/queues/queue-delivery.js +41 -0
- package/queues/queuer-util.js +1 -15
- package/telemetry/bind-telemetry-context.js +4 -0
- package/telemetry/instrument-handler.js +19 -2
- package/telemetry/no-op-telemetry.js +4 -0
- package/telemetry/otel-telemetry.js +25 -6
- package/telemetry/telemetry-context.js +19 -8
- package/telemetry/telemetry-runtime.js +17 -0
- package/telemetry/telemetry-service.js +1 -0
- package/types/errors/index.d.ts +24 -10
- package/types/logs/index.d.ts +61 -2
- package/user/repositories/user-repository-documentdb.js +10 -42
package/logs/sinks.js
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
const fs = require('node:fs');
|
|
2
|
+
const { deepFreeze } = require('./record');
|
|
3
|
+
|
|
4
|
+
const DEFAULT_MAX_RETRIES = 10;
|
|
5
|
+
const MAX_SLEEP_MS = 20;
|
|
6
|
+
|
|
7
|
+
function sleepSync(ms) {
|
|
8
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function defaultWriteStderr(line) {
|
|
12
|
+
fs.writeSync(2, line);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function safeCode(code) {
|
|
16
|
+
return String(code ?? 'UNKNOWN').replace(/[^A-Za-z0-9_]/g, '').slice(0, 32) || 'UNKNOWN';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function createStdoutSink({
|
|
20
|
+
fd = 1,
|
|
21
|
+
writeSync = fs.writeSync,
|
|
22
|
+
writeStderr = defaultWriteStderr,
|
|
23
|
+
sleep = sleepSync,
|
|
24
|
+
maxRetries = DEFAULT_MAX_RETRIES,
|
|
25
|
+
} = {}) {
|
|
26
|
+
const reportFailure = (code) => {
|
|
27
|
+
try {
|
|
28
|
+
writeStderr(`frigg.logger.write_failed code=${safeCode(code)}\n`);
|
|
29
|
+
} catch {
|
|
30
|
+
// Nothing is left to report to.
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
name: 'stdout',
|
|
36
|
+
write(record) {
|
|
37
|
+
let buffer;
|
|
38
|
+
try {
|
|
39
|
+
buffer = Buffer.from(`${JSON.stringify(record)}\n`);
|
|
40
|
+
} catch {
|
|
41
|
+
reportFailure('SERIALIZE');
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
let offset = 0;
|
|
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
|
+
};
|
|
56
|
+
while (offset < buffer.length) {
|
|
57
|
+
let written;
|
|
58
|
+
try {
|
|
59
|
+
written = writeSync(fd, buffer, offset, buffer.length - offset);
|
|
60
|
+
} catch (error) {
|
|
61
|
+
if (error?.code !== 'EAGAIN') {
|
|
62
|
+
reportFailure(error?.code);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
if (!retry()) return;
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
if (written > 0) offset += written;
|
|
69
|
+
else if (!retry()) return;
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function createMemorySink({ install = true } = {}) {
|
|
76
|
+
const records = [];
|
|
77
|
+
const sink = {
|
|
78
|
+
name: 'memory',
|
|
79
|
+
records,
|
|
80
|
+
write(record) {
|
|
81
|
+
records.push(deepFreeze(JSON.parse(JSON.stringify(record))));
|
|
82
|
+
},
|
|
83
|
+
clear() {
|
|
84
|
+
records.length = 0;
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
if (install) {
|
|
88
|
+
require('./logger-runtime').setSinks([sink]);
|
|
89
|
+
}
|
|
90
|
+
return sink;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
module.exports = { createStdoutSink, createMemorySink };
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
const { redactUrl } = require('./redact');
|
|
2
|
+
|
|
3
|
+
// Best-effort extraction of the logical event/processId/integrationId from a
|
|
4
|
+
// JSON message body: `data.*` first, then the top level. Used only for log
|
|
5
|
+
// correlation. Never throws.
|
|
6
|
+
function summarizeMessageBody(bodyStr) {
|
|
7
|
+
try {
|
|
8
|
+
const parsed = JSON.parse(bodyStr);
|
|
9
|
+
return {
|
|
10
|
+
event: parsed?.event,
|
|
11
|
+
processId: parsed?.data?.processId ?? parsed?.processId,
|
|
12
|
+
integrationId: parsed?.data?.integrationId ?? parsed?.integrationId,
|
|
13
|
+
};
|
|
14
|
+
} catch {
|
|
15
|
+
return {};
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function objectKeys(value) {
|
|
20
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
21
|
+
? Object.keys(value)
|
|
22
|
+
: [];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function rawQueryKeys(rawQueryString) {
|
|
26
|
+
if (typeof rawQueryString !== 'string' || !rawQueryString) return [];
|
|
27
|
+
try {
|
|
28
|
+
return [...new URLSearchParams(rawQueryString).keys()];
|
|
29
|
+
} catch {
|
|
30
|
+
return [];
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function unique(list) {
|
|
35
|
+
return [...new Set(list)];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// The route template keeps ids out of the field: REST v1 `resource`, or the
|
|
39
|
+
// path part of an HTTP API v2 `routeKey` such as `GET /api/{id}`.
|
|
40
|
+
function routeTemplate(event, path) {
|
|
41
|
+
if (typeof event.resource === 'string' && event.resource) {
|
|
42
|
+
return event.resource;
|
|
43
|
+
}
|
|
44
|
+
if (typeof event.routeKey === 'string') {
|
|
45
|
+
const space = event.routeKey.indexOf(' ');
|
|
46
|
+
if (space > 0) return event.routeKey.slice(space + 1);
|
|
47
|
+
}
|
|
48
|
+
return path;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function summarizeHttp(event) {
|
|
52
|
+
const path = event.path || event.rawPath;
|
|
53
|
+
const summary = {
|
|
54
|
+
source: 'http',
|
|
55
|
+
method: event.httpMethod || event.requestContext?.http?.method,
|
|
56
|
+
path,
|
|
57
|
+
route: routeTemplate(event, path),
|
|
58
|
+
queryKeys: unique([
|
|
59
|
+
...objectKeys(event.queryStringParameters),
|
|
60
|
+
...objectKeys(event.multiValueQueryStringParameters),
|
|
61
|
+
...rawQueryKeys(event.rawQueryString),
|
|
62
|
+
]),
|
|
63
|
+
headerNames: unique(
|
|
64
|
+
[...objectKeys(event.headers), ...objectKeys(event.multiValueHeaders)].map(
|
|
65
|
+
(name) => name.toLowerCase()
|
|
66
|
+
)
|
|
67
|
+
),
|
|
68
|
+
};
|
|
69
|
+
if (typeof event.routeKey === 'string' && event.routeKey) {
|
|
70
|
+
summary.routeKey = event.routeKey;
|
|
71
|
+
}
|
|
72
|
+
return summary;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Best-effort extraction of correlation identifiers from a Lambda event.
|
|
76
|
+
// For SQS: messageIds plus the parsed event/processId/integrationId of each
|
|
77
|
+
// record body. For HTTP: method, route, query keys and header names. Never
|
|
78
|
+
// throws and never returns a body, a query value or a header value.
|
|
79
|
+
function summarizeLambdaEvent(event) {
|
|
80
|
+
try {
|
|
81
|
+
if (!event || typeof event !== 'object') return {};
|
|
82
|
+
if (Array.isArray(event.Records)) {
|
|
83
|
+
return {
|
|
84
|
+
source: 'sqs',
|
|
85
|
+
records: event.Records.map((r) => ({
|
|
86
|
+
messageId: r?.messageId,
|
|
87
|
+
receiveCount: r?.attributes?.ApproximateReceiveCount,
|
|
88
|
+
...summarizeMessageBody(r?.body),
|
|
89
|
+
})),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
if (event.httpMethod || event.requestContext?.http) {
|
|
93
|
+
return summarizeHttp(event);
|
|
94
|
+
}
|
|
95
|
+
return { source: 'other' };
|
|
96
|
+
} catch {
|
|
97
|
+
return { source: 'other' };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// The bounded form that goes into the logger scope: no per-record array.
|
|
102
|
+
function toScopeInvocation(summary) {
|
|
103
|
+
if (!summary || typeof summary !== 'object' || !summary.source) {
|
|
104
|
+
return { source: 'other' };
|
|
105
|
+
}
|
|
106
|
+
if (summary.source === 'sqs') {
|
|
107
|
+
return {
|
|
108
|
+
source: 'sqs',
|
|
109
|
+
recordCount: Array.isArray(summary.records) ? summary.records.length : 0,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
if (summary.source === 'http') {
|
|
113
|
+
const invocation = {
|
|
114
|
+
source: 'http',
|
|
115
|
+
method: summary.method,
|
|
116
|
+
route: summary.route,
|
|
117
|
+
};
|
|
118
|
+
if (summary.routeKey) invocation.routeKey = summary.routeKey;
|
|
119
|
+
return invocation;
|
|
120
|
+
}
|
|
121
|
+
return { source: summary.source };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Per-request detail for the entry and failure records, never for the
|
|
125
|
+
// scope. A raw path can hold a token, so it goes through redactUrl.
|
|
126
|
+
function toRequestDetails(summary) {
|
|
127
|
+
if (!summary || summary.source !== 'http') return {};
|
|
128
|
+
return {
|
|
129
|
+
path: redactUrl(summary.path ?? ''),
|
|
130
|
+
queryKeys: summary.queryKeys ?? [],
|
|
131
|
+
headerNames: summary.headerNames ?? [],
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// The full redacted request summary (ADR-048 §6: it goes under `invocation`).
|
|
136
|
+
function toRequestInvocation(summary) {
|
|
137
|
+
return { ...toScopeInvocation(summary), ...toRequestDetails(summary) };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// The same shape from an express request, for the express error boundary.
|
|
141
|
+
function summarizeExpressRequest(req) {
|
|
142
|
+
try {
|
|
143
|
+
if (!req || typeof req !== 'object') return { source: 'http' };
|
|
144
|
+
return {
|
|
145
|
+
source: 'http',
|
|
146
|
+
method: req.method,
|
|
147
|
+
path: redactUrl(req.path ?? ''),
|
|
148
|
+
queryKeys: objectKeys(req.query),
|
|
149
|
+
headerNames: unique(objectKeys(req.headers).map((name) => name.toLowerCase())),
|
|
150
|
+
};
|
|
151
|
+
} catch {
|
|
152
|
+
return { source: 'http' };
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
module.exports = {
|
|
157
|
+
summarizeLambdaEvent,
|
|
158
|
+
summarizeMessageBody,
|
|
159
|
+
toScopeInvocation,
|
|
160
|
+
toRequestDetails,
|
|
161
|
+
toRequestInvocation,
|
|
162
|
+
summarizeExpressRequest,
|
|
163
|
+
};
|
package/modules/module.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
const { Delegate } = require('../core');
|
|
2
2
|
const _ = require('lodash');
|
|
3
|
-
const {
|
|
3
|
+
const { getLogger } = require('../logs');
|
|
4
4
|
const { ModuleConstants } = require('./ModuleConstants');
|
|
5
5
|
const {
|
|
6
6
|
createCredentialRepository,
|
|
@@ -32,6 +32,14 @@ class Module extends Delegate {
|
|
|
32
32
|
this.credential = entityObj?.credential;
|
|
33
33
|
this.definition = definition;
|
|
34
34
|
this.name = this.definition.moduleName;
|
|
35
|
+
this.logger = getLogger(`module.${this.name ?? 'unknown'}`).child(() => ({
|
|
36
|
+
entityId: this.entity?.id,
|
|
37
|
+
credentialId:
|
|
38
|
+
this.credential?.id ??
|
|
39
|
+
(typeof this.credential === 'string'
|
|
40
|
+
? this.credential
|
|
41
|
+
: undefined),
|
|
42
|
+
}));
|
|
35
43
|
this.modelName = this.definition.modelName;
|
|
36
44
|
this.apiClass = this.definition.API;
|
|
37
45
|
|
|
@@ -49,6 +57,7 @@ class Module extends Delegate {
|
|
|
49
57
|
const apiParams = {
|
|
50
58
|
...this.definition.env,
|
|
51
59
|
delegate: this,
|
|
60
|
+
logger: this.logger,
|
|
52
61
|
...(state ? { state } : {}),
|
|
53
62
|
...(this.credential?.data
|
|
54
63
|
? this.apiParamsFromCredential(this.credential.data)
|
|
@@ -100,7 +109,10 @@ class Module extends Delegate {
|
|
|
100
109
|
try {
|
|
101
110
|
if (await this.testAuthRequest(this.api)) validAuth = true;
|
|
102
111
|
} catch (e) {
|
|
103
|
-
|
|
112
|
+
this.logger.warn('testAuth failed', {
|
|
113
|
+
eventName: `${this.logger.name}.test_auth_failed`,
|
|
114
|
+
error: e,
|
|
115
|
+
});
|
|
104
116
|
}
|
|
105
117
|
return validAuth;
|
|
106
118
|
}
|
|
@@ -113,9 +125,9 @@ class Module extends Delegate {
|
|
|
113
125
|
const apiParams = this.apiParamsFromCredential(this.api);
|
|
114
126
|
|
|
115
127
|
if (!apiParams.refresh_token && this.api.isRefreshable) {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
);
|
|
128
|
+
this.logger.warn('No refresh_token in apiParams', {
|
|
129
|
+
eventName: `${this.logger.name}.refresh_token_missing`,
|
|
130
|
+
});
|
|
119
131
|
}
|
|
120
132
|
|
|
121
133
|
Object.assign(credentialDetails.details, apiParams);
|
|
@@ -133,10 +145,10 @@ class Module extends Delegate {
|
|
|
133
145
|
moduleName: this.name,
|
|
134
146
|
});
|
|
135
147
|
} catch (err) {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
);
|
|
148
|
+
this.logger.error('Failed to propagate CREDENTIAL_VALIDATED', {
|
|
149
|
+
eventName: `${this.logger.name}.credential_validated_propagation_failed`,
|
|
150
|
+
error: err,
|
|
151
|
+
});
|
|
140
152
|
}
|
|
141
153
|
}
|
|
142
154
|
}
|
|
@@ -177,12 +189,11 @@ class Module extends Delegate {
|
|
|
177
189
|
if (!this.credential.id) return;
|
|
178
190
|
|
|
179
191
|
if (diagnosticInfo) {
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
);
|
|
192
|
+
this.logger.warn('Credentials rejected', {
|
|
193
|
+
eventName: `${this.logger.name}.credentials_rejected`,
|
|
194
|
+
statusCode: diagnosticInfo.statusCode,
|
|
195
|
+
error: diagnosticInfo,
|
|
196
|
+
});
|
|
186
197
|
}
|
|
187
198
|
|
|
188
199
|
await this.credentialRepository.updateAuthenticationStatus(
|
|
@@ -216,16 +227,17 @@ class Module extends Delegate {
|
|
|
216
227
|
}),
|
|
217
228
|
});
|
|
218
229
|
} catch (err) {
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
);
|
|
230
|
+
this.logger.error('Failed to propagate CREDENTIAL_INVALIDATED', {
|
|
231
|
+
eventName: `${this.logger.name}.credential_invalidated_propagation_failed`,
|
|
232
|
+
error: err,
|
|
233
|
+
});
|
|
223
234
|
}
|
|
224
235
|
}
|
|
225
236
|
|
|
226
237
|
async deauthorize() {
|
|
227
238
|
//todo: Check if this is correct, we're instantiating a new api without params (credentials, tokens, etc...)
|
|
228
239
|
this.api = new this.apiClass();
|
|
240
|
+
this.api.logger = this.logger;
|
|
229
241
|
|
|
230
242
|
// Remove persisted credential (if any)
|
|
231
243
|
if (this.entity?.credential) {
|
|
@@ -234,6 +246,7 @@ class Module extends Delegate {
|
|
|
234
246
|
|
|
235
247
|
// Delete credential via repository
|
|
236
248
|
await this.credentialRepository.deleteCredentialById(credentialId);
|
|
249
|
+
this.credential = undefined;
|
|
237
250
|
|
|
238
251
|
// Unset credential reference on the Entity document
|
|
239
252
|
const entityId = this.entity.id;
|
|
@@ -136,13 +136,13 @@ class OAuth2Requester extends Requester {
|
|
|
136
136
|
this.refresh_token = newRefreshToken;
|
|
137
137
|
} else {
|
|
138
138
|
if (this.refresh_token) {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
);
|
|
139
|
+
this.logger.debug('No refresh_token in response, preserving existing', {
|
|
140
|
+
eventName: `${this.logger.name}.refresh_token_preserved`,
|
|
141
|
+
});
|
|
142
142
|
} else {
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
);
|
|
143
|
+
this.logger.debug('No refresh_token in response and none held', {
|
|
144
|
+
eventName: `${this.logger.name}.refresh_token_absent`,
|
|
145
|
+
});
|
|
146
146
|
}
|
|
147
147
|
}
|
|
148
148
|
const accessExpiresIn = get(params, 'expires_in', null);
|
|
@@ -265,7 +265,9 @@ class OAuth2Requester extends Requester {
|
|
|
265
265
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
266
266
|
},
|
|
267
267
|
};
|
|
268
|
-
|
|
268
|
+
this.logger.debug('Refreshing access token', {
|
|
269
|
+
eventName: `${this.logger.name}.token_refresh_request`,
|
|
270
|
+
});
|
|
269
271
|
const response = await this._post(options, false);
|
|
270
272
|
await this.setTokens(response);
|
|
271
273
|
return response;
|
|
@@ -323,12 +325,14 @@ class OAuth2Requester extends Requester {
|
|
|
323
325
|
}
|
|
324
326
|
|
|
325
327
|
try {
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
328
|
+
// Field names avoid the denied suffixes (token, secret), which
|
|
329
|
+
// would redact these booleans.
|
|
330
|
+
this.logger.debug('Starting token refresh', {
|
|
331
|
+
eventName: `${this.logger.name}.token_refresh_started`,
|
|
332
|
+
grantType: this.grant_type,
|
|
333
|
+
refreshTokenPresent: !!this.refresh_token,
|
|
334
|
+
clientIdPresent: !!this.client_id,
|
|
335
|
+
clientSecretPresent: !!this.client_secret,
|
|
332
336
|
tokenUri: this.tokenUri,
|
|
333
337
|
});
|
|
334
338
|
|
|
@@ -343,15 +347,17 @@ class OAuth2Requester extends Requester {
|
|
|
343
347
|
const tokenRes = await this.getTokenFromClientCredentials();
|
|
344
348
|
if (!tokenRes) return false;
|
|
345
349
|
}
|
|
346
|
-
|
|
350
|
+
this.logger.info('Token refresh succeeded', {
|
|
351
|
+
eventName: `${this.logger.name}.token_refreshed`,
|
|
352
|
+
});
|
|
347
353
|
return true;
|
|
348
354
|
} catch (error) {
|
|
349
355
|
const moduleName = this.delegate?.name ?? 'unknown module';
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
356
|
+
this.logger.debug('Token refresh failed', {
|
|
357
|
+
eventName: `${this.logger.name}.token_refresh_failed`,
|
|
358
|
+
statusCode:
|
|
359
|
+
error?.statusCode ?? error?.status ?? error?.response?.status,
|
|
360
|
+
reason: error?.message,
|
|
355
361
|
});
|
|
356
362
|
|
|
357
363
|
if (!this._isDefinitiveAuthRejection(error)) {
|
|
@@ -405,9 +411,9 @@ class OAuth2Requester extends Requester {
|
|
|
405
411
|
for (const delayMs of this.credentialReloadBackoffMs) {
|
|
406
412
|
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
407
413
|
if (await this._adoptNewerCredential()) {
|
|
408
|
-
|
|
409
|
-
'
|
|
410
|
-
{
|
|
414
|
+
this.logger.info(
|
|
415
|
+
'Adopted a newer credential after a refresh rejection',
|
|
416
|
+
{ eventName: `${this.logger.name}.credential_adopted` }
|
|
411
417
|
);
|
|
412
418
|
this.telemetry?.count?.('frigg.auth.refresh_race_recovered', 1, {
|
|
413
419
|
module: this._telemetryModuleLabel(),
|
|
@@ -4,8 +4,22 @@ const { Delegate } = require('../../core');
|
|
|
4
4
|
const { FetchError } = require('../../errors');
|
|
5
5
|
const { get } = require('../../assertions');
|
|
6
6
|
const { getTelemetry } = require('../../telemetry/telemetry-runtime');
|
|
7
|
+
const { getLogger } = require('../../logs');
|
|
8
|
+
const { redactUrl } = require('../../logs/redact');
|
|
9
|
+
const { toSanitizedSurrogate } = require('../../logs/serialize');
|
|
10
|
+
const { getLoggerScope } = require('../../logs/context');
|
|
7
11
|
|
|
8
12
|
const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
|
|
13
|
+
|
|
14
|
+
// A node-fetch error message holds the raw URL, and util.inspect prints the
|
|
15
|
+
// cause chain, so the FetchError keeps only a sanitized copy.
|
|
16
|
+
function sanitizedCause(err) {
|
|
17
|
+
const surrogate = toSanitizedSurrogate(err);
|
|
18
|
+
for (const key of ['errno', 'type']) {
|
|
19
|
+
if (typeof err?.[key] === 'string') surrogate[key] = err[key];
|
|
20
|
+
}
|
|
21
|
+
return surrogate;
|
|
22
|
+
}
|
|
9
23
|
const MAX_AUTH_RETRIES = 3;
|
|
10
24
|
|
|
11
25
|
// This context marks the async call chain that holds the refresh slot, and
|
|
@@ -64,6 +78,18 @@ class Requester extends Delegate {
|
|
|
64
78
|
// `this.telemetry` to attribute out-of-band requests — setup/OAuth calls
|
|
65
79
|
// made before an integration context exists aren't rolled up otherwise.
|
|
66
80
|
this.telemetry = (params && params.telemetry) || getTelemetry();
|
|
81
|
+
|
|
82
|
+
// Not `get(params, 'logger')`: it throws when the key is missing.
|
|
83
|
+
this._logger = params?.logger ?? null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Resolved per read, so a delegate set after construction names the logger.
|
|
87
|
+
get logger() {
|
|
88
|
+
return this._logger ?? getLogger(`module.${this._telemetryModuleLabel()}`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
set logger(logger) {
|
|
92
|
+
this._logger = logger ?? null;
|
|
67
93
|
}
|
|
68
94
|
|
|
69
95
|
/**
|
|
@@ -141,11 +167,14 @@ class Requester extends Delegate {
|
|
|
141
167
|
|
|
142
168
|
return telemetry.span('frigg.apimodule.request', async (span) => {
|
|
143
169
|
if (span && typeof span.setAttributes === 'function') {
|
|
170
|
+
const { requestId, messageId } = getLoggerScope();
|
|
144
171
|
span.setAttributes({
|
|
145
172
|
'frigg.module': module,
|
|
146
173
|
'http.request.method': method,
|
|
147
174
|
// Redacted (no query/userinfo) — never emit raw URLs.
|
|
148
175
|
'url.path': safeUrl,
|
|
176
|
+
...(requestId !== undefined && { requestId }),
|
|
177
|
+
...(messageId !== undefined && { messageId }),
|
|
149
178
|
});
|
|
150
179
|
}
|
|
151
180
|
// Redacted url (unbounded) rides the bus-only context for North Star
|
|
@@ -255,15 +284,12 @@ class Requester extends Delegate {
|
|
|
255
284
|
const fetchError = await FetchError.create({
|
|
256
285
|
resource: encodedUrl,
|
|
257
286
|
init: options,
|
|
258
|
-
|
|
259
|
-
? `Request timed out after ${timeoutMs}ms`
|
|
260
|
-
: e,
|
|
287
|
+
cause: sanitizedCause(e),
|
|
261
288
|
});
|
|
262
289
|
if (isTimeout) {
|
|
263
290
|
// Flag + machine-readable fields so callers can
|
|
264
291
|
// distinguish a timeout from a generic network error
|
|
265
|
-
// without parsing the message
|
|
266
|
-
// sanitizes outside of STAGE=dev).
|
|
292
|
+
// without parsing the message.
|
|
267
293
|
fetchError.isTimeout = true;
|
|
268
294
|
fetchError.timeoutMs = timeoutMs;
|
|
269
295
|
}
|
|
@@ -361,6 +387,7 @@ class Requester extends Delegate {
|
|
|
361
387
|
// the response body (response.text()) — timer must still be
|
|
362
388
|
// alive to catch a stalled body stream.
|
|
363
389
|
if (status >= 400) {
|
|
390
|
+
this._logRequestFailed(encodedUrl, options, status);
|
|
364
391
|
const fetchError = await FetchError.create({
|
|
365
392
|
resource: encodedUrl,
|
|
366
393
|
init: options,
|
|
@@ -387,12 +414,25 @@ class Requester extends Delegate {
|
|
|
387
414
|
// If the abort fired during body consumption, node-fetch emits
|
|
388
415
|
// the error as an AbortError on the body stream. Surface the
|
|
389
416
|
// same isTimeout flag callers use for header-phase timeouts.
|
|
390
|
-
|
|
417
|
+
const flagged = this._maybeFlagTimeoutDuringBodyRead(e, timeoutMs);
|
|
418
|
+
throw this._wrapFetchLibraryError(flagged, encodedUrl, options);
|
|
391
419
|
} finally {
|
|
392
420
|
clearRequestTimer();
|
|
393
421
|
}
|
|
394
422
|
}
|
|
395
423
|
|
|
424
|
+
_logRequestFailed(encodedUrl, options, status) {
|
|
425
|
+
const logger = this.logger;
|
|
426
|
+
if (!logger.isLevelEnabled('DEBUG')) return;
|
|
427
|
+
logger.debug('Request failed', {
|
|
428
|
+
eventName: `${logger.name}.request_failed`,
|
|
429
|
+
method: (options.method || 'GET').toUpperCase(),
|
|
430
|
+
url: redactUrl(encodedUrl),
|
|
431
|
+
statusCode: status,
|
|
432
|
+
headerNames: Object.keys(options.headers || {}),
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
|
|
396
436
|
async _invalidateAuth(encodedUrl, options, response) {
|
|
397
437
|
const fetchError = await FetchError.create({
|
|
398
438
|
resource: encodedUrl,
|
|
@@ -403,6 +443,29 @@ class Requester extends Delegate {
|
|
|
403
443
|
return fetchError;
|
|
404
444
|
}
|
|
405
445
|
|
|
446
|
+
// node-fetch names its own error class FetchError too, and its message
|
|
447
|
+
// holds the raw URL. Only our class passes through unwrapped.
|
|
448
|
+
_wrapFetchLibraryError(err, encodedUrl, options) {
|
|
449
|
+
if (!err || typeof err !== 'object' || err instanceof FetchError) {
|
|
450
|
+
return err;
|
|
451
|
+
}
|
|
452
|
+
const isFetchLibraryError =
|
|
453
|
+
err.name === 'FetchError' ||
|
|
454
|
+
err.name === 'AbortError' ||
|
|
455
|
+
typeof err.type === 'string';
|
|
456
|
+
if (!isFetchLibraryError) return err;
|
|
457
|
+
const wrapped = new FetchError({
|
|
458
|
+
resource: encodedUrl,
|
|
459
|
+
init: options,
|
|
460
|
+
cause: sanitizedCause(err),
|
|
461
|
+
});
|
|
462
|
+
if (err.isTimeout) {
|
|
463
|
+
wrapped.isTimeout = true;
|
|
464
|
+
wrapped.timeoutMs = err.timeoutMs;
|
|
465
|
+
}
|
|
466
|
+
return wrapped;
|
|
467
|
+
}
|
|
468
|
+
|
|
406
469
|
_maybeFlagTimeoutDuringBodyRead(err, timeoutMs) {
|
|
407
470
|
if (!err || typeof err !== 'object') return err;
|
|
408
471
|
if (err.isTimeout) return err;
|
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.
|
|
4
|
+
"version": "2.0.0-next.113",
|
|
5
5
|
"dependencies": {
|
|
6
6
|
"@aws-sdk/client-apigatewaymanagementapi": "^3.588.0",
|
|
7
7
|
"@aws-sdk/client-kms": "^3.588.0",
|
|
@@ -22,7 +22,6 @@
|
|
|
22
22
|
"body-parser": "^1.20.5",
|
|
23
23
|
"bson": "^4.7.2",
|
|
24
24
|
"chalk": "^4.1.2",
|
|
25
|
-
"common-tags": "^1.8.2",
|
|
26
25
|
"cors": "^2.8.5",
|
|
27
26
|
"dotenv": "^16.4.7",
|
|
28
27
|
"express": "^4.22.2",
|
|
@@ -48,9 +47,9 @@
|
|
|
48
47
|
}
|
|
49
48
|
},
|
|
50
49
|
"devDependencies": {
|
|
51
|
-
"@friggframework/eslint-config": "2.0.0-next.
|
|
52
|
-
"@friggframework/prettier-config": "2.0.0-next.
|
|
53
|
-
"@friggframework/test": "2.0.0-next.
|
|
50
|
+
"@friggframework/eslint-config": "2.0.0-next.113",
|
|
51
|
+
"@friggframework/prettier-config": "2.0.0-next.113",
|
|
52
|
+
"@friggframework/test": "2.0.0-next.113",
|
|
54
53
|
"@prisma/client": "^6.19.3",
|
|
55
54
|
"@types/lodash": "4.17.15",
|
|
56
55
|
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
|
@@ -90,5 +89,5 @@
|
|
|
90
89
|
"publishConfig": {
|
|
91
90
|
"access": "public"
|
|
92
91
|
},
|
|
93
|
-
"gitHead": "
|
|
92
|
+
"gitHead": "783d9f59b16663376c313b1bb4874d5efcb54a5a"
|
|
94
93
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
const INTEGRATION_QUEUE_MAX_RECEIVE_COUNT = 3;
|
|
2
|
+
const QUEUE_MAX_RECEIVE_COUNT_ENV = 'FRIGG_QUEUE_MAX_RECEIVE_COUNT';
|
|
3
|
+
|
|
4
|
+
const toPositiveInteger = (value) => {
|
|
5
|
+
const number = Number(value);
|
|
6
|
+
return Number.isInteger(number) && number > 0 ? number : undefined;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {Object} QueueDelivery
|
|
11
|
+
* @property {number|undefined} receiveCount SQS ApproximateReceiveCount of this delivery.
|
|
12
|
+
* @property {number|undefined} maxReceiveCount Receives allowed before the queue dead-letters the message.
|
|
13
|
+
* @property {boolean} isLastAttempt True only when both counts are known and receiveCount >= maxReceiveCount.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {Object} record SQS event record
|
|
18
|
+
* @param {Object} [env=process.env]
|
|
19
|
+
* @returns {QueueDelivery}
|
|
20
|
+
*/
|
|
21
|
+
const readQueueDelivery = (record, env = process.env) => {
|
|
22
|
+
const receiveCount = toPositiveInteger(
|
|
23
|
+
record?.attributes?.ApproximateReceiveCount
|
|
24
|
+
);
|
|
25
|
+
const maxReceiveCount = toPositiveInteger(env[QUEUE_MAX_RECEIVE_COUNT_ENV]);
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
receiveCount,
|
|
29
|
+
maxReceiveCount,
|
|
30
|
+
isLastAttempt:
|
|
31
|
+
receiveCount !== undefined &&
|
|
32
|
+
maxReceiveCount !== undefined &&
|
|
33
|
+
receiveCount >= maxReceiveCount,
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
module.exports = {
|
|
38
|
+
INTEGRATION_QUEUE_MAX_RECEIVE_COUNT,
|
|
39
|
+
QUEUE_MAX_RECEIVE_COUNT_ENV,
|
|
40
|
+
readQueueDelivery,
|
|
41
|
+
};
|