@friggframework/core 2.0.0-next.102 → 2.0.0-next.103

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 (55) hide show
  1. package/README.md +40 -0
  2. package/application/commands/usage-commands.js +56 -0
  3. package/application/index.js +10 -9
  4. package/core/create-handler.js +112 -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 +1503 -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 +23 -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 +1540 -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 +22 -0
  18. package/generated/prisma-postgresql/wasm.js +16 -4
  19. package/handlers/app-definition-loader.js +26 -3
  20. package/handlers/integration-event-dispatcher.js +29 -15
  21. package/handlers/routers/integration-webhook-routers.js +20 -7
  22. package/index.js +16 -9
  23. package/integrations/integration-base.js +64 -7
  24. package/modules/requester/requester.js +106 -5
  25. package/package.json +12 -5
  26. package/prisma-mongodb/schema.prisma +23 -0
  27. package/prisma-postgresql/migrations/20260705000000_create_usage_counter/migration.sql +26 -0
  28. package/prisma-postgresql/schema.prisma +22 -0
  29. package/reporting/README.md +8 -1
  30. package/reporting/reporting-router.js +8 -1
  31. package/reporting/use-cases/list-integrations-report.js +53 -6
  32. package/telemetry/README.md +331 -0
  33. package/telemetry/bind-telemetry-context.js +73 -0
  34. package/telemetry/canonical-counters.js +52 -0
  35. package/telemetry/exporters.js +85 -0
  36. package/telemetry/index.js +26 -0
  37. package/telemetry/instrument-handler.js +87 -0
  38. package/telemetry/no-op-telemetry.js +67 -0
  39. package/telemetry/north-star.js +103 -0
  40. package/telemetry/otel-telemetry.js +213 -0
  41. package/telemetry/plugin-subscribers.js +77 -0
  42. package/telemetry/telemetry-config.js +120 -0
  43. package/telemetry/telemetry-context.js +40 -0
  44. package/telemetry/telemetry-event-bus.js +58 -0
  45. package/telemetry/telemetry-runtime.js +147 -0
  46. package/telemetry/telemetry-service.js +51 -0
  47. package/telemetry/usage-rollup-subscriber.js +116 -0
  48. package/usage/README.md +54 -0
  49. package/usage/index.js +17 -0
  50. package/usage/repositories/usage-repository-documentdb.js +194 -0
  51. package/usage/repositories/usage-repository-factory.js +25 -0
  52. package/usage/repositories/usage-repository-interface.js +37 -0
  53. package/usage/repositories/usage-repository-prisma.js +146 -0
  54. package/usage/tracked-metrics.js +38 -0
  55. package/usage/usage-windows.js +24 -0
@@ -2,6 +2,7 @@ const fetch = require('node-fetch');
2
2
  const { Delegate } = require('../../core');
3
3
  const { FetchError } = require('../../errors');
4
4
  const { get } = require('../../assertions');
5
+ const { getTelemetry } = require('../../telemetry/telemetry-runtime');
5
6
 
6
7
  const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
7
8
  const MAX_AUTH_RETRIES = 3;
@@ -44,6 +45,40 @@ class Requester extends Delegate {
44
45
  // Allow passing in the fetch function
45
46
  // Instance methods can use this.fetch without differentiating
46
47
  this.fetch = get(params, 'fetch', fetch);
48
+
49
+ // Defaults to the process singleton. Pass an integration's bound
50
+ // `this.telemetry` to attribute out-of-band requests — setup/OAuth calls
51
+ // made before an integration context exists aren't rolled up otherwise.
52
+ this.telemetry = (params && params.telemetry) || getTelemetry();
53
+ }
54
+
55
+ /**
56
+ * Redact secrets/PII from a URL before it touches telemetry. Many API
57
+ * modules embed credentials in the query string (?api_key=, ?token=,
58
+ * presigned signatures) or in userinfo — those must never reach a span,
59
+ * the bus, or an exporter. Keep only protocol + host + path (enough for
60
+ * North Star endpoint matching).
61
+ */
62
+ _sanitizeUrl(url) {
63
+ const raw = String(url);
64
+ try {
65
+ const u = new URL(raw);
66
+ return `${u.protocol}//${u.host}${u.pathname}`;
67
+ } catch (_) {
68
+ // Relative/opaque URL: drop the query string at minimum.
69
+ return raw.split('?')[0];
70
+ }
71
+ }
72
+
73
+ /** Bounded module label for the apimodule.requests metric. */
74
+ _telemetryModuleLabel() {
75
+ return (
76
+ this.moduleName ||
77
+ this.delegate?.name ||
78
+ this.delegate?.constructor?.name ||
79
+ this.constructor?.name ||
80
+ 'unknown'
81
+ );
47
82
  }
48
83
 
49
84
  parsedBody = async (resp) => {
@@ -61,15 +96,81 @@ class Requester extends Delegate {
61
96
  };
62
97
 
63
98
  /**
99
+ * Instrumenting entry point. Wraps the whole logical request —
100
+ * including retry/refresh recursion — in a single span + one
101
+ * `frigg.apimodule.requests` counter, emitted on the `attempt === 0`
102
+ * boundary so retries are never double-counted. The full URL rides the
103
+ * span only; the metric carries bounded labels {module, method, status}
104
+ * — never endpoint.
105
+ *
64
106
  * @param {string} url - The request URL, relative or absolute.
65
107
  * @param {Object} options - Fetch options (method, headers, body, query,
66
108
  * returnFullRes, etc.) built by the `_get`/`_post`/`_patch`/`_put`/
67
109
  * `_delete` wrappers.
68
110
  * @param {number} attempt - 0-based count of retries already made for
111
+ * this call. Non-zero only if a caller re-enters directly; normal
112
+ * retries recurse through `_rawRequest` instead (see below).
113
+ */
114
+ async _request(url, options = {}, attempt = 0) {
115
+ if (attempt !== 0) {
116
+ return this._rawRequest(url, options, attempt);
117
+ }
118
+
119
+ const telemetry = this.telemetry;
120
+ if (!telemetry || typeof telemetry.span !== 'function') {
121
+ return this._rawRequest(url, options, 0);
122
+ }
123
+
124
+ const module = this._telemetryModuleLabel();
125
+ const method = (options.method || 'GET').toUpperCase();
126
+ const safeUrl = this._sanitizeUrl(url);
127
+
128
+ return telemetry.span('frigg.apimodule.request', async (span) => {
129
+ if (span && typeof span.setAttributes === 'function') {
130
+ span.setAttributes({
131
+ 'frigg.module': module,
132
+ 'http.request.method': method,
133
+ // Redacted (no query/userinfo) — never emit raw URLs.
134
+ 'url.path': safeUrl,
135
+ });
136
+ }
137
+ // Redacted url (unbounded) rides the bus-only context for North Star
138
+ // derived-from-trace matching — never a metric label.
139
+ const busContext = { url: safeUrl };
140
+ try {
141
+ const result = await this._rawRequest(url, options, 0);
142
+ telemetry.count(
143
+ 'frigg.apimodule.requests',
144
+ 1,
145
+ { module, method, status: 'ok' },
146
+ busContext
147
+ );
148
+ return result;
149
+ } catch (err) {
150
+ const code = err?.status ?? err?.statusCode;
151
+ const status = code ? String(code) : 'error';
152
+ if (span && typeof span.setAttribute === 'function' && code) {
153
+ span.setAttribute('http.response.status_code', code);
154
+ }
155
+ telemetry.count(
156
+ 'frigg.apimodule.requests',
157
+ 1,
158
+ { module, method, status },
159
+ busContext
160
+ );
161
+ throw err;
162
+ }
163
+ });
164
+ }
165
+
166
+ /**
167
+ * @param {string} url - The request URL, relative or absolute.
168
+ * @param {Object} options - Fetch options, as built by `_request`.
169
+ * @param {number} attempt - 0-based count of retries already made for
69
170
  * this call. Indexes `this.backOff` for the next delay and is passed
70
171
  * back in on each recursive retry.
71
172
  */
72
- async _request(url, options, attempt = 0) {
173
+ async _rawRequest(url, options, attempt = 0) {
73
174
  let encodedUrl = encodeURI(url);
74
175
  if (options.query) {
75
176
  let queryBuild = '?';
@@ -131,7 +232,7 @@ class Requester extends Delegate {
131
232
  clearRequestTimer();
132
233
  const delay = this.backOff[attempt] * 1000;
133
234
  await new Promise((resolve) => setTimeout(resolve, delay));
134
- return this._request(url, options, attempt + 1);
235
+ return this._rawRequest(url, options, attempt + 1);
135
236
  }
136
237
  const fetchError = await FetchError.create({
137
238
  resource: encodedUrl,
@@ -161,7 +262,7 @@ class Requester extends Delegate {
161
262
  clearRequestTimer();
162
263
  const delay = this.backOff[attempt] * 1000;
163
264
  await new Promise((resolve) => setTimeout(resolve, delay));
164
- return this._request(url, options, attempt + 1);
265
+ return this._rawRequest(url, options, attempt + 1);
165
266
  }
166
267
 
167
268
  if (status === 401) {
@@ -179,7 +280,7 @@ class Requester extends Delegate {
179
280
  await new Promise((resolve) =>
180
281
  setTimeout(resolve, delay)
181
282
  );
182
- return this._request(url, options, attempt + 1);
283
+ return this._rawRequest(url, options, attempt + 1);
183
284
  }
184
285
 
185
286
  throw await this._invalidateAuth(
@@ -194,7 +295,7 @@ class Requester extends Delegate {
194
295
  const refreshSucceeded = await this.refreshAuth();
195
296
  if (refreshSucceeded) {
196
297
  clearRequestTimer();
197
- return this._request(url, options, attempt + 1);
298
+ return this._rawRequest(url, options, attempt + 1);
198
299
  }
199
300
 
200
301
  throw await this._invalidateAuth(
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.102",
4
+ "version": "2.0.0-next.103",
5
5
  "dependencies": {
6
6
  "@aws-sdk/client-apigatewaymanagementapi": "^3.588.0",
7
7
  "@aws-sdk/client-kms": "^3.588.0",
@@ -9,6 +9,13 @@
9
9
  "@aws-sdk/client-sqs": "^3.588.0",
10
10
  "@aws-sdk/client-ssm": "^3.588.0",
11
11
  "@hapi/boom": "^10.0.1",
12
+ "@opentelemetry/api": "^1.9.1",
13
+ "@opentelemetry/context-async-hooks": "^2.9.0",
14
+ "@opentelemetry/exporter-metrics-otlp-http": "^0.220.0",
15
+ "@opentelemetry/exporter-trace-otlp-http": "^0.220.0",
16
+ "@opentelemetry/resources": "^2.9.0",
17
+ "@opentelemetry/sdk-metrics": "^2.9.0",
18
+ "@opentelemetry/sdk-trace-base": "^2.9.0",
12
19
  "bcryptjs": "^2.4.3",
13
20
  "body-parser": "^1.20.5",
14
21
  "bson": "^4.7.2",
@@ -39,9 +46,9 @@
39
46
  }
40
47
  },
41
48
  "devDependencies": {
42
- "@friggframework/eslint-config": "2.0.0-next.102",
43
- "@friggframework/prettier-config": "2.0.0-next.102",
44
- "@friggframework/test": "2.0.0-next.102",
49
+ "@friggframework/eslint-config": "2.0.0-next.103",
50
+ "@friggframework/prettier-config": "2.0.0-next.103",
51
+ "@friggframework/test": "2.0.0-next.103",
45
52
  "@prisma/client": "^6.19.3",
46
53
  "@types/lodash": "4.17.15",
47
54
  "@typescript-eslint/eslint-plugin": "^8.0.0",
@@ -81,5 +88,5 @@
81
88
  "publishConfig": {
82
89
  "access": "public"
83
90
  },
84
- "gitHead": "88c3879ce502f9a5eb0e08d41ec55e2559e1fca8"
91
+ "gitHead": "9fc434b0c6e1323bd16be165d311b6880a23de12"
85
92
  }
@@ -430,3 +430,26 @@ model ScriptSchedule {
430
430
  @@index([enabled])
431
431
  @@map("ScriptSchedule")
432
432
  }
433
+
434
+ /// Durable usage counters, deliberately isolated: no userId and no foreign key
435
+ /// to Integration, so a user-scoped query can never return a usage row and usage
436
+ /// history survives integration deletion.
437
+ model UsageCounter {
438
+ id String @id @default(auto()) @map("_id") @db.ObjectId
439
+
440
+ integrationId String
441
+ integrationType String
442
+ metric String
443
+ window String // e.g. "day:2026-07-05" or "hour:2026-07-05T14"
444
+ value BigInt @default(0) // BigInt: a busy day/hour counter can exceed 32-bit
445
+
446
+ createdAt DateTime @default(now())
447
+ updatedAt DateTime @updatedAt
448
+
449
+ @@unique([integrationId, integrationType, metric, window])
450
+ // Covers totals() (metric + window prefix/range, no integrationType filter).
451
+ @@index([metric, window])
452
+ // Covers series() (metric + integrationType + window range).
453
+ @@index([metric, integrationType, window])
454
+ @@map("UsageCounter")
455
+ }
@@ -0,0 +1,26 @@
1
+ -- CreateTable
2
+ -- Durable usage counters, isolated from the user/integration-scoped tables (no
3
+ -- userId, no FK to Integration) so usage history survives integration deletion
4
+ -- and can never surface in a user-scoped query. "window" is quoted throughout —
5
+ -- it is a reserved SQL keyword.
6
+ CREATE TABLE "UsageCounter" (
7
+ "id" SERIAL NOT NULL,
8
+ "integrationId" TEXT NOT NULL,
9
+ "integrationType" TEXT NOT NULL,
10
+ "metric" TEXT NOT NULL,
11
+ "window" TEXT NOT NULL,
12
+ "value" BIGINT NOT NULL DEFAULT 0,
13
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
14
+ "updatedAt" TIMESTAMP(3) NOT NULL,
15
+
16
+ CONSTRAINT "UsageCounter_pkey" PRIMARY KEY ("id")
17
+ );
18
+
19
+ -- CreateIndex
20
+ CREATE UNIQUE INDEX "UsageCounter_integrationId_integrationType_metric_window_key" ON "UsageCounter"("integrationId", "integrationType", "metric", "window");
21
+
22
+ -- CreateIndex
23
+ CREATE INDEX "UsageCounter_metric_window_idx" ON "UsageCounter"("metric", "window");
24
+
25
+ -- CreateIndex
26
+ CREATE INDEX "UsageCounter_metric_integrationType_window_idx" ON "UsageCounter"("metric", "integrationType", "window");
@@ -412,3 +412,25 @@ model ScriptSchedule {
412
412
 
413
413
  @@index([enabled])
414
414
  }
415
+
416
+ /// Durable usage counters, deliberately isolated: no userId and no foreign key
417
+ /// to Integration, so a user-scoped query can never return a usage row and usage
418
+ /// history survives integration deletion.
419
+ model UsageCounter {
420
+ id Int @id @default(autoincrement())
421
+
422
+ integrationId String
423
+ integrationType String
424
+ metric String
425
+ window String // e.g. "day:2026-07-05" or "hour:2026-07-05T14"
426
+ value BigInt @default(0) // BigInt: a busy day/hour counter can exceed 32-bit
427
+
428
+ createdAt DateTime @default(now())
429
+ updatedAt DateTime @updatedAt
430
+
431
+ @@unique([integrationId, integrationType, metric, window])
432
+ // Covers totals() (metric + window prefix/range, no integrationType filter).
433
+ @@index([metric, window])
434
+ // Covers series() (metric + integrationType + window range).
435
+ @@index([metric, integrationType, window])
436
+ }
@@ -52,8 +52,15 @@ Optional query params (all strings): `status` (an `IntegrationStatus`), `type`
52
52
  - `filters` — echoes the applied filters (nulls when omitted).
53
53
  - `metrics.total` — count of integrations matching the filters.
54
54
  - `metrics.byStatus` — counts keyed by `IntegrationStatus` value.
55
- - `metrics.byType[]` — per `type` breakdown: `{ type, label, total, byStatus }`.
55
+ - `metrics.byType[]` — per `type` breakdown: `{ type, label, total, byStatus, usage? }`.
56
56
  - `type` — the `config.type` slug. Integrations with no type bucket as `"unknown"`.
57
+ - `usage` — **additive** (ADR-011): present only when the deployment has the
58
+ usage store configured. A map of canonical counter → total for that type,
59
+ e.g. `{ "records.synced": 4200, "webhooks.received": 118, "api.requests": 9004 }`
60
+ (`0` when a type has no data). Read only from the Frigg usage store, never an
61
+ external APM; a usage-store read failure never fails the structural report.
62
+ Populated by the counters an integration opts into via `Definition.usage` —
63
+ see [`../telemetry/README.md`](../telemetry/README.md).
57
64
  - `label` — human-readable name from the integration class's
58
65
  `Definition.display.label`. Falls back to the `type` slug when no registered
59
66
  class supplies a label (e.g. an integration that was removed, or run on an
@@ -3,7 +3,12 @@ const catchAsyncError = require('express-async-handler');
3
3
  const {
4
4
  createReportingRepository,
5
5
  } = require('./repositories/reporting-repository-factory');
6
- const { ListIntegrationsReport } = require('./use-cases/list-integrations-report');
6
+ const {
7
+ createUsageRepository,
8
+ } = require('../usage/repositories/usage-repository-factory');
9
+ const {
10
+ ListIntegrationsReport,
11
+ } = require('./use-cases/list-integrations-report');
7
12
  const { loadAppDefinition } = require('../handlers/app-definition-loader');
8
13
 
9
14
  // IntegrationBase.Definition default — skip it so the slug is used instead.
@@ -35,8 +40,10 @@ function buildTypeLabels() {
35
40
 
36
41
  function createReportingRouter() {
37
42
  const reportingRepository = createReportingRepository();
43
+ const usageRepository = createUsageRepository();
38
44
  const listIntegrationsReport = new ListIntegrationsReport({
39
45
  reportingRepository,
46
+ usageRepository,
40
47
  typeLabels: buildTypeLabels(),
41
48
  });
42
49
 
@@ -1,4 +1,5 @@
1
1
  const Boom = require('@hapi/boom');
2
+ const { CANONICAL_COUNTERS } = require('../../telemetry/canonical-counters');
2
3
 
3
4
  const SCHEMA_VERSION = 1;
4
5
  const SERVICE = 'frigg-core-api';
@@ -16,11 +17,19 @@ const KNOWN_STATUSES = [
16
17
  ];
17
18
 
18
19
  class ListIntegrationsReport {
19
- constructor({ reportingRepository, typeLabels = {} } = {}) {
20
+ constructor({
21
+ reportingRepository,
22
+ usageRepository,
23
+ typeLabels = {},
24
+ } = {}) {
20
25
  if (!reportingRepository) {
21
26
  throw new Error('reportingRepository is required');
22
27
  }
28
+ if (!usageRepository) {
29
+ throw new Error('usageRepository is required');
30
+ }
23
31
  this.reportingRepository = reportingRepository;
32
+ this.usageRepository = usageRepository;
24
33
  this.typeLabels = typeLabels;
25
34
  }
26
35
 
@@ -67,7 +76,8 @@ class ListIntegrationsReport {
67
76
  if (!byTypeMap.has(integration.type)) {
68
77
  byTypeMap.set(integration.type, {
69
78
  type: integration.type,
70
- label: this.typeLabels[integration.type] || integration.type,
79
+ label:
80
+ this.typeLabels[integration.type] || integration.type,
71
81
  total: 0,
72
82
  byStatus: emptyStatusCounts(),
73
83
  });
@@ -77,6 +87,8 @@ class ListIntegrationsReport {
77
87
  bucket.byStatus[statusKey] = (bucket.byStatus[statusKey] ?? 0) + 1;
78
88
  }
79
89
 
90
+ await this._attachUsageColumns(byTypeMap);
91
+
80
92
  return {
81
93
  schemaVersion: SCHEMA_VERSION,
82
94
  service: SERVICE,
@@ -96,9 +108,44 @@ class ListIntegrationsReport {
96
108
  };
97
109
  }
98
110
 
111
+ async _attachUsageColumns(byTypeMap) {
112
+ const metrics = Object.keys(CANONICAL_COUNTERS);
113
+ try {
114
+ const totalsByMetric = await Promise.all(
115
+ metrics.map(async (metric) => {
116
+ const totals = await this.usageRepository.getTotalsByDimension({
117
+ metric,
118
+ groupBy: 'integrationType',
119
+ });
120
+ const map = new Map(
121
+ (totals || []).map((t) => [t.integrationType, t.value])
122
+ );
123
+ return [metric, map];
124
+ })
125
+ );
126
+
127
+ for (const bucket of byTypeMap.values()) {
128
+ bucket.usage = {};
129
+ for (const [metric, map] of totalsByMetric) {
130
+ bucket.usage[metric] = map.get(bucket.type) ?? 0;
131
+ }
132
+ }
133
+ } catch (error) {
134
+ console.warn(
135
+ `[Frigg][reporting] usage columns unavailable: ${
136
+ error && error.message
137
+ }`
138
+ );
139
+ }
140
+ }
141
+
99
142
  _validateQuery({ status, type, userId } = {}) {
100
143
  for (const [key, value] of Object.entries({ status, type, userId })) {
101
- if (value !== undefined && value !== null && typeof value !== 'string') {
144
+ if (
145
+ value !== undefined &&
146
+ value !== null &&
147
+ typeof value !== 'string'
148
+ ) {
102
149
  throw Boom.badRequest(
103
150
  `Invalid query parameter '${key}': expected a string`
104
151
  );
@@ -112,9 +159,9 @@ class ListIntegrationsReport {
112
159
  };
113
160
  if (normalized.status && !KNOWN_STATUSES.includes(normalized.status)) {
114
161
  throw Boom.badRequest(
115
- `Invalid status '${normalized.status}'. Expected one of: ${KNOWN_STATUSES.join(
116
- ', '
117
- )}`
162
+ `Invalid status '${
163
+ normalized.status
164
+ }'. Expected one of: ${KNOWN_STATUSES.join(', ')}`
118
165
  );
119
166
  }
120
167
  return normalized;