@friggframework/core 2.0.0--canary.622.fe9da2d.0 → 2.0.0--canary.623.ee3bde8.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 +0 -40
- package/application/index.js +9 -10
- package/core/create-handler.js +10 -112
- package/generated/prisma-mongodb/edge.js +4 -16
- package/generated/prisma-mongodb/index-browser.js +1 -13
- package/generated/prisma-mongodb/index.d.ts +96 -1494
- package/generated/prisma-mongodb/index.js +4 -16
- package/generated/prisma-mongodb/package.json +1 -1
- package/generated/prisma-mongodb/schema.prisma +0 -23
- package/generated/prisma-mongodb/wasm.js +4 -16
- package/generated/prisma-postgresql/edge.js +4 -16
- package/generated/prisma-postgresql/index-browser.js +1 -13
- package/generated/prisma-postgresql/index.d.ts +91 -1540
- package/generated/prisma-postgresql/index.js +4 -16
- package/generated/prisma-postgresql/package.json +1 -1
- package/generated/prisma-postgresql/schema.prisma +0 -22
- package/generated/prisma-postgresql/wasm.js +4 -16
- package/handlers/app-definition-loader.js +3 -26
- package/handlers/integration-event-dispatcher.js +15 -29
- package/handlers/routers/integration-webhook-routers.js +7 -20
- package/index.js +9 -16
- package/integrations/integration-base.js +14 -65
- package/modules/module.js +15 -2
- package/modules/requester/requester.js +60 -105
- package/package.json +5 -12
- package/prisma-mongodb/schema.prisma +0 -23
- package/prisma-postgresql/schema.prisma +0 -22
- package/reporting/README.md +1 -8
- package/reporting/reporting-router.js +1 -8
- package/reporting/use-cases/list-integrations-report.js +6 -53
- package/application/commands/usage-commands.js +0 -56
- package/prisma-postgresql/migrations/20260705000000_create_usage_counter/migration.sql +0 -26
- package/telemetry/README.md +0 -331
- package/telemetry/bind-telemetry-context.js +0 -73
- package/telemetry/canonical-counters.js +0 -52
- package/telemetry/exporters.js +0 -85
- package/telemetry/index.js +0 -26
- package/telemetry/instrument-handler.js +0 -87
- package/telemetry/no-op-telemetry.js +0 -67
- package/telemetry/north-star.js +0 -103
- package/telemetry/otel-telemetry.js +0 -213
- package/telemetry/plugin-subscribers.js +0 -77
- package/telemetry/telemetry-config.js +0 -120
- package/telemetry/telemetry-context.js +0 -40
- package/telemetry/telemetry-event-bus.js +0 -58
- package/telemetry/telemetry-runtime.js +0 -147
- package/telemetry/telemetry-service.js +0 -51
- package/telemetry/usage-rollup-subscriber.js +0 -116
- package/usage/README.md +0 -54
- package/usage/index.js +0 -17
- package/usage/repositories/usage-repository-documentdb.js +0 -194
- package/usage/repositories/usage-repository-factory.js +0 -25
- package/usage/repositories/usage-repository-interface.js +0 -37
- package/usage/repositories/usage-repository-prisma.js +0 -146
- package/usage/tracked-metrics.js +0 -38
- package/usage/usage-windows.js +0 -24
package/modules/module.js
CHANGED
|
@@ -147,15 +147,24 @@ 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(object);
|
|
151
151
|
}
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
-
async markCredentialsInvalid() {
|
|
154
|
+
async markCredentialsInvalid(diagnosticInfo = null) {
|
|
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
|
+
|
|
159
168
|
await this.credentialRepository.updateAuthenticationStatus(
|
|
160
169
|
this.credential.id,
|
|
161
170
|
false
|
|
@@ -181,6 +190,10 @@ class Module extends Delegate {
|
|
|
181
190
|
await this.notify(this.DLGT_CREDENTIAL_INVALIDATED, {
|
|
182
191
|
credentialId: this.credential.id,
|
|
183
192
|
moduleName: this.name,
|
|
193
|
+
...(diagnosticInfo && {
|
|
194
|
+
reason: diagnosticInfo.message,
|
|
195
|
+
statusCode: diagnosticInfo.statusCode,
|
|
196
|
+
}),
|
|
184
197
|
});
|
|
185
198
|
} catch (err) {
|
|
186
199
|
console.error(
|
|
@@ -2,7 +2,6 @@ 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');
|
|
6
5
|
|
|
7
6
|
const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
|
|
8
7
|
|
|
@@ -12,6 +11,11 @@ class Requester extends Delegate {
|
|
|
12
11
|
this.backOff = get(params, 'backOff', [1, 3, 10, 30, 60, 180]);
|
|
13
12
|
this.isRefreshable = false;
|
|
14
13
|
this.refreshCount = 0;
|
|
14
|
+
// Independent from the `i` backoff-attempt counter passed through
|
|
15
|
+
// _request's recursion — a 429/5xx retry earlier in the same call
|
|
16
|
+
// already advances `i`, and gating the 401 grace retry on `i === 0`
|
|
17
|
+
// would skip it whenever a 401 follows any prior backoff response.
|
|
18
|
+
this.authGraceRetryCount = 0;
|
|
15
19
|
this.DLGT_INVALID_AUTH = 'INVALID_AUTH';
|
|
16
20
|
this.delegateTypes.push(this.DLGT_INVALID_AUTH);
|
|
17
21
|
this.agent = get(params, 'agent', null);
|
|
@@ -43,40 +47,6 @@ class Requester extends Delegate {
|
|
|
43
47
|
// Allow passing in the fetch function
|
|
44
48
|
// Instance methods can use this.fetch without differentiating
|
|
45
49
|
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
|
-
);
|
|
80
50
|
}
|
|
81
51
|
|
|
82
52
|
parsedBody = async (resp) => {
|
|
@@ -93,66 +63,7 @@ class Requester extends Delegate {
|
|
|
93
63
|
return resp.text();
|
|
94
64
|
};
|
|
95
65
|
|
|
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) {
|
|
66
|
+
async _request(url, options, i = 0) {
|
|
156
67
|
let encodedUrl = encodeURI(url);
|
|
157
68
|
if (options.query) {
|
|
158
69
|
let queryBuild = '?';
|
|
@@ -214,7 +125,7 @@ class Requester extends Delegate {
|
|
|
214
125
|
clearRequestTimer();
|
|
215
126
|
const delay = this.backOff[i] * 1000;
|
|
216
127
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
217
|
-
return this.
|
|
128
|
+
return this._request(url, options, i + 1);
|
|
218
129
|
}
|
|
219
130
|
const fetchError = await FetchError.create({
|
|
220
131
|
resource: encodedUrl,
|
|
@@ -241,13 +152,36 @@ class Requester extends Delegate {
|
|
|
241
152
|
clearRequestTimer();
|
|
242
153
|
const delay = this.backOff[i] * 1000;
|
|
243
154
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
244
|
-
return this.
|
|
155
|
+
return this._request(url, options, i + 1);
|
|
245
156
|
}
|
|
246
157
|
|
|
247
158
|
if (status === 401) {
|
|
248
159
|
if (!this.isRefreshable) {
|
|
249
|
-
|
|
250
|
-
|
|
160
|
+
// A non-refreshable requester (ApiKeyRequester,
|
|
161
|
+
// BasicAuthRequester) has no token to renew, but a single
|
|
162
|
+
// 401 is not necessarily proof the credential itself is
|
|
163
|
+
// bad — it may be a transient edge/gateway hiccup. Grant
|
|
164
|
+
// one grace retry of the same request before concluding
|
|
165
|
+
// the credential is truly invalid, mirroring the single
|
|
166
|
+
// refresh attempt OAuth2Requester gets below. Gated on
|
|
167
|
+
// authGraceRetryCount rather than `i` so an earlier
|
|
168
|
+
// 429/5xx backoff on the same call doesn't consume the
|
|
169
|
+
// grace retry before a 401 is even seen.
|
|
170
|
+
if (this.authGraceRetryCount === 0) {
|
|
171
|
+
this.authGraceRetryCount++;
|
|
172
|
+
clearRequestTimer();
|
|
173
|
+
const delay = this.backOff[0] * 1000;
|
|
174
|
+
await new Promise((resolve) =>
|
|
175
|
+
setTimeout(resolve, delay)
|
|
176
|
+
);
|
|
177
|
+
return this._request(url, options, i + 1);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
throw await this._invalidateAuth(
|
|
181
|
+
encodedUrl,
|
|
182
|
+
options,
|
|
183
|
+
response
|
|
184
|
+
);
|
|
251
185
|
}
|
|
252
186
|
|
|
253
187
|
if (this.refreshCount === 0) {
|
|
@@ -255,11 +189,14 @@ class Requester extends Delegate {
|
|
|
255
189
|
const refreshSucceeded = await this.refreshAuth();
|
|
256
190
|
if (refreshSucceeded) {
|
|
257
191
|
clearRequestTimer();
|
|
258
|
-
return this.
|
|
192
|
+
return this._request(url, options, i + 1);
|
|
259
193
|
}
|
|
260
194
|
|
|
261
|
-
await this.
|
|
262
|
-
|
|
195
|
+
throw await this._invalidateAuth(
|
|
196
|
+
encodedUrl,
|
|
197
|
+
options,
|
|
198
|
+
response
|
|
199
|
+
);
|
|
263
200
|
}
|
|
264
201
|
}
|
|
265
202
|
|
|
@@ -278,10 +215,12 @@ class Requester extends Delegate {
|
|
|
278
215
|
);
|
|
279
216
|
}
|
|
280
217
|
|
|
281
|
-
// Successful response: reset the per-instance refresh
|
|
282
|
-
// a later 401 in the same Requester lifetime can
|
|
283
|
-
// again instead of
|
|
218
|
+
// Successful response: reset the per-instance refresh/grace
|
|
219
|
+
// budgets so a later 401 in the same Requester lifetime can
|
|
220
|
+
// attempt refresh (or another grace retry) again instead of
|
|
221
|
+
// silently falling through.
|
|
284
222
|
this.refreshCount = 0;
|
|
223
|
+
this.authGraceRetryCount = 0;
|
|
285
224
|
|
|
286
225
|
// parsedBody consumes the response body stream. If the server
|
|
287
226
|
// stalls mid-stream the timer (still armed) aborts it.
|
|
@@ -298,6 +237,22 @@ class Requester extends Delegate {
|
|
|
298
237
|
}
|
|
299
238
|
}
|
|
300
239
|
|
|
240
|
+
// Builds a diagnostic FetchError from the failed 401 response, notifies
|
|
241
|
+
// delegates with it attached (so the credential-invalidation chain can
|
|
242
|
+
// log real detail instead of a bare "invalid credentials" with no
|
|
243
|
+
// evidence), and returns the error for the caller to throw. Notifying
|
|
244
|
+
// and throwing are split so notify() always fires even though `await`ing
|
|
245
|
+
// it happens before the throw.
|
|
246
|
+
async _invalidateAuth(encodedUrl, options, response) {
|
|
247
|
+
const fetchError = await FetchError.create({
|
|
248
|
+
resource: encodedUrl,
|
|
249
|
+
init: options,
|
|
250
|
+
response,
|
|
251
|
+
});
|
|
252
|
+
await this.notify(this.DLGT_INVALID_AUTH, fetchError);
|
|
253
|
+
return fetchError;
|
|
254
|
+
}
|
|
255
|
+
|
|
301
256
|
_maybeFlagTimeoutDuringBodyRead(err, timeoutMs) {
|
|
302
257
|
if (!err || typeof err !== 'object') return err;
|
|
303
258
|
if (err.isTimeout) return err;
|
package/package.json
CHANGED
|
@@ -1,20 +1,13 @@
|
|
|
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.623.ee3bde8.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",
|
|
18
11
|
"bcryptjs": "^2.4.3",
|
|
19
12
|
"body-parser": "^1.20.5",
|
|
20
13
|
"bson": "^4.7.2",
|
|
@@ -45,9 +38,9 @@
|
|
|
45
38
|
}
|
|
46
39
|
},
|
|
47
40
|
"devDependencies": {
|
|
48
|
-
"@friggframework/eslint-config": "2.0.0--canary.
|
|
49
|
-
"@friggframework/prettier-config": "2.0.0--canary.
|
|
50
|
-
"@friggframework/test": "2.0.0--canary.
|
|
41
|
+
"@friggframework/eslint-config": "2.0.0--canary.623.ee3bde8.0",
|
|
42
|
+
"@friggframework/prettier-config": "2.0.0--canary.623.ee3bde8.0",
|
|
43
|
+
"@friggframework/test": "2.0.0--canary.623.ee3bde8.0",
|
|
51
44
|
"@prisma/client": "^6.19.3",
|
|
52
45
|
"@types/lodash": "4.17.15",
|
|
53
46
|
"@typescript-eslint/eslint-plugin": "^8.0.0",
|
|
@@ -87,5 +80,5 @@
|
|
|
87
80
|
"publishConfig": {
|
|
88
81
|
"access": "public"
|
|
89
82
|
},
|
|
90
|
-
"gitHead": "
|
|
83
|
+
"gitHead": "ee3bde8d1adc7e90a424a26429ce4b4d612ff52f"
|
|
91
84
|
}
|
|
@@ -430,26 +430,3 @@ 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
|
-
}
|
|
@@ -412,25 +412,3 @@ 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,15 +52,8 @@ 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 }`.
|
|
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).
|
|
64
57
|
- `label` — human-readable name from the integration class's
|
|
65
58
|
`Definition.display.label`. Falls back to the `type` slug when no registered
|
|
66
59
|
class supplies a label (e.g. an integration that was removed, or run on an
|
|
@@ -3,12 +3,7 @@ const catchAsyncError = require('express-async-handler');
|
|
|
3
3
|
const {
|
|
4
4
|
createReportingRepository,
|
|
5
5
|
} = require('./repositories/reporting-repository-factory');
|
|
6
|
-
const {
|
|
7
|
-
createUsageRepository,
|
|
8
|
-
} = require('../usage/repositories/usage-repository-factory');
|
|
9
|
-
const {
|
|
10
|
-
ListIntegrationsReport,
|
|
11
|
-
} = require('./use-cases/list-integrations-report');
|
|
6
|
+
const { ListIntegrationsReport } = require('./use-cases/list-integrations-report');
|
|
12
7
|
const { loadAppDefinition } = require('../handlers/app-definition-loader');
|
|
13
8
|
|
|
14
9
|
// IntegrationBase.Definition default — skip it so the slug is used instead.
|
|
@@ -40,10 +35,8 @@ function buildTypeLabels() {
|
|
|
40
35
|
|
|
41
36
|
function createReportingRouter() {
|
|
42
37
|
const reportingRepository = createReportingRepository();
|
|
43
|
-
const usageRepository = createUsageRepository();
|
|
44
38
|
const listIntegrationsReport = new ListIntegrationsReport({
|
|
45
39
|
reportingRepository,
|
|
46
|
-
usageRepository,
|
|
47
40
|
typeLabels: buildTypeLabels(),
|
|
48
41
|
});
|
|
49
42
|
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
const Boom = require('@hapi/boom');
|
|
2
|
-
const { CANONICAL_COUNTERS } = require('../../telemetry/canonical-counters');
|
|
3
2
|
|
|
4
3
|
const SCHEMA_VERSION = 1;
|
|
5
4
|
const SERVICE = 'frigg-core-api';
|
|
@@ -17,19 +16,11 @@ const KNOWN_STATUSES = [
|
|
|
17
16
|
];
|
|
18
17
|
|
|
19
18
|
class ListIntegrationsReport {
|
|
20
|
-
constructor({
|
|
21
|
-
reportingRepository,
|
|
22
|
-
usageRepository,
|
|
23
|
-
typeLabels = {},
|
|
24
|
-
} = {}) {
|
|
19
|
+
constructor({ reportingRepository, typeLabels = {} } = {}) {
|
|
25
20
|
if (!reportingRepository) {
|
|
26
21
|
throw new Error('reportingRepository is required');
|
|
27
22
|
}
|
|
28
|
-
if (!usageRepository) {
|
|
29
|
-
throw new Error('usageRepository is required');
|
|
30
|
-
}
|
|
31
23
|
this.reportingRepository = reportingRepository;
|
|
32
|
-
this.usageRepository = usageRepository;
|
|
33
24
|
this.typeLabels = typeLabels;
|
|
34
25
|
}
|
|
35
26
|
|
|
@@ -76,8 +67,7 @@ class ListIntegrationsReport {
|
|
|
76
67
|
if (!byTypeMap.has(integration.type)) {
|
|
77
68
|
byTypeMap.set(integration.type, {
|
|
78
69
|
type: integration.type,
|
|
79
|
-
label:
|
|
80
|
-
this.typeLabels[integration.type] || integration.type,
|
|
70
|
+
label: this.typeLabels[integration.type] || integration.type,
|
|
81
71
|
total: 0,
|
|
82
72
|
byStatus: emptyStatusCounts(),
|
|
83
73
|
});
|
|
@@ -87,8 +77,6 @@ class ListIntegrationsReport {
|
|
|
87
77
|
bucket.byStatus[statusKey] = (bucket.byStatus[statusKey] ?? 0) + 1;
|
|
88
78
|
}
|
|
89
79
|
|
|
90
|
-
await this._attachUsageColumns(byTypeMap);
|
|
91
|
-
|
|
92
80
|
return {
|
|
93
81
|
schemaVersion: SCHEMA_VERSION,
|
|
94
82
|
service: SERVICE,
|
|
@@ -108,44 +96,9 @@ class ListIntegrationsReport {
|
|
|
108
96
|
};
|
|
109
97
|
}
|
|
110
98
|
|
|
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
|
-
|
|
142
99
|
_validateQuery({ status, type, userId } = {}) {
|
|
143
100
|
for (const [key, value] of Object.entries({ status, type, userId })) {
|
|
144
|
-
if (
|
|
145
|
-
value !== undefined &&
|
|
146
|
-
value !== null &&
|
|
147
|
-
typeof value !== 'string'
|
|
148
|
-
) {
|
|
101
|
+
if (value !== undefined && value !== null && typeof value !== 'string') {
|
|
149
102
|
throw Boom.badRequest(
|
|
150
103
|
`Invalid query parameter '${key}': expected a string`
|
|
151
104
|
);
|
|
@@ -159,9 +112,9 @@ class ListIntegrationsReport {
|
|
|
159
112
|
};
|
|
160
113
|
if (normalized.status && !KNOWN_STATUSES.includes(normalized.status)) {
|
|
161
114
|
throw Boom.badRequest(
|
|
162
|
-
`Invalid status '${
|
|
163
|
-
|
|
164
|
-
|
|
115
|
+
`Invalid status '${normalized.status}'. Expected one of: ${KNOWN_STATUSES.join(
|
|
116
|
+
', '
|
|
117
|
+
)}`
|
|
165
118
|
);
|
|
166
119
|
}
|
|
167
120
|
return normalized;
|
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
// `frigg.usage.*` — the read/write surface over the durable usage store.
|
|
2
|
-
const {
|
|
3
|
-
createUsageRepository,
|
|
4
|
-
} = require('../../usage/repositories/usage-repository-factory');
|
|
5
|
-
const { computeUsageWindows } = require('../../usage/usage-windows');
|
|
6
|
-
const { resolveNorthStarEntry } = require('../../telemetry/north-star');
|
|
7
|
-
|
|
8
|
-
function createUsageCommands({ usageRepository } = {}) {
|
|
9
|
-
const repository = usageRepository || createUsageRepository();
|
|
10
|
-
|
|
11
|
-
return {
|
|
12
|
-
// Increments both the day and hour window rows for `at`.
|
|
13
|
-
async recordUsageCounter({
|
|
14
|
-
integrationId,
|
|
15
|
-
integrationType,
|
|
16
|
-
metric,
|
|
17
|
-
value = 1,
|
|
18
|
-
at = new Date(),
|
|
19
|
-
}) {
|
|
20
|
-
const windows = computeUsageWindows(at);
|
|
21
|
-
for (const window of windows) {
|
|
22
|
-
await repository.increment({
|
|
23
|
-
integrationId,
|
|
24
|
-
integrationType,
|
|
25
|
-
metric,
|
|
26
|
-
window,
|
|
27
|
-
value,
|
|
28
|
-
});
|
|
29
|
-
}
|
|
30
|
-
},
|
|
31
|
-
|
|
32
|
-
async getTotalsByDimension(args) {
|
|
33
|
-
return repository.getTotalsByDimension(args);
|
|
34
|
-
},
|
|
35
|
-
|
|
36
|
-
async getTimeSeries(args) {
|
|
37
|
-
return repository.getTimeSeries(args);
|
|
38
|
-
},
|
|
39
|
-
|
|
40
|
-
// Caller supplies the North Star config (Definition.telemetry.northStar);
|
|
41
|
-
// resolves the counter for the type (byType > default), null if none.
|
|
42
|
-
async getNorthStarTotals({ northStar, integrationType, since, groupBy = 'integrationType', bucket } = {}) {
|
|
43
|
-
const entry = resolveNorthStarEntry(northStar, integrationType);
|
|
44
|
-
if (!entry) return null;
|
|
45
|
-
const totals = await repository.getTotalsByDimension({
|
|
46
|
-
metric: entry.name,
|
|
47
|
-
groupBy,
|
|
48
|
-
since,
|
|
49
|
-
bucket,
|
|
50
|
-
});
|
|
51
|
-
return { metric: entry.name, totals };
|
|
52
|
-
},
|
|
53
|
-
};
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
module.exports = { createUsageCommands };
|
|
@@ -1,26 +0,0 @@
|
|
|
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");
|