@friggframework/core 2.0.0--canary.623.71305f2.0 → 2.0.0--canary.622.fe9da2d.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.
- 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 +65 -14
- package/modules/module.js +2 -15
- package/modules/requester/requester.js +102 -46
- 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
package/modules/module.js
CHANGED
|
@@ -147,24 +147,15 @@ class Module extends Delegate {
|
|
|
147
147
|
} else if (delegateString === this.api.DLGT_TOKEN_DEAUTHORIZED) {
|
|
148
148
|
await this.deauthorize();
|
|
149
149
|
} else if (delegateString === this.api.DLGT_INVALID_AUTH) {
|
|
150
|
-
await this.markCredentialsInvalid(
|
|
150
|
+
await this.markCredentialsInvalid();
|
|
151
151
|
}
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
-
async markCredentialsInvalid(
|
|
154
|
+
async markCredentialsInvalid() {
|
|
155
155
|
if (!this.credential) return;
|
|
156
156
|
|
|
157
157
|
if (!this.credential.id) return;
|
|
158
158
|
|
|
159
|
-
if (diagnosticInfo) {
|
|
160
|
-
console.error(
|
|
161
|
-
`[Frigg] Module ${this.name} credentials rejected (status ${
|
|
162
|
-
diagnosticInfo.statusCode ?? '?'
|
|
163
|
-
}):`,
|
|
164
|
-
diagnosticInfo.message ?? diagnosticInfo
|
|
165
|
-
);
|
|
166
|
-
}
|
|
167
|
-
|
|
168
159
|
await this.credentialRepository.updateAuthenticationStatus(
|
|
169
160
|
this.credential.id,
|
|
170
161
|
false
|
|
@@ -190,10 +181,6 @@ class Module extends Delegate {
|
|
|
190
181
|
await this.notify(this.DLGT_CREDENTIAL_INVALIDATED, {
|
|
191
182
|
credentialId: this.credential.id,
|
|
192
183
|
moduleName: this.name,
|
|
193
|
-
...(diagnosticInfo && {
|
|
194
|
-
reason: diagnosticInfo.message,
|
|
195
|
-
statusCode: diagnosticInfo.statusCode,
|
|
196
|
-
}),
|
|
197
184
|
});
|
|
198
185
|
} catch (err) {
|
|
199
186
|
console.error(
|
|
@@ -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
|
|
|
@@ -42,6 +43,40 @@ class Requester extends Delegate {
|
|
|
42
43
|
// Allow passing in the fetch function
|
|
43
44
|
// Instance methods can use this.fetch without differentiating
|
|
44
45
|
this.fetch = get(params, 'fetch', fetch);
|
|
46
|
+
|
|
47
|
+
// Defaults to the process singleton. Pass an integration's bound
|
|
48
|
+
// `this.telemetry` to attribute out-of-band requests — setup/OAuth calls
|
|
49
|
+
// made before an integration context exists aren't rolled up otherwise.
|
|
50
|
+
this.telemetry = (params && params.telemetry) || getTelemetry();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Redact secrets/PII from a URL before it touches telemetry. Many API
|
|
55
|
+
* modules embed credentials in the query string (?api_key=, ?token=,
|
|
56
|
+
* presigned signatures) or in userinfo — those must never reach a span,
|
|
57
|
+
* the bus, or an exporter. Keep only protocol + host + path (enough for
|
|
58
|
+
* North Star endpoint matching).
|
|
59
|
+
*/
|
|
60
|
+
_sanitizeUrl(url) {
|
|
61
|
+
const raw = String(url);
|
|
62
|
+
try {
|
|
63
|
+
const u = new URL(raw);
|
|
64
|
+
return `${u.protocol}//${u.host}${u.pathname}`;
|
|
65
|
+
} catch (_) {
|
|
66
|
+
// Relative/opaque URL: drop the query string at minimum.
|
|
67
|
+
return raw.split('?')[0];
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Bounded module label for the apimodule.requests metric. */
|
|
72
|
+
_telemetryModuleLabel() {
|
|
73
|
+
return (
|
|
74
|
+
this.moduleName ||
|
|
75
|
+
this.delegate?.name ||
|
|
76
|
+
this.delegate?.constructor?.name ||
|
|
77
|
+
this.constructor?.name ||
|
|
78
|
+
'unknown'
|
|
79
|
+
);
|
|
45
80
|
}
|
|
46
81
|
|
|
47
82
|
parsedBody = async (resp) => {
|
|
@@ -58,7 +93,66 @@ class Requester extends Delegate {
|
|
|
58
93
|
return resp.text();
|
|
59
94
|
};
|
|
60
95
|
|
|
61
|
-
|
|
96
|
+
/**
|
|
97
|
+
* Instrumenting entry point. Wraps the whole logical request —
|
|
98
|
+
* including retry/refresh recursion — in a single span + one
|
|
99
|
+
* `frigg.apimodule.requests` counter, emitted on the `i === 0` boundary so
|
|
100
|
+
* retries are never double-counted. The full URL rides the span only; the
|
|
101
|
+
* metric carries bounded labels {module, method, status} — never endpoint.
|
|
102
|
+
*/
|
|
103
|
+
async _request(url, options = {}, i = 0) {
|
|
104
|
+
if (i !== 0) {
|
|
105
|
+
return this._rawRequest(url, options, i);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const telemetry = this.telemetry;
|
|
109
|
+
if (!telemetry || typeof telemetry.span !== 'function') {
|
|
110
|
+
return this._rawRequest(url, options, 0);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const module = this._telemetryModuleLabel();
|
|
114
|
+
const method = (options.method || 'GET').toUpperCase();
|
|
115
|
+
const safeUrl = this._sanitizeUrl(url);
|
|
116
|
+
|
|
117
|
+
return telemetry.span('frigg.apimodule.request', async (span) => {
|
|
118
|
+
if (span && typeof span.setAttributes === 'function') {
|
|
119
|
+
span.setAttributes({
|
|
120
|
+
'frigg.module': module,
|
|
121
|
+
'http.request.method': method,
|
|
122
|
+
// Redacted (no query/userinfo) — never emit raw URLs.
|
|
123
|
+
'url.path': safeUrl,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
// Redacted url (unbounded) rides the bus-only context for North Star
|
|
127
|
+
// derived-from-trace matching — never a metric label.
|
|
128
|
+
const busContext = { url: safeUrl };
|
|
129
|
+
try {
|
|
130
|
+
const result = await this._rawRequest(url, options, 0);
|
|
131
|
+
telemetry.count(
|
|
132
|
+
'frigg.apimodule.requests',
|
|
133
|
+
1,
|
|
134
|
+
{ module, method, status: 'ok' },
|
|
135
|
+
busContext
|
|
136
|
+
);
|
|
137
|
+
return result;
|
|
138
|
+
} catch (err) {
|
|
139
|
+
const code = err?.status ?? err?.statusCode;
|
|
140
|
+
const status = code ? String(code) : 'error';
|
|
141
|
+
if (span && typeof span.setAttribute === 'function' && code) {
|
|
142
|
+
span.setAttribute('http.response.status_code', code);
|
|
143
|
+
}
|
|
144
|
+
telemetry.count(
|
|
145
|
+
'frigg.apimodule.requests',
|
|
146
|
+
1,
|
|
147
|
+
{ module, method, status },
|
|
148
|
+
busContext
|
|
149
|
+
);
|
|
150
|
+
throw err;
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async _rawRequest(url, options, i = 0) {
|
|
62
156
|
let encodedUrl = encodeURI(url);
|
|
63
157
|
if (options.query) {
|
|
64
158
|
let queryBuild = '?';
|
|
@@ -120,7 +214,7 @@ class Requester extends Delegate {
|
|
|
120
214
|
clearRequestTimer();
|
|
121
215
|
const delay = this.backOff[i] * 1000;
|
|
122
216
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
123
|
-
return this.
|
|
217
|
+
return this._rawRequest(url, options, i + 1);
|
|
124
218
|
}
|
|
125
219
|
const fetchError = await FetchError.create({
|
|
126
220
|
resource: encodedUrl,
|
|
@@ -147,32 +241,13 @@ class Requester extends Delegate {
|
|
|
147
241
|
clearRequestTimer();
|
|
148
242
|
const delay = this.backOff[i] * 1000;
|
|
149
243
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
150
|
-
return this.
|
|
244
|
+
return this._rawRequest(url, options, i + 1);
|
|
151
245
|
}
|
|
152
246
|
|
|
153
247
|
if (status === 401) {
|
|
154
248
|
if (!this.isRefreshable) {
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
// 401 is not necessarily proof the credential itself is
|
|
158
|
-
// bad — it may be a transient edge/gateway hiccup. Grant
|
|
159
|
-
// one grace retry of the same request before concluding
|
|
160
|
-
// the credential is truly invalid, mirroring the single
|
|
161
|
-
// refresh attempt OAuth2Requester gets below.
|
|
162
|
-
if (i === 0) {
|
|
163
|
-
clearRequestTimer();
|
|
164
|
-
const delay = this.backOff[0] * 1000;
|
|
165
|
-
await new Promise((resolve) =>
|
|
166
|
-
setTimeout(resolve, delay)
|
|
167
|
-
);
|
|
168
|
-
return this._request(url, options, i + 1);
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
throw await this._invalidateAuth(
|
|
172
|
-
encodedUrl,
|
|
173
|
-
options,
|
|
174
|
-
response
|
|
175
|
-
);
|
|
249
|
+
await this.notify(this.DLGT_INVALID_AUTH);
|
|
250
|
+
return;
|
|
176
251
|
}
|
|
177
252
|
|
|
178
253
|
if (this.refreshCount === 0) {
|
|
@@ -180,14 +255,11 @@ class Requester extends Delegate {
|
|
|
180
255
|
const refreshSucceeded = await this.refreshAuth();
|
|
181
256
|
if (refreshSucceeded) {
|
|
182
257
|
clearRequestTimer();
|
|
183
|
-
return this.
|
|
258
|
+
return this._rawRequest(url, options, i + 1);
|
|
184
259
|
}
|
|
185
260
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
options,
|
|
189
|
-
response
|
|
190
|
-
);
|
|
261
|
+
await this.notify(this.DLGT_INVALID_AUTH);
|
|
262
|
+
return;
|
|
191
263
|
}
|
|
192
264
|
}
|
|
193
265
|
|
|
@@ -226,22 +298,6 @@ class Requester extends Delegate {
|
|
|
226
298
|
}
|
|
227
299
|
}
|
|
228
300
|
|
|
229
|
-
// Builds a diagnostic FetchError from the failed 401 response, notifies
|
|
230
|
-
// delegates with it attached (so the credential-invalidation chain can
|
|
231
|
-
// log real detail instead of a bare "invalid credentials" with no
|
|
232
|
-
// evidence), and returns the error for the caller to throw. Notifying
|
|
233
|
-
// and throwing are split so notify() always fires even though `await`ing
|
|
234
|
-
// it happens before the throw.
|
|
235
|
-
async _invalidateAuth(encodedUrl, options, response) {
|
|
236
|
-
const fetchError = await FetchError.create({
|
|
237
|
-
resource: encodedUrl,
|
|
238
|
-
init: options,
|
|
239
|
-
response,
|
|
240
|
-
});
|
|
241
|
-
await this.notify(this.DLGT_INVALID_AUTH, fetchError);
|
|
242
|
-
return fetchError;
|
|
243
|
-
}
|
|
244
|
-
|
|
245
301
|
_maybeFlagTimeoutDuringBodyRead(err, timeoutMs) {
|
|
246
302
|
if (!err || typeof err !== 'object') return err;
|
|
247
303
|
if (err.isTimeout) return err;
|
package/package.json
CHANGED
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@friggframework/core",
|
|
3
3
|
"prettier": "@friggframework/prettier-config",
|
|
4
|
-
"version": "2.0.0--canary.
|
|
4
|
+
"version": "2.0.0--canary.622.fe9da2d.0",
|
|
5
5
|
"dependencies": {
|
|
6
6
|
"@aws-sdk/client-apigatewaymanagementapi": "^3.588.0",
|
|
7
7
|
"@aws-sdk/client-kms": "^3.588.0",
|
|
8
8
|
"@aws-sdk/client-lambda": "^3.714.0",
|
|
9
9
|
"@aws-sdk/client-sqs": "^3.588.0",
|
|
10
10
|
"@hapi/boom": "^10.0.1",
|
|
11
|
+
"@opentelemetry/api": "^1.9.1",
|
|
12
|
+
"@opentelemetry/context-async-hooks": "^2.9.0",
|
|
13
|
+
"@opentelemetry/exporter-metrics-otlp-http": "^0.220.0",
|
|
14
|
+
"@opentelemetry/exporter-trace-otlp-http": "^0.220.0",
|
|
15
|
+
"@opentelemetry/resources": "^2.9.0",
|
|
16
|
+
"@opentelemetry/sdk-metrics": "^2.9.0",
|
|
17
|
+
"@opentelemetry/sdk-trace-base": "^2.9.0",
|
|
11
18
|
"bcryptjs": "^2.4.3",
|
|
12
19
|
"body-parser": "^1.20.5",
|
|
13
20
|
"bson": "^4.7.2",
|
|
@@ -38,9 +45,9 @@
|
|
|
38
45
|
}
|
|
39
46
|
},
|
|
40
47
|
"devDependencies": {
|
|
41
|
-
"@friggframework/eslint-config": "2.0.0--canary.
|
|
42
|
-
"@friggframework/prettier-config": "2.0.0--canary.
|
|
43
|
-
"@friggframework/test": "2.0.0--canary.
|
|
48
|
+
"@friggframework/eslint-config": "2.0.0--canary.622.fe9da2d.0",
|
|
49
|
+
"@friggframework/prettier-config": "2.0.0--canary.622.fe9da2d.0",
|
|
50
|
+
"@friggframework/test": "2.0.0--canary.622.fe9da2d.0",
|
|
44
51
|
"@prisma/client": "^6.19.3",
|
|
45
52
|
"@types/lodash": "4.17.15",
|
|
46
53
|
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
|
@@ -80,5 +87,5 @@
|
|
|
80
87
|
"publishConfig": {
|
|
81
88
|
"access": "public"
|
|
82
89
|
},
|
|
83
|
-
"gitHead": "
|
|
90
|
+
"gitHead": "fe9da2d26354e6118a6f14350cdf18394408b3a4"
|
|
84
91
|
}
|
|
@@ -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;
|