@friggframework/core 2.0.0-next.112 → 2.0.0-next.114
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 +58 -64
- 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
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
// ADR-048 §14 redaction vectors. Each vector is one log call that carries
|
|
2
|
+
// secrets; the suite asserts that no 8-char window of them reaches a record.
|
|
3
|
+
const { SECRETS: S, fakeToken } = require('./secrets');
|
|
4
|
+
const { httpApiV2Event, restV1Event, sqsEvent } = require('./events');
|
|
5
|
+
const { summarizeLambdaEvent } = require('../summarize-event');
|
|
6
|
+
|
|
7
|
+
const secretUrl = `https://api.example.com/v1/items?api_key=${S.apiKeyQuery}`;
|
|
8
|
+
const SIGNATURE_VALUE = fakeToken('signatureValue', 27);
|
|
9
|
+
|
|
10
|
+
// Built by concatenation so push protection does not match the prefixes.
|
|
11
|
+
const TOKEN_TAIL = fakeToken('prefixTail', 25);
|
|
12
|
+
const PREFIXED_TOKENS = [
|
|
13
|
+
'sk_' + 'live_' + TOKEN_TAIL,
|
|
14
|
+
'whsec' + '_' + TOKEN_TAIL,
|
|
15
|
+
'xox' + 'b-' + TOKEN_TAIL,
|
|
16
|
+
'gh' + 'p_' + TOKEN_TAIL,
|
|
17
|
+
'AK' + 'IA' + 'ZQPR3FIXTAIL8HN2',
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
function errorWithMessage(message, name = 'Error') {
|
|
21
|
+
const error = new Error(message);
|
|
22
|
+
error.name = name;
|
|
23
|
+
return error;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function nodeFetchError() {
|
|
27
|
+
const { FetchError } = require('node-fetch');
|
|
28
|
+
return new FetchError(
|
|
29
|
+
`request to ${secretUrl} failed, reason: connect ECONNREFUSED Authorization: Bearer ${S.bearer}`,
|
|
30
|
+
'system',
|
|
31
|
+
{ code: 'ECONNREFUSED' }
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function causeChain() {
|
|
36
|
+
const deepest = errorWithMessage(`token refresh failed: refresh_token=${S.refreshToken}`);
|
|
37
|
+
const third = new Error('third', { cause: deepest });
|
|
38
|
+
const second = new Error('second', { cause: third });
|
|
39
|
+
return new Error('top', { cause: second });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function cyclicObject() {
|
|
43
|
+
const node = { name: 'node', access_token: S.accessToken };
|
|
44
|
+
node.self = node;
|
|
45
|
+
node.list = [node, { password: S.password }];
|
|
46
|
+
return node;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function aggregateError() {
|
|
50
|
+
return new AggregateError(
|
|
51
|
+
[
|
|
52
|
+
errorWithMessage(`GET ${secretUrl} 401`),
|
|
53
|
+
errorWithMessage(`Authorization: Basic ${Buffer.from(`u:${S.password}`).toString('base64')}`),
|
|
54
|
+
],
|
|
55
|
+
'batch failed'
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function prismaValidationError() {
|
|
60
|
+
const error = errorWithMessage(
|
|
61
|
+
[
|
|
62
|
+
'Invalid `prisma.credential.create()` invocation:',
|
|
63
|
+
'',
|
|
64
|
+
`{ data: { access_token: "${S.accessToken}", refresh_token: "${S.refreshToken}" } }`,
|
|
65
|
+
'',
|
|
66
|
+
'Argument `userId` is missing.',
|
|
67
|
+
].join('\n'),
|
|
68
|
+
'PrismaClientValidationError'
|
|
69
|
+
);
|
|
70
|
+
error.clientVersion = '6.19.3';
|
|
71
|
+
return error;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function vectors() {
|
|
75
|
+
return [
|
|
76
|
+
{
|
|
77
|
+
name: 'HTTP API v2 event through summarizeLambdaEvent',
|
|
78
|
+
level: 'info',
|
|
79
|
+
fields: () => ({ invocation: summarizeLambdaEvent(httpApiV2Event()) }),
|
|
80
|
+
secrets: [S.friggApiKey, S.cookie, S.oauthCode, S.bearer, S.apiKeyQuery, S.codeVerifier, S.clientSecret],
|
|
81
|
+
expectSanitized: (record) => {
|
|
82
|
+
expect(record.invocation.headerNames).toEqual(
|
|
83
|
+
expect.arrayContaining(['x-frigg-api-key', 'cookie', 'authorization'])
|
|
84
|
+
);
|
|
85
|
+
expect(record.invocation.queryKeys).toEqual(expect.arrayContaining(['code', 'api_key']));
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
name: 'HTTP API v2 event as a raw field',
|
|
90
|
+
level: 'debug',
|
|
91
|
+
fields: () => ({ event: httpApiV2Event() }),
|
|
92
|
+
secrets: [S.friggApiKey, S.cookie, S.oauthCode, S.bearer, S.apiKeyQuery, S.codeVerifier, S.clientSecret],
|
|
93
|
+
expectSanitized: (record) => {
|
|
94
|
+
expect(record.event.headers).toEqual(
|
|
95
|
+
expect.arrayContaining(['x-frigg-api-key', 'cookie'])
|
|
96
|
+
);
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
name: 'REST v1 event as a raw field',
|
|
101
|
+
level: 'debug',
|
|
102
|
+
fields: () => ({ event: restV1Event() }),
|
|
103
|
+
secrets: [S.friggApiKey, S.cookie, S.password, S.accessToken, S.refreshToken],
|
|
104
|
+
expectSanitized: (record) => {
|
|
105
|
+
expect(record.event.httpMethod).toBe('POST');
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
name: 'SQS event as a raw field',
|
|
110
|
+
level: 'debug',
|
|
111
|
+
fields: () => ({ event: sqsEvent() }),
|
|
112
|
+
secrets: [S.accessToken, S.clientSecret, S.bearer],
|
|
113
|
+
expectSanitized: (record) => {
|
|
114
|
+
expect(record.event.Records[0].messageId).toBe('msg-1');
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
name: 'node-fetch FetchError',
|
|
119
|
+
level: 'error',
|
|
120
|
+
fields: () => ({ error: nodeFetchError() }),
|
|
121
|
+
secrets: [S.apiKeyQuery, S.bearer],
|
|
122
|
+
expectSanitized: (record) => {
|
|
123
|
+
expect(record.error.message).toContain('api_key=REDACTED');
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
name: 'Error as the message',
|
|
128
|
+
level: 'error',
|
|
129
|
+
message: () => errorWithMessage(`GET ${secretUrl} Authorization: Bearer ${S.bearer}`),
|
|
130
|
+
secrets: [S.apiKeyQuery, S.bearer],
|
|
131
|
+
expectSanitized: (record) => {
|
|
132
|
+
expect(record.message).toContain('api_key=REDACTED');
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
name: 'cause chain with a secret at depth 3',
|
|
137
|
+
level: 'error',
|
|
138
|
+
fields: () => ({ error: causeChain() }),
|
|
139
|
+
secrets: [S.refreshToken],
|
|
140
|
+
expectSanitized: (record) => {
|
|
141
|
+
expect(record.error.cause.cause.message).toBe('third');
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
name: 'cyclic object',
|
|
146
|
+
level: 'warn',
|
|
147
|
+
fields: () => ({ node: cyclicObject() }),
|
|
148
|
+
secrets: [S.accessToken, S.password],
|
|
149
|
+
expectSanitized: (record) => {
|
|
150
|
+
expect(JSON.stringify(record)).toContain('[Circular]');
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
{
|
|
154
|
+
name: 'AggregateError',
|
|
155
|
+
level: 'error',
|
|
156
|
+
fields: () => ({ error: aggregateError() }),
|
|
157
|
+
secrets: [S.apiKeyQuery, S.password],
|
|
158
|
+
expectSanitized: (record) => {
|
|
159
|
+
expect(record.error.type).toBe('AggregateError');
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
name: 'Prisma validation error',
|
|
164
|
+
level: 'error',
|
|
165
|
+
fields: () => ({ error: prismaValidationError() }),
|
|
166
|
+
secrets: [S.accessToken, S.refreshToken],
|
|
167
|
+
expectSanitized: (record) => {
|
|
168
|
+
expect(record.error.message).toContain('Argument `userId` is missing.');
|
|
169
|
+
},
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
name: 'connection strings',
|
|
173
|
+
level: 'warn',
|
|
174
|
+
message: () =>
|
|
175
|
+
`connect failed postgresql://frigg:${S.dbPassword}@db.internal:5432/app and mongodb+srv://frigg:${S.dbPassword}@cluster0.example.net/app`,
|
|
176
|
+
secrets: [S.dbPassword],
|
|
177
|
+
expectSanitized: (record) => {
|
|
178
|
+
expect(record.message).toContain('db.internal');
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
name: 'JWT',
|
|
183
|
+
level: 'info',
|
|
184
|
+
fields: () => ({ note: `id token ${S.jwt} received` }),
|
|
185
|
+
secrets: [S.jwt],
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
name: 'Bearer and Basic credentials',
|
|
189
|
+
level: 'info',
|
|
190
|
+
fields: () => ({
|
|
191
|
+
note: `Authorization: Bearer ${S.bearer}; Authorization: Basic ${Buffer.from(`u:${S.password}`).toString('base64')}`,
|
|
192
|
+
}),
|
|
193
|
+
secrets: [S.bearer, S.password],
|
|
194
|
+
},
|
|
195
|
+
{
|
|
196
|
+
name: 'base64 run',
|
|
197
|
+
level: 'info',
|
|
198
|
+
fields: () => ({ note: `blob ${S.base64Run} end` }),
|
|
199
|
+
secrets: [S.base64Run],
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
name: 'provider token prefixes',
|
|
203
|
+
level: 'warn',
|
|
204
|
+
message: () => `provider rejected ${PREFIXED_TOKENS.join(' and ')}`,
|
|
205
|
+
fields: () => ({ note: PREFIXED_TOKENS.join(',') }),
|
|
206
|
+
secrets: PREFIXED_TOKENS,
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
name: '40-hex token',
|
|
210
|
+
level: 'error',
|
|
211
|
+
message: () => `lookup failed for key ${S.hexToken}`,
|
|
212
|
+
secrets: [S.hexToken],
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
name: 'client_secret, signature and code pairs',
|
|
216
|
+
level: 'info',
|
|
217
|
+
fields: () => ({
|
|
218
|
+
note: `client_secret=${S.clientSecret} signature=${SIGNATURE_VALUE} code=${S.oauthCode}`,
|
|
219
|
+
}),
|
|
220
|
+
secrets: [S.clientSecret, SIGNATURE_VALUE, S.oauthCode],
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
name: 'JSON string',
|
|
224
|
+
level: 'info',
|
|
225
|
+
fields: () => ({ note: JSON.stringify({ token: S.accessToken, id: 7 }) }),
|
|
226
|
+
secrets: [S.accessToken],
|
|
227
|
+
},
|
|
228
|
+
{
|
|
229
|
+
name: 'k=v& string',
|
|
230
|
+
level: 'info',
|
|
231
|
+
fields: () => ({ note: `user=alice&password=${S.password}&next=/home` }),
|
|
232
|
+
secrets: [S.password],
|
|
233
|
+
},
|
|
234
|
+
{
|
|
235
|
+
name: 'URLSearchParams body',
|
|
236
|
+
level: 'debug',
|
|
237
|
+
fields: () => ({
|
|
238
|
+
body: new URLSearchParams({
|
|
239
|
+
grant_type: 'authorization_code',
|
|
240
|
+
code: S.oauthCode,
|
|
241
|
+
code_verifier: S.codeVerifier,
|
|
242
|
+
client_secret: S.clientSecret,
|
|
243
|
+
}),
|
|
244
|
+
}),
|
|
245
|
+
secrets: [S.oauthCode, S.codeVerifier, S.clientSecret],
|
|
246
|
+
expectSanitized: (record) => {
|
|
247
|
+
expect(JSON.stringify(record.body)).toContain('grant_type');
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
{
|
|
251
|
+
name: 'Headers, URL and Buffer',
|
|
252
|
+
level: 'debug',
|
|
253
|
+
fields: () => ({
|
|
254
|
+
headers: new Headers({ authorization: `Bearer ${S.bearer}`, 'x-frigg-api-key': S.friggApiKey }),
|
|
255
|
+
url: new URL(`https://user:${S.password}@api.example.com/x?access_token=${S.accessToken}`),
|
|
256
|
+
raw: Buffer.from(`secret=${S.clientSecret}`),
|
|
257
|
+
}),
|
|
258
|
+
secrets: [S.bearer, S.friggApiKey, S.password, S.accessToken, S.clientSecret],
|
|
259
|
+
expectSanitized: (record) => {
|
|
260
|
+
expect(JSON.stringify(record.url)).toContain('api.example.com');
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
name: 'OAuth callback params',
|
|
265
|
+
level: 'debug',
|
|
266
|
+
fields: () => ({ params: { code: S.oauthCode, state: 'abc123', code_verifier: S.codeVerifier } }),
|
|
267
|
+
secrets: [S.oauthCode, S.codeVerifier],
|
|
268
|
+
expectSanitized: (record) => {
|
|
269
|
+
expect(Object.keys(record.params)).toEqual(
|
|
270
|
+
expect.arrayContaining(['code', 'state'])
|
|
271
|
+
);
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
name: 'hashword',
|
|
276
|
+
level: 'info',
|
|
277
|
+
fields: () => ({ user: { username: 'alice', hashword: S.hashword } }),
|
|
278
|
+
secrets: [S.hashword],
|
|
279
|
+
expectSanitized: (record) => {
|
|
280
|
+
expect(record.user.username).toBe('alice');
|
|
281
|
+
},
|
|
282
|
+
},
|
|
283
|
+
{
|
|
284
|
+
name: 'denied keys as bindings and top-level fields',
|
|
285
|
+
level: 'info',
|
|
286
|
+
bindings: () => ({ apiKey: S.friggApiKey }),
|
|
287
|
+
fields: () => ({ access_token: S.accessToken, id_token: S.idToken, cookies: [S.cookie] }),
|
|
288
|
+
secrets: [S.friggApiKey, S.accessToken, S.idToken, S.cookie],
|
|
289
|
+
},
|
|
290
|
+
];
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// A real Requester call that fails with 401. The URL carries a query key and
|
|
294
|
+
// the request an Authorization header.
|
|
295
|
+
async function requesterFetchError() {
|
|
296
|
+
const { Requester } = require('../../modules/requester/requester');
|
|
297
|
+
class TestRequester extends Requester {
|
|
298
|
+
async addAuthHeaders(headers) {
|
|
299
|
+
return { ...headers, Authorization: `Bearer ${S.bearer}` };
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
const response = {
|
|
303
|
+
status: 401,
|
|
304
|
+
ok: false,
|
|
305
|
+
bodyUsed: false,
|
|
306
|
+
headers: {
|
|
307
|
+
get: () => 'application/json',
|
|
308
|
+
raw: () => ({ 'content-type': ['application/json'] }),
|
|
309
|
+
[Symbol.iterator]: function* () {
|
|
310
|
+
yield ['content-type', 'application/json'];
|
|
311
|
+
},
|
|
312
|
+
},
|
|
313
|
+
json: async () => ({ access_token: S.accessToken }),
|
|
314
|
+
text: async () => `{"access_token":"${S.accessToken}"}`,
|
|
315
|
+
};
|
|
316
|
+
const requester = new TestRequester({
|
|
317
|
+
backOff: [],
|
|
318
|
+
requestTimeoutMs: 0,
|
|
319
|
+
fetch: async () => response,
|
|
320
|
+
});
|
|
321
|
+
return requester._get({ url: secretUrl }).then(
|
|
322
|
+
() => {
|
|
323
|
+
throw new Error('expected the request to fail');
|
|
324
|
+
},
|
|
325
|
+
(error) => error
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
module.exports = { vectors, requesterFetchError, secretUrl };
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
const { createMemorySink } = require('../sinks');
|
|
2
|
+
const { resetLoggerForTests } = require('../logger-runtime');
|
|
3
|
+
|
|
4
|
+
function applyEnv(vars) {
|
|
5
|
+
const saved = {};
|
|
6
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
7
|
+
saved[key] = Object.prototype.hasOwnProperty.call(process.env, key)
|
|
8
|
+
? process.env[key]
|
|
9
|
+
: undefined;
|
|
10
|
+
if (value === undefined) delete process.env[key];
|
|
11
|
+
else process.env[key] = value;
|
|
12
|
+
}
|
|
13
|
+
return () => {
|
|
14
|
+
for (const [key, value] of Object.entries(saved)) {
|
|
15
|
+
if (value === undefined) delete process.env[key];
|
|
16
|
+
else process.env[key] = value;
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Sets env keys (undefined deletes), resets the logger to read them, and
|
|
22
|
+
// passes a fresh memory sink to fn. Restores env and the test logger after.
|
|
23
|
+
function withEnv(vars, fn) {
|
|
24
|
+
const restore = applyEnv(vars);
|
|
25
|
+
const sink = createMemorySink({ install: false });
|
|
26
|
+
resetLoggerForTests({ sinks: [sink] });
|
|
27
|
+
const done = () => {
|
|
28
|
+
restore();
|
|
29
|
+
resetLoggerForTests({ level: 'TRACE', sinks: [createMemorySink({ install: false })] });
|
|
30
|
+
};
|
|
31
|
+
let result;
|
|
32
|
+
try {
|
|
33
|
+
result = fn(sink);
|
|
34
|
+
} catch (error) {
|
|
35
|
+
done();
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
if (result && typeof result.then === 'function') {
|
|
39
|
+
return result.finally(done);
|
|
40
|
+
}
|
|
41
|
+
done();
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
module.exports = { withEnv, applyEnv };
|
package/logs/context.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
const { AsyncLocalStorage } = require('node:async_hooks');
|
|
2
|
+
|
|
3
|
+
const LOGGER_SCOPE_KEY = 'log';
|
|
4
|
+
const INTEGRATION_KEYS = ['integrationId', 'integrationType', 'userId', 'version'];
|
|
5
|
+
|
|
6
|
+
// Shared across jest module registries and nested core copies.
|
|
7
|
+
const STORE_KEY = Symbol.for('@friggframework/core.logs.context');
|
|
8
|
+
|
|
9
|
+
function als() {
|
|
10
|
+
if (!globalThis[STORE_KEY]) {
|
|
11
|
+
globalThis[STORE_KEY] = new AsyncLocalStorage();
|
|
12
|
+
}
|
|
13
|
+
return globalThis[STORE_KEY];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function isPlainObject(value) {
|
|
17
|
+
if (value === null || typeof value !== 'object') return false;
|
|
18
|
+
const proto = Object.getPrototypeOf(value);
|
|
19
|
+
return proto === Object.prototype || proto === null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function freezeCopy(value) {
|
|
23
|
+
if (Array.isArray(value)) {
|
|
24
|
+
return Object.freeze(value.map(freezeCopy));
|
|
25
|
+
}
|
|
26
|
+
if (isPlainObject(value)) {
|
|
27
|
+
const copy = {};
|
|
28
|
+
for (const key of Object.keys(value)) {
|
|
29
|
+
copy[key] = freezeCopy(value[key]);
|
|
30
|
+
}
|
|
31
|
+
return Object.freeze(copy);
|
|
32
|
+
}
|
|
33
|
+
return value;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function assignDefined(target, source) {
|
|
37
|
+
for (const key of Object.keys(source)) {
|
|
38
|
+
if (source[key] !== undefined) target[key] = source[key];
|
|
39
|
+
}
|
|
40
|
+
return target;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function mergeStores(outer, inner) {
|
|
44
|
+
const merged = { ...(outer || {}) };
|
|
45
|
+
if (!inner || typeof inner !== 'object') return freezeCopy(merged);
|
|
46
|
+
for (const key of Object.keys(inner)) {
|
|
47
|
+
const value = inner[key];
|
|
48
|
+
if (value === undefined) continue;
|
|
49
|
+
if (key === LOGGER_SCOPE_KEY && isPlainObject(value)) {
|
|
50
|
+
merged[key] = assignDefined({ ...(merged[key] || {}) }, value);
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
merged[key] = value;
|
|
54
|
+
}
|
|
55
|
+
return freezeCopy(merged);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function runInContext(context, fn) {
|
|
59
|
+
const storage = als();
|
|
60
|
+
return storage.run(mergeStores(storage.getStore(), context), fn);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function getContext() {
|
|
64
|
+
return als().getStore() ?? null;
|
|
65
|
+
}
|
|
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
|
+
|
|
71
|
+
function getLoggerScope() {
|
|
72
|
+
const store = getContext();
|
|
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
|
+
// A set (hydrated) id beats a logger-only one, e.g. from a URL or a
|
|
81
|
+
// message body; a null id leaves the logger-only value in place.
|
|
82
|
+
const loggerKeys = store[LOGGER_SCOPE_KEY];
|
|
83
|
+
if (isPlainObject(loggerKeys)) {
|
|
84
|
+
for (const [key, value] of Object.entries(loggerKeys)) {
|
|
85
|
+
if (scope[key] === undefined || scope[key] === null) {
|
|
86
|
+
scope[key] = value;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
scope = Object.freeze(scope);
|
|
91
|
+
scopeByStore.set(store, scope);
|
|
92
|
+
}
|
|
93
|
+
return scope;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function hasOnlyLoggerKeys(store) {
|
|
97
|
+
if (!store || typeof store !== 'object') return false;
|
|
98
|
+
const keys = Object.keys(store);
|
|
99
|
+
return keys.length > 0 && keys.every((key) => key === LOGGER_SCOPE_KEY);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
module.exports = {
|
|
103
|
+
LOGGER_SCOPE_KEY,
|
|
104
|
+
runInContext,
|
|
105
|
+
getContext,
|
|
106
|
+
getLoggerScope,
|
|
107
|
+
hasOnlyLoggerKeys,
|
|
108
|
+
isPlainObject,
|
|
109
|
+
};
|
|
@@ -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,46 @@
|
|
|
1
|
+
const util = require('node:util');
|
|
2
|
+
const { getLogger } = require('./logger');
|
|
3
|
+
const { getContext } = require('./context');
|
|
4
|
+
const {
|
|
5
|
+
summarizeLambdaEvent,
|
|
6
|
+
toRequestInvocation,
|
|
7
|
+
} = require('./summarize-event');
|
|
8
|
+
|
|
9
|
+
// Deprecated. Kept for one major version; use getLogger or this.logger.
|
|
10
|
+
const LEGACY_LOGGER_NAME = 'frigg.legacy';
|
|
11
|
+
const FORMAT_DIRECTIVE = /%[sdifjoOc%]/;
|
|
12
|
+
|
|
13
|
+
const legacy = () => getLogger(LEGACY_LOGGER_NAME);
|
|
14
|
+
|
|
15
|
+
function debug(...messages) {
|
|
16
|
+
if (!messages.length) return;
|
|
17
|
+
const log = legacy();
|
|
18
|
+
if (!log.isLevelEnabled('DEBUG')) return;
|
|
19
|
+
const index = messages.findIndex((m) => typeof m === 'string');
|
|
20
|
+
if (index === 0 && FORMAT_DIRECTIVE.test(messages[0])) {
|
|
21
|
+
log.debug(util.format(...messages));
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
const message = index === -1 ? '' : messages[index];
|
|
25
|
+
const args = messages.filter((_, i) => i !== index);
|
|
26
|
+
log.debug(message, args.length ? { args } : undefined);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function initDebugLog(...initMessages) {
|
|
30
|
+
if (getContext()?.log) return;
|
|
31
|
+
const log = legacy();
|
|
32
|
+
if (!log.isLevelEnabled('DEBUG')) return;
|
|
33
|
+
const event = initMessages.find((m) => m && typeof m === 'object');
|
|
34
|
+
log.debug('Debug log initialized', {
|
|
35
|
+
invocation: toRequestInvocation(summarizeLambdaEvent(event)),
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function flushDebugLog(error) {
|
|
40
|
+
legacy().error('Unhandled error', {
|
|
41
|
+
eventName: 'frigg.legacy.error',
|
|
42
|
+
error: error || new Error('flushDebugLog called with empty error'),
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
module.exports = { debug, initDebugLog, flushDebugLog };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const { addDeniedKeys } = require('./redact');
|
|
2
|
+
const { getLogger } = require('./logger');
|
|
3
|
+
|
|
4
|
+
const warnedKeys = new Set();
|
|
5
|
+
|
|
6
|
+
// addDeniedKeys plus one WARN per record-contract key it had to skip, so a
|
|
7
|
+
// credential field named like a record field is visible wherever it is set.
|
|
8
|
+
function registerDeniedKeys(keys) {
|
|
9
|
+
const ignored = addDeniedKeys(keys);
|
|
10
|
+
for (const key of ignored) {
|
|
11
|
+
if (warnedKeys.has(key)) continue;
|
|
12
|
+
warnedKeys.add(key);
|
|
13
|
+
getLogger('frigg.logger').warn(
|
|
14
|
+
'Credential field name is a record field; it stays visible in logs',
|
|
15
|
+
{ eventName: 'frigg.logger.denied_key_ignored', key }
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
return ignored;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
module.exports = { registerDeniedKeys };
|
package/logs/index.js
CHANGED
|
@@ -1,7 +1,18 @@
|
|
|
1
|
-
const {
|
|
1
|
+
const { getLogger } = require('./logger');
|
|
2
|
+
const { createMemorySink } = require('./sinks');
|
|
3
|
+
const { resetLoggerForTests } = require('./logger-runtime');
|
|
4
|
+
const { serializeError, toSanitizedSurrogate } = require('./serialize');
|
|
5
|
+
const { redactValue } = require('./redact');
|
|
6
|
+
const { debug, initDebugLog, flushDebugLog } = require('./debug-shims');
|
|
2
7
|
|
|
3
8
|
module.exports = {
|
|
9
|
+
getLogger,
|
|
10
|
+
createMemorySink,
|
|
11
|
+
resetLoggerForTests,
|
|
12
|
+
serializeError,
|
|
13
|
+
redactValue,
|
|
14
|
+
toSanitizedSurrogate,
|
|
4
15
|
debug,
|
|
5
16
|
initDebugLog,
|
|
6
|
-
flushDebugLog
|
|
7
|
-
}
|
|
17
|
+
flushDebugLog,
|
|
18
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const { createMemorySink } = require('./sinks');
|
|
2
|
+
const { resetLoggerForTests, takeViolationsForTests } = require('./logger-runtime');
|
|
3
|
+
const { toContainNoSecretWindow } = require('./__fixtures__/matchers');
|
|
4
|
+
|
|
5
|
+
expect.extend({ toContainNoSecretWindow });
|
|
6
|
+
|
|
7
|
+
const resetToMemory = () =>
|
|
8
|
+
resetLoggerForTests({
|
|
9
|
+
level: 'TRACE',
|
|
10
|
+
sinks: [createMemorySink({ install: false })],
|
|
11
|
+
trackViolations: true,
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
// Also at load: records written at require time must not reach fd 1.
|
|
15
|
+
resetToMemory();
|
|
16
|
+
|
|
17
|
+
beforeEach(resetToMemory);
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
const violations = takeViolationsForTests();
|
|
21
|
+
if (violations.length) {
|
|
22
|
+
const list = violations.map((v) => `${v.level} ${v.logger}: ${v.message}`).join('\n');
|
|
23
|
+
throw new Error(`frigg.* records at WARN or above need an eventName:\n${list}`);
|
|
24
|
+
}
|
|
25
|
+
});
|