@friggframework/core 2.0.0--canary.622.faffe5f.0 → 2.0.0--canary.623.71305f2.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.
Files changed (59) hide show
  1. package/README.md +0 -40
  2. package/application/index.js +10 -15
  3. package/core/create-handler.js +10 -114
  4. package/generated/prisma-mongodb/edge.js +4 -16
  5. package/generated/prisma-mongodb/index-browser.js +1 -13
  6. package/generated/prisma-mongodb/index.d.ts +96 -1495
  7. package/generated/prisma-mongodb/index.js +4 -16
  8. package/generated/prisma-mongodb/package.json +1 -1
  9. package/generated/prisma-mongodb/schema.prisma +0 -24
  10. package/generated/prisma-mongodb/wasm.js +4 -16
  11. package/generated/prisma-postgresql/edge.js +4 -16
  12. package/generated/prisma-postgresql/index-browser.js +1 -13
  13. package/generated/prisma-postgresql/index.d.ts +91 -1541
  14. package/generated/prisma-postgresql/index.js +4 -16
  15. package/generated/prisma-postgresql/package.json +1 -1
  16. package/generated/prisma-postgresql/schema.prisma +0 -23
  17. package/generated/prisma-postgresql/wasm.js +4 -16
  18. package/handlers/app-definition-loader.js +3 -26
  19. package/handlers/integration-event-dispatcher.js +15 -34
  20. package/handlers/routers/integration-webhook-routers.js +7 -23
  21. package/index.js +9 -11
  22. package/integrations/integration-base.js +23 -88
  23. package/modules/module.js +15 -2
  24. package/modules/requester/requester.js +46 -111
  25. package/package.json +5 -12
  26. package/prisma-mongodb/schema.prisma +0 -24
  27. package/prisma-postgresql/schema.prisma +0 -23
  28. package/reporting/README.md +1 -8
  29. package/reporting/reporting-router.js +1 -16
  30. package/reporting/use-cases/list-integrations-report.js +6 -61
  31. package/application/commands/usage-commands.js +0 -77
  32. package/prisma-postgresql/migrations/20260705000000_create_usage_counter/migration.sql +0 -26
  33. package/telemetry/README.md +0 -317
  34. package/telemetry/bind-telemetry-context.js +0 -51
  35. package/telemetry/canonical-counters.js +0 -52
  36. package/telemetry/exporters/exporter-factory.js +0 -79
  37. package/telemetry/index.js +0 -56
  38. package/telemetry/instrument-handler.js +0 -82
  39. package/telemetry/no-op-telemetry.js +0 -66
  40. package/telemetry/north-star.js +0 -103
  41. package/telemetry/otel-telemetry.js +0 -215
  42. package/telemetry/plugin-subscribers-singleton.js +0 -51
  43. package/telemetry/plugin-subscribers.js +0 -77
  44. package/telemetry/telemetry-config.js +0 -118
  45. package/telemetry/telemetry-context.js +0 -40
  46. package/telemetry/telemetry-event-bus.js +0 -58
  47. package/telemetry/telemetry-service.js +0 -44
  48. package/telemetry/telemetry-singleton.js +0 -41
  49. package/telemetry/usage-rollup-singleton.js +0 -74
  50. package/telemetry/usage-rollup-subscriber.js +0 -116
  51. package/telemetry/usage-windows.js +0 -14
  52. package/usage/README.md +0 -54
  53. package/usage/index.js +0 -19
  54. package/usage/repositories/usage-repository-documentdb.js +0 -199
  55. package/usage/repositories/usage-repository-factory.js +0 -28
  56. package/usage/repositories/usage-repository-interface.js +0 -37
  57. package/usage/repositories/usage-repository-mongo.js +0 -12
  58. package/usage/repositories/usage-repository-postgres.js +0 -152
  59. package/usage/tracked-metrics.js +0 -38
@@ -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
23
  this.reportingRepository = reportingRepository;
29
- // Optional usage store (the ADR-010 hand-off). When present, the
30
- // report's byType buckets are enriched with feature-usage columns read
31
- // ONLY from this store — never an external APM.
32
- this.usageRepository = usageRepository || null;
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,13 +77,6 @@ class ListIntegrationsReport {
87
77
  bucket.byStatus[statusKey] = (bucket.byStatus[statusKey] ?? 0) + 1;
88
78
  }
89
79
 
90
- // Additive usage columns. Read per-type totals for each
91
- // canonical counter; a type with no rows reads as 0. Guarded so a usage
92
- // store failure never breaks the structural report.
93
- if (this.usageRepository) {
94
- await this._attachUsageColumns(byTypeMap);
95
- }
96
-
97
80
  return {
98
81
  schemaVersion: SCHEMA_VERSION,
99
82
  service: SERVICE,
@@ -113,47 +96,9 @@ class ListIntegrationsReport {
113
96
  };
114
97
  }
115
98
 
116
- async _attachUsageColumns(byTypeMap) {
117
- const metrics = Object.keys(CANONICAL_COUNTERS);
118
- try {
119
- const totalsByMetric = await Promise.all(
120
- metrics.map(async (metric) => {
121
- const totals = await this.usageRepository.totals({
122
- metric,
123
- groupBy: 'integrationType',
124
- });
125
- const map = new Map(
126
- (totals || []).map((t) => [t.integrationType, t.value])
127
- );
128
- return [metric, map];
129
- })
130
- );
131
-
132
- for (const bucket of byTypeMap.values()) {
133
- bucket.usage = {};
134
- for (const [metric, map] of totalsByMetric) {
135
- bucket.usage[metric] = map.get(bucket.type) ?? 0;
136
- }
137
- }
138
- } catch (error) {
139
- // Usage is a supplement; a read failure must not fail the report,
140
- // but it is worth surfacing (silent all-zeros usage columns hide
141
- // data-quality issues).
142
- console.warn(
143
- `[Frigg][reporting] usage columns unavailable: ${
144
- error && error.message
145
- }`
146
- );
147
- }
148
- }
149
-
150
99
  _validateQuery({ status, type, userId } = {}) {
151
100
  for (const [key, value] of Object.entries({ status, type, userId })) {
152
- if (
153
- value !== undefined &&
154
- value !== null &&
155
- typeof value !== 'string'
156
- ) {
101
+ if (value !== undefined && value !== null && typeof value !== 'string') {
157
102
  throw Boom.badRequest(
158
103
  `Invalid query parameter '${key}': expected a string`
159
104
  );
@@ -167,9 +112,9 @@ class ListIntegrationsReport {
167
112
  };
168
113
  if (normalized.status && !KNOWN_STATUSES.includes(normalized.status)) {
169
114
  throw Boom.badRequest(
170
- `Invalid status '${
171
- normalized.status
172
- }'. Expected one of: ${KNOWN_STATUSES.join(', ')}`
115
+ `Invalid status '${normalized.status}'. Expected one of: ${KNOWN_STATUSES.join(
116
+ ', '
117
+ )}`
173
118
  );
174
119
  }
175
120
  return normalized;
@@ -1,77 +0,0 @@
1
- /**
2
- * Usage Commands
3
- *
4
- * Application Layer — the read/write surface over the durable usage store.
5
- * Exposed as `frigg.usage.*` on the unified command object so
6
- * reports and integration code query usage without touching the repository.
7
- *
8
- * @example
9
- * const frigg = createFriggCommands({ integrationClass: MyIntegration });
10
- * await frigg.usage.totals({ metric: 'records.synced', groupBy: 'integrationType', since });
11
- * await frigg.usage.series({ metric: 'records.synced', integrationType: 'hubspot', bucket: 'day' });
12
- */
13
- const {
14
- createUsageRepository,
15
- } = require('../../usage/repositories/usage-repository-factory');
16
- const { computeUsageWindows } = require('../../telemetry/usage-windows');
17
- const { resolveNorthStarEntry } = require('../../telemetry/north-star');
18
-
19
- function createUsageCommands({ usageRepository, northStar = null } = {}) {
20
- const repository = usageRepository || createUsageRepository();
21
-
22
- return {
23
- /**
24
- * Record a usage counter for a point in time. Callers pass `at` (a Date,
25
- * default now) — NOT a raw window key — and both the day and hour windows
26
- * are derived, matching how the auto-rollup persists so series() reads
27
- * back consistently at either granularity.
28
- */
29
- async recordUsageCounter({
30
- integrationId,
31
- integrationType,
32
- metric,
33
- value = 1,
34
- at = new Date(),
35
- }) {
36
- const windows = computeUsageWindows(at);
37
- for (const window of windows) {
38
- await repository.increment({
39
- integrationId,
40
- integrationType,
41
- metric,
42
- window,
43
- value,
44
- });
45
- }
46
- },
47
-
48
- async totals(args) {
49
- return repository.totals(args);
50
- },
51
-
52
- async series(args) {
53
- return repository.series(args);
54
- },
55
-
56
- /**
57
- * First-class North Star read. Resolves the
58
- * configured counter for an integration type (byType wins over default),
59
- * then returns its totals from the durable usage store. Returns `null`
60
- * when no North Star is configured, so callers can branch without
61
- * knowing the counter key. Trends read via `series({ metric })`.
62
- */
63
- async northStar({ integrationType, since, groupBy = 'integrationType', bucket } = {}) {
64
- const entry = resolveNorthStarEntry(northStar, integrationType);
65
- if (!entry) return null;
66
- const totals = await repository.totals({
67
- metric: entry.name,
68
- groupBy,
69
- since,
70
- bucket,
71
- });
72
- return { metric: entry.name, totals };
73
- },
74
- };
75
- }
76
-
77
- module.exports = { createUsageCommands };
@@ -1,26 +0,0 @@
1
- -- CreateTable
2
- -- ADR-011 durable usage counters. Deliberately isolated from the
3
- -- user/integration-scoped tables (no userId, no FK to Integration) so usage
4
- -- history survives integration deletion and can never surface in a user-scoped
5
- -- query. "window" is quoted throughout — 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");
@@ -1,317 +0,0 @@
1
- # Frigg Telemetry & Usage Tracking
2
-
3
- Vendor-neutral observability (traces + metrics) and durable, per-integration
4
- feature-usage counters for `@friggframework/core`, built on OpenTelemetry.
5
- Implements [ADR-011](../../../docs/architecture-decisions/011-integration-telemetry-and-usage-tracking.md).
6
-
7
- ## Overview
8
-
9
- Two capabilities ride the same primitive:
10
-
11
- 1. **Observability** — spans + low-cardinality metrics across handlers, API
12
- modules, queues and webhooks, per integration, exported to any OTLP backend
13
- (Honeycomb, Datadog, Grafana, an OTel Collector, …).
14
- 2. **Usage tracking** — durable per-integration counters (records synced,
15
- webhooks received, API requests, user actions, …) folded into a Frigg-owned
16
- store that the reporting endpoint reads for apples-to-apples comparison.
17
-
18
- ### Key properties
19
-
20
- - **Rides for free.** Framework seams are auto-instrumented — integrations get
21
- handler/API-module/webhook metrics with zero code.
22
- - **No-op by default.** With no exporter configured the service emits nothing and
23
- loads **zero** OpenTelemetry modules — no cold-start cost. Integration code can
24
- always call `this.telemetry.*`.
25
- - **Vendor-neutral.** Integration code never imports a backend SDK. Swap exporters
26
- in the app definition.
27
- - **Usage store ≠ APM.** Reports read the durable Frigg store, never an external
28
- APM.
29
-
30
- ## Configuration (app definition)
31
-
32
- ```js
33
- // backend/index.js
34
- const Definition = {
35
- name: 'my-app',
36
- integrations: [HubSpotIntegration, SalesforceIntegration],
37
-
38
- telemetry: {
39
- // none | console | otlp | honeycomb | datadog
40
- exporter: {
41
- type: 'otlp',
42
- endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT,
43
- // headers: { 'x-honeycomb-team': process.env.HONEYCOMB_KEY }, // or use type:'honeycomb' + apiKey
44
- },
45
- sampleRatio: 1.0, // parent-based trace sampling (0..1)
46
- northStar: {
47
- default: { name: 'records.synced' },
48
- },
49
- },
50
- };
51
- ```
52
-
53
- **Exporter default (no `telemetry.exporter` set):** `console` only when
54
- `STAGE=local` (a genuinely local run); every deployed stage — including `dev` —
55
- defaults to `none` (no per-event cost, no data written to CloudWatch). Point
56
- `exporter` at an OTLP backend to turn export on.
57
-
58
- ### Environment variables
59
-
60
- | Variable | Purpose |
61
- | --- | --- |
62
- | `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP backend URL (referenced from `telemetry.exporter.endpoint`). Auto-passed through to Lambda when an OTLP-family exporter is configured. |
63
- | `OTEL_EXPORTER_OTLP_HEADERS` | OTLP headers (e.g. auth). |
64
- | `OTEL_FLUSH_TIMEOUT_MS` | Max time the handler waits to flush telemetry before returning (default `500`). Bounds tail latency if the backend is unreachable. |
65
- | `OTEL_METRIC_EXPORT_INTERVAL_MS` | Metric reader interval (default `60000`). |
66
- | `STAGE` | `local` enables the `console` default. |
67
-
68
- > **VPC note:** a Lambda in a private subnet needs a NAT gateway or VPC endpoint
69
- > to reach an external OTLP backend. Without egress the exporter fails silently
70
- > within `OTEL_FLUSH_TIMEOUT_MS`.
71
-
72
- ## What you get for free (auto-instrumentation)
73
-
74
- No integration code required — every emission carries `{integration_type, event,
75
- status, ...}` bounded labels, with high-cardinality ids on span baggage / the bus
76
- context only.
77
-
78
- | Seam | Span | Metric |
79
- | --- | --- | --- |
80
- | Handler dispatch (USER_ACTION / CRON / QUEUE / WEBHOOK / lifecycle) | `frigg.handler.<type>` | `frigg.handler.invocations{integration_type, event, status}` |
81
- | Outbound API-module request | `frigg.apimodule.request` | `frigg.apimodule.requests{module, method, status}` |
82
-
83
- Request URLs are redacted (query string + userinfo stripped) before they touch a
84
- span, so credentials in query params never leak.
85
-
86
- > **Usage-attribution boundary.** The OTel metrics above fire for *every* seam
87
- > invocation. The durable per-integration **usage** rollup, though, only counts
88
- > emissions that carry an integration context — set by the handler seams. Requests
89
- > an API module makes *before an integration exists* (OAuth/token exchange, entity
90
- > discovery during connection setup) are observable in traces but not attributed to
91
- > an `api.requests` usage counter (there is no integration to attribute them to).
92
-
93
- ## Custom metrics (integration code)
94
-
95
- Every integration instance carries `this.telemetry` (auto-tagged with its
96
- `integration_type`):
97
-
98
- ```js
99
- class HubSpotIntegration extends IntegrationBase {
100
- async deltaSync() {
101
- // A span wraps the operation (nested under the handler span):
102
- await this.telemetry.span('delta_sync', async () => {
103
- const batch = await this.hubspot.api.getContacts();
104
-
105
- // A counter — declare 'records.synced' in Definition.usage to persist it:
106
- this.telemetry.count('records.synced', batch.length, {
107
- entity: 'contact',
108
- });
109
-
110
- // A one-off event (attached to the active span):
111
- this.telemetry.event('workflow_invoked', { workflow: 'lead_route' });
112
- });
113
- }
114
- }
115
- ```
116
-
117
- ## Usage counters (durable, comparable)
118
-
119
- ### 1. Declare which counters an integration reports
120
-
121
- ```js
122
- class HubSpotIntegration extends IntegrationBase {
123
- static Definition = {
124
- name: 'hubspot',
125
- usage: {
126
- // Canonical keys → comparable ACROSS integration types (reporting):
127
- canonical: ['records.synced', 'webhooks.received', 'api.requests'],
128
- // Custom keys → comparable only WITHIN this integration type:
129
- custom: { 'deals.enriched': { unit: 'count', label: 'Deals enriched' } },
130
- },
131
- };
132
- }
133
- ```
134
-
135
- **Canonical vocabulary** (core-owned, versioned):
136
-
137
- | Key | Source |
138
- | --- | --- |
139
- | `api.requests` | auto — every outbound API-module request |
140
- | `user_actions` | auto — every `USER_ACTION` handler |
141
- | `webhooks.received` | auto — the `ON_WEBHOOK` queue dispatch (per-integration, DB-connected) |
142
- | `records.synced` | explicit — `this.telemetry.count('records.synced', n, { entity })` |
143
- | `workflows.invoked` | explicit — `this.telemetry.count('workflows.invoked', 1, { workflow })` |
144
-
145
- Only **declared** keys are persisted. Declaring a canonical key opts the
146
- integration into the comparison report.
147
-
148
- ### 2. Read the usage store
149
-
150
- `frigg.usage.*` (via `createFriggCommands`) reads the durable store — never an APM:
151
-
152
- ```js
153
- const { createFriggCommands } = require('@friggframework/core');
154
- const frigg = createFriggCommands({ integrationClass: HubSpotIntegration });
155
-
156
- // Apples-to-apples comparison across integration types:
157
- await frigg.usage.totals({
158
- metric: 'records.synced',
159
- groupBy: 'integrationType', // or 'metric'
160
- since: daysAgo(30),
161
- bucket: 'day', // 'day' (default) | 'hour'
162
- });
163
- // → [{ integrationType: 'hubspot', value: 4200 }, { integrationType: 'salesforce', value: 1180 }]
164
-
165
- // Trend series for one type (aggregated across its instances):
166
- await frigg.usage.series({
167
- metric: 'records.synced',
168
- integrationType: 'hubspot',
169
- from: daysAgo(7),
170
- to: new Date(),
171
- bucket: 'day',
172
- });
173
- // → [{ bucket: 'day:2026-07-04', value: 610 }, { bucket: 'day:2026-07-05', value: 720 }]
174
-
175
- // Manual write (day + hour windows derived from `at`):
176
- await frigg.usage.recordUsageCounter({
177
- integrationId: 'int_1',
178
- integrationType: 'hubspot',
179
- metric: 'deals.enriched',
180
- value: 3,
181
- at: new Date(),
182
- });
183
- ```
184
-
185
- The [reporting endpoint](../reporting/README.md) surfaces these as additive
186
- `usage` columns on each `byType` bucket.
187
-
188
- ## North Star metric
189
-
190
- Declare an adopter North Star that reports/snapshots read as a first-class
191
- counter — populated by direct emission, or derived from a trace signal with no
192
- integration code:
193
-
194
- ```js
195
- telemetry: {
196
- northStar: {
197
- default: { name: 'records.synced' },
198
- byType: {
199
- hubspot: {
200
- name: 'contacts_synced',
201
- // derive from an auto-emitted signal:
202
- deriveFrom: { apiRequest: { endpoint: '/contacts', method: 'POST' } },
203
- // or: deriveFrom: { userAction: { action: 'route_lead' } }
204
- },
205
- },
206
- },
207
- }
208
- ```
209
-
210
- Read it as a first-class metric without knowing the configured key — the North
211
- Star resolves per integration type (`byType` wins over `default`):
212
-
213
- ```js
214
- // Resolves the configured counter for the type, then returns its totals.
215
- await frigg.usage.northStar({ integrationType: 'hubspot', since: daysAgo(30) });
216
- // → { metric: 'contacts_synced', totals: [{ integrationType: 'hubspot', value: 900 }] }
217
- // → null when no North Star is configured (caller branches without knowing keys)
218
- ```
219
-
220
- Or read it like any counter once you know the key:
221
- `frigg.usage.totals({ metric: 'contacts_synced' })`; trends via `frigg.usage.series({ metric })`.
222
-
223
- > `northStar` config is injected at the composition root — the caller that owns
224
- > the app definition passes it in: `createFriggCommands({ integrationClass, northStar })`
225
- > (the application layer never reaches up to load it). Omitted → `northStar()`
226
- > returns `null`.
227
-
228
- ## Plugin / extension tap
229
-
230
- Telemetry flows onto an internal event stream (independent of OTel export, so
231
- taps fire even with `exporter: none`). Two ways to subscribe:
232
-
233
- **Declarative (app definition)** — the framework wires these once per cold start,
234
- each guarded so a bad subscriber can't break emission or its siblings:
235
-
236
- ```js
237
- // backend/index.js
238
- const Definition = {
239
- telemetry: {
240
- subscribers: [
241
- // (a) declarative object — `event` optional; omit to receive both:
242
- { event: 'metric', handler: ({ name, value, attributes, context }) => {
243
- forwardToStatsd(name, value, attributes);
244
- } },
245
- // (b) factory — gets the telemetry service, registers itself, may
246
- // return an unsubscribe:
247
- (telemetry) => telemetry.on('event', (payload) => auditSink.write(payload)),
248
- ],
249
- },
250
- };
251
- ```
252
-
253
- **Imperative** — subscribe from anywhere that runs at startup:
254
-
255
- ```js
256
- const { getTelemetry } = require('@friggframework/core');
257
-
258
- const off = getTelemetry().on('metric', ({ name, value, attributes, context }) => {
259
- // `attributes` = bounded metric labels; `context` = high-cardinality ids
260
- // (integrationId, integrationType, userId, url, …). Never throws upstream.
261
- });
262
- // off() to unsubscribe
263
- ```
264
-
265
- > The bus payload shape (`{ name, value, attributes, context? }` for `'metric'`,
266
- > `{ name, attributes, context? }` for `'event'`) is a stable contract.
267
-
268
- ## Cardinality rule
269
-
270
- High-cardinality identifiers (`integrationId`, `userId`, request `url`, action
271
- names) ride **span baggage / the bus `context`** — never OTel **metric** labels.
272
- Metric labels stay bounded (`integration_type`, `event`, `status`, `method`,
273
- `module`). The usage rollup derives per-integration counts from the bus context,
274
- not from metric labels.
275
-
276
- ## How it works
277
-
278
- ```
279
- this.telemetry.count / auto-instrumented seam
280
- │ (bounded metric labels → OTel; ids → bus context via AsyncLocalStorage)
281
- ├──────────────► OTel exporter (traces + metrics) [observability]
282
- └──────────────► TelemetryEventBus ('metric'/'event')
283
- │
284
- ├─ UsageRollupSubscriber ── buffers per invocation,
285
- │ flushes to the UsageCounter store on handler exit
286
- │ (discards on SQS redelivery — approximate contract)
287
- └─ your plugin taps
288
-
289
- frigg.usage.totals / series ◄── UsageCounter store ──► reporting usage columns
290
- ```
291
-
292
- - **Flush is Lambda-safe:** `create-handler` awaits a bounded `forceFlush` in a
293
- `finally` (background timers can't fire once the container freezes).
294
- - **Usage accuracy is approximate:** at-least-once delivery means a retried
295
- handler could double-count. The invocation buffer is discarded only when the
296
- **whole** SQS batch is a redelivery (`ApproximateReceiveCount > 1`); a mixed
297
- batch flushes so a redelivered sibling never drops a fresh record's counts.
298
-
299
- ## Caveats / current limitations
300
-
301
- - **Usage persistence requires a DB-connected handler.** DB-free handlers (e.g.
302
- the raw webhook-receipt route) can't write; `webhooks.received` is counted at
303
- the DB-connected `ON_WEBHOOK` queue dispatch instead.
304
- - **DocumentDB** uses a raw-command adapter (`$runCommandRaw`) for increment and
305
- aggregate; command shapes are unit-tested but not yet run against a real cluster.
306
- - **Retention:** the `UsageCounter` table has no pruning yet — hour-grain rows
307
- accumulate. Add a scheduled prune for high-volume deployments. (Read paths are
308
- covered by composite indexes `(metric, window)` and `(metric, integrationType,
309
- window)`.)
310
- - Metric `value` is a `BigInt` per `(integrationId, integrationType, metric,
311
- window)` row; reads coerce the sum to a JS Number (safe below 2^53).
312
-
313
- ## See also
314
-
315
- - Architecture: [ADR-011](../../../docs/architecture-decisions/011-integration-telemetry-and-usage-tracking.md)
316
- - Reporting hand-off: [`reporting/README.md`](../reporting/README.md)
317
- - Encryption (same repository-triad pattern): [`database/encryption/README.md`](../database/encryption/README.md)
@@ -1,51 +0,0 @@
1
- /**
2
- * Wrap a telemetry service so an integration instance's emissions automatically
3
- * carry its `integration_type`. This lets developers write
4
- * `this.telemetry.count('records.synced', n, { entity })` with no per-call
5
- * boilerplate, while the usage rollup still attributes the counter to the right
6
- * integration type.
7
- *
8
- * Only the bounded `integration_type` is injected onto attributes — high-
9
- * cardinality ids stay off metric labels (Cardinality note). All other methods
10
- * delegate unchanged.
11
- *
12
- * @param {object} base The underlying telemetry service.
13
- * @param {() => {integrationType?: string}} getContext Lazy context accessor.
14
- */
15
- function bindTelemetryContext(base, getContext) {
16
- if (!base) return base;
17
-
18
- const withType = (attributes = {}) => {
19
- let integrationType;
20
- try {
21
- integrationType = getContext && getContext().integrationType;
22
- } catch (_) {
23
- integrationType = undefined;
24
- }
25
- if (!integrationType || 'integration_type' in attributes) {
26
- return attributes;
27
- }
28
- return { integration_type: integrationType, ...attributes };
29
- };
30
-
31
- return {
32
- count(name, value = 1, attributes = {}, context) {
33
- return base.count(name, value, withType(attributes), context);
34
- },
35
- event(name, attributes = {}, context) {
36
- return base.event(name, withType(attributes), context);
37
- },
38
- span: (...args) => base.span(...args),
39
- startSpan: (...args) => base.startSpan(...args),
40
- withContext: (...args) => base.withContext(...args),
41
- on: (...args) => base.on(...args),
42
- forceFlush: (...args) => base.forceFlush(...args),
43
- shutdown: (...args) =>
44
- typeof base.shutdown === 'function'
45
- ? base.shutdown(...args)
46
- : undefined,
47
- isEnabled: (...args) => base.isEnabled(...args),
48
- };
49
- }
50
-
51
- module.exports = { bindTelemetryContext };
@@ -1,52 +0,0 @@
1
- /**
2
- * Canonical usage-counter vocabulary.
3
- *
4
- * These core-owned, versioned keys are the ONLY metrics guaranteed comparable
5
- * *across* integration types — they power ADR-010's apples-to-apples comparison
6
- * report. Custom keys (declared in an integration's `Definition.usage.custom`)
7
- * are comparable only *within* an integration type.
8
- *
9
- * The registry is **additive**: new keys may be added, existing keys never
10
- * change or are removed, so existing reports never break.
11
- *
12
- * `dims` are the bounded dimensions a counter may carry (Cardinality note) —
13
- * high-cardinality ids (integrationId, userId) never appear here.
14
- */
15
- const CANONICAL_COUNTERS = {
16
- 'records.synced': {
17
- unit: 'count',
18
- label: 'Records synced',
19
- dims: ['entity'],
20
- },
21
- 'webhooks.received': {
22
- unit: 'count',
23
- label: 'Webhooks received',
24
- dims: ['event'],
25
- },
26
- 'workflows.invoked': {
27
- unit: 'count',
28
- label: 'Workflows invoked',
29
- dims: ['workflow'],
30
- },
31
- 'api.requests': {
32
- unit: 'count',
33
- label: 'API requests',
34
- dims: ['endpoint', 'status'],
35
- },
36
- user_actions: { unit: 'count', label: 'User actions', dims: ['action'] },
37
- };
38
-
39
- // NOTE: the framework-signal → canonical mapping lives in the usage rollup
40
- // subscriber (METRIC_TO_CANONICAL, keyed by the actual emitted metric names).
41
- // `records.synced` / `workflows.invoked` have no auto-signal — they are
42
- // explicit-only (`this.telemetry.count(...)`), so a canonical counter is never
43
- // shipped that silently stays at zero.
44
-
45
- function isCanonicalCounter(name) {
46
- return Object.prototype.hasOwnProperty.call(CANONICAL_COUNTERS, name);
47
- }
48
-
49
- module.exports = {
50
- CANONICAL_COUNTERS,
51
- isCanonicalCounter,
52
- };