@friggframework/core 2.0.0--canary.622.16ee19d.0 → 2.0.0--canary.622.faffe5f.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/application/commands/usage-commands.js +5 -4
- package/application/index.js +9 -18
- package/core/create-handler.js +17 -6
- package/generated/prisma-mongodb/edge.js +3 -3
- package/generated/prisma-mongodb/index.d.ts +80 -55
- package/generated/prisma-mongodb/index.js +3 -3
- package/generated/prisma-mongodb/package.json +1 -1
- package/generated/prisma-mongodb/schema.prisma +5 -3
- package/generated/prisma-mongodb/wasm.js +3 -3
- package/generated/prisma-postgresql/edge.js +3 -3
- package/generated/prisma-postgresql/index.d.ts +92 -16
- package/generated/prisma-postgresql/index.js +3 -3
- package/generated/prisma-postgresql/package.json +1 -1
- package/generated/prisma-postgresql/schema.prisma +5 -3
- package/generated/prisma-postgresql/wasm.js +3 -3
- package/handlers/integration-event-dispatcher.js +1 -1
- package/index.js +1 -1
- package/integrations/integration-base.js +23 -4
- package/modules/requester/requester.js +12 -2
- package/package.json +5 -5
- package/prisma-mongodb/schema.prisma +5 -3
- package/prisma-postgresql/migrations/20260705000000_create_usage_counter/migration.sql +3 -3
- package/prisma-postgresql/schema.prisma +5 -3
- package/reporting/reporting-router.js +1 -1
- package/reporting/use-cases/list-integrations-report.js +2 -2
- package/telemetry/README.md +22 -6
- package/telemetry/bind-telemetry-context.js +1 -1
- package/telemetry/canonical-counters.js +1 -1
- package/telemetry/exporters/exporter-factory.js +2 -2
- package/telemetry/instrument-handler.js +1 -1
- package/telemetry/no-op-telemetry.js +1 -1
- package/telemetry/north-star.js +1 -1
- package/telemetry/otel-telemetry.js +1 -1
- package/telemetry/plugin-subscribers-singleton.js +2 -2
- package/telemetry/plugin-subscribers.js +1 -1
- package/telemetry/telemetry-config.js +1 -1
- package/telemetry/telemetry-context.js +1 -1
- package/telemetry/telemetry-event-bus.js +2 -2
- package/telemetry/telemetry-service.js +3 -3
- package/telemetry/telemetry-singleton.js +1 -1
- package/telemetry/usage-rollup-singleton.js +1 -1
- package/telemetry/usage-rollup-subscriber.js +3 -3
- package/telemetry/usage-windows.js +1 -1
- package/usage/README.md +6 -4
- package/usage/repositories/usage-repository-documentdb.js +193 -9
- package/usage/repositories/usage-repository-interface.js +1 -1
- package/usage/repositories/usage-repository-postgres.js +22 -12
- package/usage/tracked-metrics.js +1 -1
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
const { createUsageRollupSubscriber } = require('./usage-rollup-subscriber');
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Process-wide usage-rollup subscriber
|
|
4
|
+
* Process-wide usage-rollup subscriber. Built once per cold start,
|
|
5
5
|
* subscribed to the telemetry singleton's bus, and reused across invocations.
|
|
6
6
|
* Returns null when usage is disabled (no integration declares Definition.usage)
|
|
7
7
|
* or when the app definition can't be loaded — the handler then skips the flush.
|
|
@@ -3,8 +3,8 @@ const { computeUsageWindows } = require('./usage-windows');
|
|
|
3
3
|
const WEBHOOK_EVENT_NAMES = new Set(['ON_WEBHOOK']);
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* Framework auto-signal metric names → the canonical usage key they feed
|
|
7
|
-
*
|
|
6
|
+
* Framework auto-signal metric names → the canonical usage key they feed.
|
|
7
|
+
* Resolvers receive (attributes, context) and may return null to
|
|
8
8
|
* decline. Handler invocations map by event: USER_ACTION → user_actions; the
|
|
9
9
|
* DB-connected `ON_WEBHOOK` queue dispatch → webhooks.received (per-integration,
|
|
10
10
|
* and where a durable write is actually possible — the HTTP receipt handler is
|
|
@@ -22,7 +22,7 @@ const METRIC_TO_CANONICAL = {
|
|
|
22
22
|
};
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
|
-
* The built-in usage-rollup subscriber
|
|
25
|
+
* The built-in usage-rollup subscriber. Subscribes to the
|
|
26
26
|
* telemetry event bus and folds *declared* counters (canonical or custom) into
|
|
27
27
|
* the durable usage store. It buffers within an invocation and writes on
|
|
28
28
|
* `flush()` (no timers — Lambda-safe), or drops the buffer on `discard()` for an
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Compute the rollup window keys a usage event falls into
|
|
2
|
+
* Compute the rollup window keys a usage event falls into.
|
|
3
3
|
* Windows are UTC and prefixed by their granularity so the store can filter a
|
|
4
4
|
* series by bucket (`window startsWith 'day:'`).
|
|
5
5
|
*
|
package/usage/README.md
CHANGED
|
@@ -46,7 +46,9 @@ class UsageRepositoryInterface {
|
|
|
46
46
|
- **`series`** aggregates across integration instances (`groupBy(window) + sum`)
|
|
47
47
|
and range-filters on the window key. Requires an `integrationType`.
|
|
48
48
|
|
|
49
|
-
> **DocumentDB:** the adapter
|
|
50
|
-
>
|
|
51
|
-
>
|
|
52
|
-
>
|
|
49
|
+
> **DocumentDB:** the adapter overrides increment/totals/series with raw commands
|
|
50
|
+
> (`$runCommandRaw`: a `$inc` upsert via `documentdb-utils.updateOne`, and a
|
|
51
|
+
> cursor-drained `$aggregate` `$group/$sum`) — matching every other DocumentDB
|
|
52
|
+
> adapter, since Prisma's Mongo engine emits upsert/groupBy shapes DocumentDB
|
|
53
|
+
> rejects and cursor reads truncate at ~101 docs. Command shapes are unit-tested;
|
|
54
|
+
> run an end-to-end check against a real cluster before GA.
|
|
@@ -1,15 +1,199 @@
|
|
|
1
|
+
const { prisma } = require('../../database/prisma');
|
|
2
|
+
const { updateOne } = require('../../database/documentdb-utils');
|
|
1
3
|
const { UsageRepositoryMongo } = require('./usage-repository-mongo');
|
|
2
4
|
|
|
5
|
+
const COLLECTION = 'UsageCounter';
|
|
6
|
+
const DRAIN_BATCH_SIZE = 1000;
|
|
7
|
+
const MAX_BATCHES = 100000;
|
|
8
|
+
const VALID_GROUP_BY = new Set(['integrationType', 'metric']);
|
|
9
|
+
const VALID_BUCKETS = new Set(['day', 'hour']);
|
|
10
|
+
|
|
3
11
|
/**
|
|
4
|
-
* DocumentDB usage store.
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* raw-command override here only.
|
|
12
|
+
* DocumentDB usage store. Amazon DocumentDB does not accept the command shapes
|
|
13
|
+
* Prisma's Mongo engine emits for `upsert({ update: { value: { increment } } })`
|
|
14
|
+
* and `groupBy({ _sum })`, and cursor reads truncate at ~101 docs — so, like
|
|
15
|
+
* every other DocumentDB adapter in this repo, these operations are issued as
|
|
16
|
+
* raw commands (`$runCommandRaw`) via the validated documentdb-utils helpers and
|
|
17
|
+
* a drained aggregate cursor. No timestamps are managed: totals/series filter on
|
|
18
|
+
* the window KEY (not write-time), so createdAt/updatedAt are unnecessary here.
|
|
12
19
|
*/
|
|
13
|
-
class UsageRepositoryDocumentDB extends UsageRepositoryMongo {
|
|
20
|
+
class UsageRepositoryDocumentDB extends UsageRepositoryMongo {
|
|
21
|
+
constructor() {
|
|
22
|
+
super();
|
|
23
|
+
this.prisma = prisma;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async increment({ integrationId, integrationType, metric, window, value = 1 }) {
|
|
27
|
+
// Atomic upsert-increment: $inc creates the field at the increment value
|
|
28
|
+
// on insert; $setOnInsert stamps the identity on first write. The compound
|
|
29
|
+
// filter is the unique key, so concurrent writers converge on one row.
|
|
30
|
+
await updateOne(
|
|
31
|
+
this.prisma,
|
|
32
|
+
COLLECTION,
|
|
33
|
+
{ integrationId, integrationType, metric, window },
|
|
34
|
+
{
|
|
35
|
+
$inc: { value },
|
|
36
|
+
$setOnInsert: {
|
|
37
|
+
integrationId,
|
|
38
|
+
integrationType,
|
|
39
|
+
metric,
|
|
40
|
+
window,
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
{ upsert: true }
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async totals({
|
|
48
|
+
metric,
|
|
49
|
+
groupBy = 'integrationType',
|
|
50
|
+
since,
|
|
51
|
+
bucket = 'day',
|
|
52
|
+
} = {}) {
|
|
53
|
+
if (!metric) {
|
|
54
|
+
throw new Error('totals requires a metric (units are per-metric)');
|
|
55
|
+
}
|
|
56
|
+
assertGroupBy(groupBy);
|
|
57
|
+
assertBucket(bucket);
|
|
58
|
+
|
|
59
|
+
// Single window granularity (day: OR hour:) — never sum across both.
|
|
60
|
+
// `since` bounds on the window KEY (mirrors series / the Prisma adapter).
|
|
61
|
+
const rows = await this._aggregateDrained([
|
|
62
|
+
{
|
|
63
|
+
$match: {
|
|
64
|
+
metric,
|
|
65
|
+
window: windowMatch(bucket, {
|
|
66
|
+
gte: since ? windowKey(bucket, since) : undefined,
|
|
67
|
+
}),
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
{ $group: { _id: `$${groupBy}`, value: { $sum: '$value' } } },
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
return rows.map((row) => ({
|
|
74
|
+
[groupBy]: row._id,
|
|
75
|
+
value: toNumber(row.value),
|
|
76
|
+
}));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async series({ metric, integrationType, from, to, bucket = 'day' } = {}) {
|
|
80
|
+
assertBucket(bucket);
|
|
81
|
+
if (!integrationType) {
|
|
82
|
+
throw new Error('series requires an integrationType');
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const rows = await this._aggregateDrained([
|
|
86
|
+
{
|
|
87
|
+
$match: {
|
|
88
|
+
metric,
|
|
89
|
+
integrationType,
|
|
90
|
+
window: windowMatch(bucket, {
|
|
91
|
+
gte: from ? windowKey(bucket, from) : undefined,
|
|
92
|
+
lte: to ? windowKey(bucket, to) : undefined,
|
|
93
|
+
}),
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
{ $group: { _id: '$window', value: { $sum: '$value' } } },
|
|
97
|
+
{ $sort: { _id: 1 } },
|
|
98
|
+
]);
|
|
99
|
+
|
|
100
|
+
return rows.map((row) => ({
|
|
101
|
+
bucket: row._id,
|
|
102
|
+
value: toNumber(row.value),
|
|
103
|
+
}));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async _aggregateDrained(pipeline) {
|
|
107
|
+
const first = await this.prisma.$runCommandRaw({
|
|
108
|
+
aggregate: COLLECTION,
|
|
109
|
+
pipeline,
|
|
110
|
+
cursor: { batchSize: DRAIN_BATCH_SIZE },
|
|
111
|
+
});
|
|
112
|
+
return drainCursor(this.prisma, COLLECTION, first);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** A `$match` window clause: prefix by granularity, optionally range-bounded. */
|
|
117
|
+
function windowMatch(bucket, { gte, lte } = {}) {
|
|
118
|
+
const clause = { $regex: `^${bucket}:` };
|
|
119
|
+
if (gte) clause.$gte = gte;
|
|
120
|
+
if (lte) clause.$lte = lte;
|
|
121
|
+
return clause;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Window key for a date at a granularity (mirrors telemetry/usage-windows). */
|
|
125
|
+
function windowKey(bucket, date) {
|
|
126
|
+
const iso = new Date(date).toISOString();
|
|
127
|
+
return `${bucket}:${bucket === 'hour' ? iso.slice(0, 13) : iso.slice(0, 10)}`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Coerce an aggregate `$sum` result to a JS Number. Prisma $runCommandRaw returns
|
|
132
|
+
* extended JSON, so a 64-bit sum can arrive as { $numberLong: "..." } (or
|
|
133
|
+
* $numberInt/$numberDouble); counts never approach 2^53.
|
|
134
|
+
*/
|
|
135
|
+
function toNumber(value) {
|
|
136
|
+
if (value === null || value === undefined) return 0;
|
|
137
|
+
if (typeof value === 'number') return value;
|
|
138
|
+
if (typeof value === 'bigint') return Number(value);
|
|
139
|
+
if (typeof value === 'object') {
|
|
140
|
+
const raw =
|
|
141
|
+
value.$numberLong ?? value.$numberInt ?? value.$numberDouble;
|
|
142
|
+
if (raw !== undefined) return Number(raw);
|
|
143
|
+
}
|
|
144
|
+
return Number(value) || 0;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function drainCursor(client, collection, firstResult) {
|
|
148
|
+
const cursor = firstResult?.cursor || {};
|
|
149
|
+
const docs = [...(cursor.firstBatch || [])];
|
|
150
|
+
let cursorId = cursor.id;
|
|
151
|
+
let batches = 0;
|
|
152
|
+
|
|
153
|
+
while (isCursorOpen(cursorId) && batches < MAX_BATCHES) {
|
|
154
|
+
batches += 1;
|
|
155
|
+
const next = await client.$runCommandRaw({
|
|
156
|
+
getMore: cursorId,
|
|
157
|
+
collection,
|
|
158
|
+
batchSize: DRAIN_BATCH_SIZE,
|
|
159
|
+
});
|
|
160
|
+
const nextCursor = next?.cursor || {};
|
|
161
|
+
const nextBatch = nextCursor.nextBatch || [];
|
|
162
|
+
docs.push(...nextBatch);
|
|
163
|
+
cursorId = nextCursor.id;
|
|
164
|
+
if (nextBatch.length === 0) break;
|
|
165
|
+
}
|
|
166
|
+
return docs;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function isCursorOpen(id) {
|
|
170
|
+
if (id === undefined || id === null) return false;
|
|
171
|
+
if (typeof id === 'number') return id !== 0;
|
|
172
|
+
if (typeof id === 'bigint') return id !== 0n;
|
|
173
|
+
if (typeof id === 'object' && id.$numberLong !== undefined) {
|
|
174
|
+
return id.$numberLong !== '0';
|
|
175
|
+
}
|
|
176
|
+
return String(id) !== '0';
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function assertGroupBy(groupBy) {
|
|
180
|
+
if (!VALID_GROUP_BY.has(groupBy)) {
|
|
181
|
+
throw new Error(
|
|
182
|
+
`Invalid groupBy "${groupBy}". Allowed: ${[...VALID_GROUP_BY].join(
|
|
183
|
+
', '
|
|
184
|
+
)}`
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function assertBucket(bucket) {
|
|
190
|
+
if (!VALID_BUCKETS.has(bucket)) {
|
|
191
|
+
throw new Error(
|
|
192
|
+
`Invalid bucket "${bucket}". Allowed: ${[...VALID_BUCKETS].join(
|
|
193
|
+
', '
|
|
194
|
+
)}`
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
14
198
|
|
|
15
199
|
module.exports = { UsageRepositoryDocumentDB };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Port for the durable usage-counter store
|
|
2
|
+
* Port for the durable usage-counter store.
|
|
3
3
|
*
|
|
4
4
|
* The store is deliberately isolated (ADR-010 Decision 3): its fact rows carry
|
|
5
5
|
* NO userId and NO foreign key to Integration, so a user-scoped query can never
|
|
@@ -2,7 +2,7 @@ const { prisma } = require('../../database/prisma');
|
|
|
2
2
|
const { UsageRepositoryInterface } = require('./usage-repository-interface');
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* Prisma-backed usage store
|
|
5
|
+
* Prisma-backed usage store. This is the canonical implementation;
|
|
6
6
|
* because Prisma abstracts the underlying database, the Mongo and DocumentDB
|
|
7
7
|
* adapters extend this class unchanged (see their files). All queries touch only
|
|
8
8
|
* the isolated `UsageCounter` model — never user/integration-scoped tables.
|
|
@@ -34,17 +34,21 @@ class UsageRepositoryPostgres extends UsageRepositoryInterface {
|
|
|
34
34
|
update: { value: { increment: value } },
|
|
35
35
|
};
|
|
36
36
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
37
|
+
// Concurrent first-insert race: two workers both INSERT and one hits the
|
|
38
|
+
// unique constraint (P2002). After the winner's insert the row exists, so
|
|
39
|
+
// a retry takes the atomic UPDATE (increment) path. Bounded so a
|
|
40
|
+
// pathological repeated race can't throw unexpectedly out of the public
|
|
41
|
+
// `recordUsageCounter` write; non-conflict errors surface immediately.
|
|
42
|
+
for (let attempt = 1; ; attempt++) {
|
|
43
|
+
try {
|
|
44
44
|
await this.prisma.usageCounter.upsert(upsertArgs);
|
|
45
45
|
return;
|
|
46
|
+
} catch (err) {
|
|
47
|
+
if (err && err.code === 'P2002' && attempt < MAX_UPSERT_ATTEMPTS) {
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
throw err;
|
|
46
51
|
}
|
|
47
|
-
throw err;
|
|
48
52
|
}
|
|
49
53
|
}
|
|
50
54
|
|
|
@@ -63,8 +67,11 @@ class UsageRepositoryPostgres extends UsageRepositoryInterface {
|
|
|
63
67
|
// Filter to ONE window granularity — every event is written to both a
|
|
64
68
|
// day: and an hour: row, so summing across granularities would double
|
|
65
69
|
// (or worse) the true count.
|
|
70
|
+
// Bound `since` on the WINDOW key (mirrors series) — write-time
|
|
71
|
+
// updatedAt would misplace a late/redelivered increment for an earlier
|
|
72
|
+
// window, over- or under-counting the time-bounded total.
|
|
66
73
|
const where = { metric, window: { startsWith: `${bucket}:` } };
|
|
67
|
-
if (since) where.
|
|
74
|
+
if (since) where.window.gte = windowKey(bucket, since);
|
|
68
75
|
|
|
69
76
|
const groups = await this.prisma.usageCounter.groupBy({
|
|
70
77
|
by: [groupBy],
|
|
@@ -72,9 +79,11 @@ class UsageRepositoryPostgres extends UsageRepositoryInterface {
|
|
|
72
79
|
_sum: { value: true },
|
|
73
80
|
});
|
|
74
81
|
|
|
82
|
+
// value is a BigInt column — coerce the sum to a JSON-safe Number
|
|
83
|
+
// (JSON.stringify throws on BigInt; counts never approach 2^53).
|
|
75
84
|
return groups.map((group) => ({
|
|
76
85
|
[groupBy]: group[groupBy],
|
|
77
|
-
value: group._sum?.value ?? 0,
|
|
86
|
+
value: Number(group._sum?.value ?? 0),
|
|
78
87
|
}));
|
|
79
88
|
}
|
|
80
89
|
|
|
@@ -103,11 +112,12 @@ class UsageRepositoryPostgres extends UsageRepositoryInterface {
|
|
|
103
112
|
|
|
104
113
|
return groups.map((group) => ({
|
|
105
114
|
bucket: group.window,
|
|
106
|
-
value: group._sum?.value ?? 0,
|
|
115
|
+
value: Number(group._sum?.value ?? 0),
|
|
107
116
|
}));
|
|
108
117
|
}
|
|
109
118
|
}
|
|
110
119
|
|
|
120
|
+
const MAX_UPSERT_ATTEMPTS = 3;
|
|
111
121
|
const VALID_GROUP_BY = new Set(['integrationType', 'metric']);
|
|
112
122
|
const VALID_BUCKETS = new Set(['day', 'hour']);
|
|
113
123
|
|
package/usage/tracked-metrics.js
CHANGED
|
@@ -2,7 +2,7 @@ const { isCanonicalCounter } = require('../telemetry/canonical-counters');
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Compute the set of usage-counter keys the rollup should persist, from each
|
|
5
|
-
* integration's `Definition.usage` opt-in
|
|
5
|
+
* integration's `Definition.usage` opt-in.
|
|
6
6
|
* Declaring a canonical key opts into cross-type comparison + the rollup; custom
|
|
7
7
|
* keys are tracked per integration type. Unknown canonical keys are dropped with
|
|
8
8
|
* a warning (lazy validation, matching how Definition is treated elsewhere).
|