@friggframework/core 2.0.0-next.111 → 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 (74) hide show
  1. package/.eslintrc.json +18 -1
  2. package/CLAUDE.md +101 -13
  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/documentdb-encryption-service.js +6 -2
  12. package/database/documentdb-utils.js +2 -1
  13. package/database/encryption/README.md +25 -1
  14. package/database/encryption/encryption-schema-registry.js +3 -0
  15. package/database/encryption/integration-mapping-encryption.js +97 -0
  16. package/database/encryption/logger.js +5 -2
  17. package/database/encryption/prisma-encryption-extension.js +20 -9
  18. package/errors/fetch-error.js +48 -65
  19. package/handlers/app-definition-loader.js +38 -2
  20. package/handlers/app-handler-helpers.js +18 -4
  21. package/handlers/backend-utils.js +10 -13
  22. package/handlers/database-migration-handler.js +10 -1
  23. package/handlers/integration-event-dispatcher.js +2 -2
  24. package/handlers/routers/integration-webhook-routers.js +13 -6
  25. package/handlers/routers/websocket.js +27 -7
  26. package/handlers/workers/db-migration.js +12 -1
  27. package/handlers/workers/dlq-processor.js +64 -38
  28. package/index.js +19 -1
  29. package/integrations/integration-base.js +56 -38
  30. package/integrations/integration-router.js +21 -20
  31. package/integrations/repositories/integration-mapping-query-pipeline.js +152 -0
  32. package/integrations/repositories/integration-mapping-query.js +193 -0
  33. package/integrations/repositories/integration-mapping-repository-documentdb.js +53 -0
  34. package/integrations/repositories/integration-mapping-repository-interface.js +48 -0
  35. package/integrations/repositories/integration-mapping-repository-mongo.js +72 -0
  36. package/integrations/repositories/integration-mapping-repository-postgres.js +118 -0
  37. package/integrations/use-cases/delete-integration-for-user.js +24 -13
  38. package/jest.config.js +2 -0
  39. package/logs/.eslintrc.json +26 -1
  40. package/logs/__fixtures__/events.js +82 -0
  41. package/logs/__fixtures__/matchers.js +45 -0
  42. package/logs/__fixtures__/secrets.js +59 -0
  43. package/logs/__fixtures__/vectors.js +329 -0
  44. package/logs/__fixtures__/with-env.js +45 -0
  45. package/logs/context.js +109 -0
  46. package/logs/contract-keys.js +67 -0
  47. package/logs/debug-shims.js +46 -0
  48. package/logs/denied-keys.js +21 -0
  49. package/logs/index.js +14 -3
  50. package/logs/jest-logger-setup.js +25 -0
  51. package/logs/levels.js +71 -0
  52. package/logs/logger-runtime.js +137 -0
  53. package/logs/logger.js +134 -42
  54. package/logs/record.js +276 -0
  55. package/logs/redact.js +328 -0
  56. package/logs/serialize.js +329 -0
  57. package/logs/sinks.js +93 -0
  58. package/logs/summarize-event.js +163 -0
  59. package/modules/module.js +32 -19
  60. package/modules/requester/oauth-2.js +28 -22
  61. package/modules/requester/requester.js +69 -6
  62. package/package.json +5 -6
  63. package/queues/queue-delivery.js +41 -0
  64. package/queues/queuer-util.js +1 -15
  65. package/telemetry/bind-telemetry-context.js +4 -0
  66. package/telemetry/instrument-handler.js +19 -2
  67. package/telemetry/no-op-telemetry.js +4 -0
  68. package/telemetry/otel-telemetry.js +25 -6
  69. package/telemetry/telemetry-context.js +19 -8
  70. package/telemetry/telemetry-runtime.js +17 -0
  71. package/telemetry/telemetry-service.js +1 -0
  72. package/types/errors/index.d.ts +24 -10
  73. package/types/logs/index.d.ts +61 -2
  74. package/user/repositories/user-repository-documentdb.js +10 -42
@@ -136,13 +136,13 @@ class OAuth2Requester extends Requester {
136
136
  this.refresh_token = newRefreshToken;
137
137
  } else {
138
138
  if (this.refresh_token) {
139
- console.log(
140
- '[Frigg] No refresh_token in response, preserving existing'
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
- console.log(
144
- '[Frigg] Current refresh_token is null and no new refresh_token in response'
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
- console.log('[Frigg] Refreshing access token with options');
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
- console.log('[Frigg] Starting token refresh', {
327
- grant_type: this.grant_type,
328
- has_refresh_token: !!this.refresh_token,
329
- has_client_id: !!this.client_id,
330
- has_client_secret: !!this.client_secret,
331
- has_token_uri: !!this.tokenUri,
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
- console.log('[Frigg] Token refresh succeeded');
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
- console.error(`[Frigg] Token refresh failed for ${moduleName}`, {
351
- error_message: error?.message,
352
- error_name: error?.name,
353
- response_status: error?.response?.status,
354
- response_data: error?.response?.data,
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
- console.log(
409
- '[Frigg] Adopted a newer credential after a refresh rejection',
410
- { module: this._telemetryModuleLabel() }
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
- responseBody: isTimeout
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 (which FetchError
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
- throw this._maybeFlagTimeoutDuringBodyRead(e, timeoutMs);
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.111",
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.111",
52
- "@friggframework/prettier-config": "2.0.0-next.111",
53
- "@friggframework/test": "2.0.0-next.111",
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": "6880b89cf8aa381fac9165ad0bb810347bc8a75d"
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
+ };
@@ -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;