@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.
- package/README.md +40 -0
- package/application/commands/usage-commands.js +56 -0
- package/application/index.js +10 -9
- package/core/create-handler.js +112 -10
- package/generated/prisma-mongodb/edge.js +16 -4
- package/generated/prisma-mongodb/index-browser.js +13 -1
- package/generated/prisma-mongodb/index.d.ts +1503 -105
- package/generated/prisma-mongodb/index.js +16 -4
- package/generated/prisma-mongodb/package.json +1 -1
- package/generated/prisma-mongodb/schema.prisma +23 -0
- package/generated/prisma-mongodb/wasm.js +16 -4
- package/generated/prisma-postgresql/edge.js +16 -4
- package/generated/prisma-postgresql/index-browser.js +13 -1
- package/generated/prisma-postgresql/index.d.ts +1540 -91
- package/generated/prisma-postgresql/index.js +16 -4
- package/generated/prisma-postgresql/package.json +1 -1
- package/generated/prisma-postgresql/schema.prisma +22 -0
- package/generated/prisma-postgresql/wasm.js +16 -4
- package/handlers/app-definition-loader.js +26 -3
- package/handlers/integration-event-dispatcher.js +29 -15
- package/handlers/routers/integration-webhook-routers.js +20 -7
- package/index.js +16 -9
- package/integrations/integration-base.js +64 -7
- package/modules/requester/requester.js +106 -5
- package/package.json +12 -5
- package/prisma-mongodb/schema.prisma +23 -0
- package/prisma-postgresql/migrations/20260705000000_create_usage_counter/migration.sql +26 -0
- package/prisma-postgresql/schema.prisma +22 -0
- package/reporting/README.md +8 -1
- package/reporting/reporting-router.js +8 -1
- package/reporting/use-cases/list-integrations-report.js +53 -6
- package/telemetry/README.md +331 -0
- package/telemetry/bind-telemetry-context.js +73 -0
- package/telemetry/canonical-counters.js +52 -0
- package/telemetry/exporters.js +85 -0
- package/telemetry/index.js +26 -0
- package/telemetry/instrument-handler.js +87 -0
- package/telemetry/no-op-telemetry.js +67 -0
- package/telemetry/north-star.js +103 -0
- package/telemetry/otel-telemetry.js +213 -0
- package/telemetry/plugin-subscribers.js +77 -0
- package/telemetry/telemetry-config.js +120 -0
- package/telemetry/telemetry-context.js +40 -0
- package/telemetry/telemetry-event-bus.js +58 -0
- package/telemetry/telemetry-runtime.js +147 -0
- package/telemetry/telemetry-service.js +51 -0
- package/telemetry/usage-rollup-subscriber.js +116 -0
- package/usage/README.md +54 -0
- package/usage/index.js +17 -0
- package/usage/repositories/usage-repository-documentdb.js +194 -0
- package/usage/repositories/usage-repository-factory.js +25 -0
- package/usage/repositories/usage-repository-interface.js +37 -0
- package/usage/repositories/usage-repository-prisma.js +146 -0
- package/usage/tracked-metrics.js +38 -0
- 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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
43
|
-
"@friggframework/prettier-config": "2.0.0-next.
|
|
44
|
-
"@friggframework/test": "2.0.0-next.
|
|
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": "
|
|
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
|
+
}
|
package/reporting/README.md
CHANGED
|
@@ -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 {
|
|
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({
|
|
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:
|
|
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 (
|
|
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 '${
|
|
116
|
-
|
|
117
|
-
)}`
|
|
162
|
+
`Invalid status '${
|
|
163
|
+
normalized.status
|
|
164
|
+
}'. Expected one of: ${KNOWN_STATUSES.join(', ')}`
|
|
118
165
|
);
|
|
119
166
|
}
|
|
120
167
|
return normalized;
|