@friggframework/core 2.0.0--canary.517.beaf080.0 → 2.0.0--canary.622.5e1352f.0

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 (56) hide show
  1. package/README.md +40 -0
  2. package/application/commands/usage-commands.js +56 -0
  3. package/application/index.js +8 -9
  4. package/core/create-handler.js +95 -10
  5. package/generated/prisma-mongodb/edge.js +16 -4
  6. package/generated/prisma-mongodb/index-browser.js +13 -1
  7. package/generated/prisma-mongodb/index.d.ts +1479 -105
  8. package/generated/prisma-mongodb/index.js +16 -4
  9. package/generated/prisma-mongodb/package.json +1 -1
  10. package/generated/prisma-mongodb/schema.prisma +22 -0
  11. package/generated/prisma-mongodb/wasm.js +16 -4
  12. package/generated/prisma-postgresql/edge.js +16 -4
  13. package/generated/prisma-postgresql/index-browser.js +13 -1
  14. package/generated/prisma-postgresql/index.d.ts +1465 -91
  15. package/generated/prisma-postgresql/index.js +16 -4
  16. package/generated/prisma-postgresql/package.json +1 -1
  17. package/generated/prisma-postgresql/schema.prisma +21 -0
  18. package/generated/prisma-postgresql/wasm.js +16 -4
  19. package/handlers/app-definition-loader.js +25 -3
  20. package/handlers/integration-event-dispatcher.js +34 -15
  21. package/handlers/routers/integration-webhook-routers.js +23 -7
  22. package/index.js +11 -9
  23. package/integrations/integration-base.js +80 -20
  24. package/modules/requester/requester.js +97 -4
  25. package/package.json +12 -5
  26. package/prisma-mongodb/schema.prisma +22 -0
  27. package/prisma-postgresql/migrations/20260705000000_create_usage_counter/migration.sql +26 -0
  28. package/prisma-postgresql/schema.prisma +21 -0
  29. package/reporting/README.md +8 -1
  30. package/reporting/reporting-router.js +16 -1
  31. package/reporting/use-cases/list-integrations-report.js +61 -6
  32. package/telemetry/README.md +268 -0
  33. package/telemetry/bind-telemetry-context.js +51 -0
  34. package/telemetry/canonical-counters.js +52 -0
  35. package/telemetry/exporters/exporter-factory.js +79 -0
  36. package/telemetry/index.js +48 -0
  37. package/telemetry/instrument-handler.js +82 -0
  38. package/telemetry/no-op-telemetry.js +66 -0
  39. package/telemetry/north-star.js +103 -0
  40. package/telemetry/otel-telemetry.js +215 -0
  41. package/telemetry/telemetry-config.js +92 -0
  42. package/telemetry/telemetry-context.js +40 -0
  43. package/telemetry/telemetry-event-bus.js +58 -0
  44. package/telemetry/telemetry-service.js +44 -0
  45. package/telemetry/telemetry-singleton.js +41 -0
  46. package/telemetry/usage-rollup-singleton.js +74 -0
  47. package/telemetry/usage-rollup-subscriber.js +116 -0
  48. package/telemetry/usage-windows.js +14 -0
  49. package/usage/README.md +52 -0
  50. package/usage/index.js +19 -0
  51. package/usage/repositories/usage-repository-documentdb.js +15 -0
  52. package/usage/repositories/usage-repository-factory.js +28 -0
  53. package/usage/repositories/usage-repository-interface.js +37 -0
  54. package/usage/repositories/usage-repository-mongo.js +12 -0
  55. package/usage/repositories/usage-repository-postgres.js +142 -0
  56. package/usage/tracked-metrics.js +38 -0
package/README.md CHANGED
@@ -200,6 +200,46 @@ const secureData = cryptor.encrypt(JSON.stringify({
200
200
  }));
201
201
  ```
202
202
 
203
+ ### 4b. Telemetry & Usage Tracking (`/telemetry`, `/usage`)
204
+
205
+ Vendor-neutral OpenTelemetry observability plus durable, per-integration usage
206
+ counters (ADR-011). No-op by default (zero cold-start cost); framework seams are
207
+ auto-instrumented so integrations get handler/API-module/webhook metrics for free.
208
+
209
+ **Usage:**
210
+ ```javascript
211
+ // App definition — turn on export + declare a North Star:
212
+ const Definition = {
213
+ name: 'my-app',
214
+ telemetry: {
215
+ exporter: { type: 'otlp', endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT },
216
+ northStar: { default: { name: 'records.synced' } },
217
+ },
218
+ };
219
+
220
+ // Integration code — custom metrics/spans (this.telemetry is auto-tagged):
221
+ await this.telemetry.span('delta_sync', async () => {
222
+ this.telemetry.count('records.synced', batch.length, { entity: 'contact' });
223
+ });
224
+
225
+ // Declare which usage counters an integration reports (opts into reporting):
226
+ class HubSpotIntegration extends IntegrationBase {
227
+ static Definition = {
228
+ name: 'hubspot',
229
+ usage: { canonical: ['records.synced', 'api.requests'] },
230
+ };
231
+ }
232
+
233
+ // Read the durable usage store (never an APM):
234
+ const frigg = createFriggCommands({ integrationClass: HubSpotIntegration });
235
+ await frigg.usage.totals({ metric: 'records.synced', groupBy: 'integrationType' });
236
+ ```
237
+
238
+ **See:** [`telemetry/README.md`](telemetry/README.md) for the full guide
239
+ (exporters, custom metrics, the Usage-Counter contract, North Star, the plugin
240
+ tap, cardinality rules, and caveats) and [`usage/README.md`](usage/README.md) for
241
+ the store internals.
242
+
203
243
  ### 5. Error Handling (`/errors`)
204
244
 
205
245
  Standardized error types with proper HTTP status codes.
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Usage Commands
3
+ *
4
+ * Application Layer — the read/write surface over the durable usage store
5
+ * (ADR-011 §5). Exposed as `frigg.usage.*` on the unified command object so
6
+ * reports and integration code query usage without touching the repository.
7
+ *
8
+ * @example
9
+ * const frigg = createFriggCommands({ integrationClass: MyIntegration });
10
+ * await frigg.usage.totals({ metric: 'records.synced', groupBy: 'integrationType', since });
11
+ * await frigg.usage.series({ metric: 'records.synced', integrationType: 'hubspot', bucket: 'day' });
12
+ */
13
+ const {
14
+ createUsageRepository,
15
+ } = require('../../usage/repositories/usage-repository-factory');
16
+ const { computeUsageWindows } = require('../../telemetry/usage-windows');
17
+
18
+ function createUsageCommands({ usageRepository } = {}) {
19
+ const repository = usageRepository || createUsageRepository();
20
+
21
+ return {
22
+ /**
23
+ * Record a usage counter for a point in time. Callers pass `at` (a Date,
24
+ * default now) — NOT a raw window key — and both the day and hour windows
25
+ * are derived, matching how the auto-rollup persists so series() reads
26
+ * back consistently at either granularity.
27
+ */
28
+ async recordUsageCounter({
29
+ integrationId,
30
+ integrationType,
31
+ metric,
32
+ value = 1,
33
+ at = new Date(),
34
+ }) {
35
+ for (const window of computeUsageWindows(at)) {
36
+ await repository.increment({
37
+ integrationId,
38
+ integrationType,
39
+ metric,
40
+ window,
41
+ value,
42
+ });
43
+ }
44
+ },
45
+
46
+ async totals(args) {
47
+ return repository.totals(args);
48
+ },
49
+
50
+ async series(args) {
51
+ return repository.series(args);
52
+ },
53
+ };
54
+ }
55
+
56
+ module.exports = { createUsageCommands };
@@ -4,15 +4,10 @@ const {
4
4
  } = require('./commands/integration-commands');
5
5
  const { createUserCommands } = require('./commands/user-commands');
6
6
  const { createEntityCommands } = require('./commands/entity-commands');
7
- const {
8
- createCredentialCommands,
9
- } = require('./commands/credential-commands');
10
- const {
11
- createProcessCommands,
12
- } = require('./commands/process-commands');
13
- const {
14
- createSchedulerCommands,
15
- } = require('./commands/scheduler-commands');
7
+ const { createCredentialCommands } = require('./commands/credential-commands');
8
+ const { createProcessCommands } = require('./commands/process-commands');
9
+ const { createSchedulerCommands } = require('./commands/scheduler-commands');
10
+ const { createUsageCommands } = require('./commands/usage-commands');
16
11
 
17
12
  /**
18
13
  * Create a unified command factory with all CRUD operations
@@ -56,6 +51,9 @@ function createFriggCommands({ integrationClass }) {
56
51
 
57
52
  // Process commands
58
53
  ...processCommands,
54
+
55
+ // Usage read/write (ADR-011) — nested to match `frigg.usage.*`
56
+ usage: createUsageCommands(),
59
57
  };
60
58
  }
61
59
 
@@ -70,6 +68,7 @@ module.exports = {
70
68
  createCredentialCommands,
71
69
  createProcessCommands,
72
70
  createSchedulerCommands,
71
+ createUsageCommands,
73
72
 
74
73
  // Legacy standalone function
75
74
  findIntegrationContextByExternalEntityId,
@@ -4,6 +4,79 @@
4
4
 
5
5
  const { initDebugLog, flushDebugLog } = require('../logs');
6
6
  const { secretsToEnv } = require('./secrets-to-env');
7
+ const { getTelemetry } = require('../telemetry/telemetry-singleton');
8
+ const {
9
+ getUsageRollupSubscriber,
10
+ } = require('../telemetry/usage-rollup-singleton');
11
+
12
+ // Bounds the tail latency telemetry adds to every warm invocation. Kept low so
13
+ // an unreachable OTLP endpoint (e.g. a VPC Lambda with no NAT/egress) costs at
14
+ // most this, not multiple seconds. Override with OTEL_FLUSH_TIMEOUT_MS.
15
+ const DEFAULT_FLUSH_TIMEOUT_MS =
16
+ Number(process.env.OTEL_FLUSH_TIMEOUT_MS) || 500;
17
+
18
+ /**
19
+ * Fold the invocation's buffered usage counters into the durable store, then
20
+ * clear the buffer. On an SQS redelivery (ApproximateReceiveCount > 1) we
21
+ * DISCARD rather than flush — the prior delivery already counted, and the usage
22
+ * accuracy contract is "approximate, skip obvious redeliveries". Fully guarded.
23
+ */
24
+ async function flushUsageRollup(subscriber, eventSummary, shouldUseDatabase) {
25
+ if (!subscriber) return;
26
+ try {
27
+ // Persisting usage requires a DB connection. DB-free handlers (e.g. the
28
+ // webhook-receipt route) never called connectPrisma, so drop the buffer
29
+ // instead of issuing a connectionless Prisma write.
30
+ if (!shouldUseDatabase) {
31
+ subscriber.discard();
32
+ return;
33
+ }
34
+ const redelivered =
35
+ Array.isArray(eventSummary?.records) &&
36
+ eventSummary.records.some((r) => Number(r.receiveCount) > 1);
37
+ if (redelivered) {
38
+ subscriber.discard();
39
+ } else {
40
+ await subscriber.flush();
41
+ }
42
+ } catch (_) {
43
+ // Usage rollup must never break the handler.
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Flush telemetry before the Lambda container freezes. Because
49
+ * `callbackWaitsForEmptyEventLoop=false` (below) stops the event loop the moment
50
+ * the handler returns, OTel's timer-driven batch processors would never fire —
51
+ * so spans/metrics must be flushed synchronously here. Bounded by a timeout so a
52
+ * stalled exporter can never block the response, and fully guarded so a flush
53
+ * failure never breaks the handler.
54
+ */
55
+ async function flushTelemetry(telemetry, timeoutMs) {
56
+ try {
57
+ if (
58
+ !telemetry ||
59
+ typeof telemetry.isEnabled !== 'function' ||
60
+ !telemetry.isEnabled()
61
+ ) {
62
+ return;
63
+ }
64
+ let timer;
65
+ const deadline = new Promise((resolve) => {
66
+ timer = setTimeout(resolve, timeoutMs);
67
+ });
68
+ try {
69
+ await Promise.race([
70
+ Promise.resolve(telemetry.forceFlush()),
71
+ deadline,
72
+ ]);
73
+ } finally {
74
+ clearTimeout(timer);
75
+ }
76
+ } catch (_) {
77
+ // Telemetry flush must never break the handler.
78
+ }
79
+ }
7
80
 
8
81
  // Best-effort extraction of correlation identifiers from a Lambda event.
9
82
  // For SQS: pulls messageIds + parsed event/processId/integrationId from each
@@ -36,8 +109,7 @@ const summarizeLambdaEvent = (event) => {
36
109
  if (event.httpMethod || event.requestContext?.http) {
37
110
  return {
38
111
  source: 'http',
39
- method:
40
- event.httpMethod || event.requestContext?.http?.method,
112
+ method: event.httpMethod || event.requestContext?.http?.method,
41
113
  path: event.path || event.rawPath,
42
114
  };
43
115
  }
@@ -50,6 +122,9 @@ const createHandler = (optionByName = {}) => {
50
122
  isUserFacingResponse = true,
51
123
  method,
52
124
  shouldUseDatabase = true,
125
+ telemetry,
126
+ flushTimeoutMs = DEFAULT_FLUSH_TIMEOUT_MS,
127
+ usageRollup,
53
128
  } = optionByName;
54
129
 
55
130
  if (!method) {
@@ -58,16 +133,18 @@ const createHandler = (optionByName = {}) => {
58
133
 
59
134
  return async (event, context) => {
60
135
  const eventSummary = summarizeLambdaEvent(event);
136
+ const activeTelemetry = telemetry || getTelemetry();
137
+ const activeUsageRollup =
138
+ usageRollup !== undefined
139
+ ? usageRollup
140
+ : getUsageRollupSubscriber();
61
141
 
62
142
  try {
63
- console.info(
64
- `[createHandler] ${eventName}: handler entry`,
65
- {
66
- eventName,
67
- awsRequestId: context?.awsRequestId,
68
- ...eventSummary,
69
- }
70
- );
143
+ console.info(`[createHandler] ${eventName}: handler entry`, {
144
+ eventName,
145
+ awsRequestId: context?.awsRequestId,
146
+ ...eventSummary,
147
+ });
71
148
 
72
149
  initDebugLog(eventName, event);
73
150
 
@@ -139,6 +216,14 @@ const createHandler = (optionByName = {}) => {
139
216
 
140
217
  // Here we can just rethrow and let AWS build the response.
141
218
  throw error;
219
+ } finally {
220
+ // Flush telemetry + usage before the container freezes.
221
+ await flushTelemetry(activeTelemetry, flushTimeoutMs);
222
+ await flushUsageRollup(
223
+ activeUsageRollup,
224
+ eventSummary,
225
+ shouldUseDatabase
226
+ );
142
227
  }
143
228
  };
144
229
  };