@friggframework/core 2.0.0-next.102 → 2.0.0-next.103

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +40 -0
  2. package/application/commands/usage-commands.js +56 -0
  3. package/application/index.js +10 -9
  4. package/core/create-handler.js +112 -10
  5. package/generated/prisma-mongodb/edge.js +16 -4
  6. package/generated/prisma-mongodb/index-browser.js +13 -1
  7. package/generated/prisma-mongodb/index.d.ts +1503 -105
  8. package/generated/prisma-mongodb/index.js +16 -4
  9. package/generated/prisma-mongodb/package.json +1 -1
  10. package/generated/prisma-mongodb/schema.prisma +23 -0
  11. package/generated/prisma-mongodb/wasm.js +16 -4
  12. package/generated/prisma-postgresql/edge.js +16 -4
  13. package/generated/prisma-postgresql/index-browser.js +13 -1
  14. package/generated/prisma-postgresql/index.d.ts +1540 -91
  15. package/generated/prisma-postgresql/index.js +16 -4
  16. package/generated/prisma-postgresql/package.json +1 -1
  17. package/generated/prisma-postgresql/schema.prisma +22 -0
  18. package/generated/prisma-postgresql/wasm.js +16 -4
  19. package/handlers/app-definition-loader.js +26 -3
  20. package/handlers/integration-event-dispatcher.js +29 -15
  21. package/handlers/routers/integration-webhook-routers.js +20 -7
  22. package/index.js +16 -9
  23. package/integrations/integration-base.js +64 -7
  24. package/modules/requester/requester.js +106 -5
  25. package/package.json +12 -5
  26. package/prisma-mongodb/schema.prisma +23 -0
  27. package/prisma-postgresql/migrations/20260705000000_create_usage_counter/migration.sql +26 -0
  28. package/prisma-postgresql/schema.prisma +22 -0
  29. package/reporting/README.md +8 -1
  30. package/reporting/reporting-router.js +8 -1
  31. package/reporting/use-cases/list-integrations-report.js +53 -6
  32. package/telemetry/README.md +331 -0
  33. package/telemetry/bind-telemetry-context.js +73 -0
  34. package/telemetry/canonical-counters.js +52 -0
  35. package/telemetry/exporters.js +85 -0
  36. package/telemetry/index.js +26 -0
  37. package/telemetry/instrument-handler.js +87 -0
  38. package/telemetry/no-op-telemetry.js +67 -0
  39. package/telemetry/north-star.js +103 -0
  40. package/telemetry/otel-telemetry.js +213 -0
  41. package/telemetry/plugin-subscribers.js +77 -0
  42. package/telemetry/telemetry-config.js +120 -0
  43. package/telemetry/telemetry-context.js +40 -0
  44. package/telemetry/telemetry-event-bus.js +58 -0
  45. package/telemetry/telemetry-runtime.js +147 -0
  46. package/telemetry/telemetry-service.js +51 -0
  47. package/telemetry/usage-rollup-subscriber.js +116 -0
  48. package/usage/README.md +54 -0
  49. package/usage/index.js +17 -0
  50. package/usage/repositories/usage-repository-documentdb.js +194 -0
  51. package/usage/repositories/usage-repository-factory.js +25 -0
  52. package/usage/repositories/usage-repository-interface.js +37 -0
  53. package/usage/repositories/usage-repository-prisma.js +146 -0
  54. package/usage/tracked-metrics.js +38 -0
  55. package/usage/usage-windows.js +24 -0
@@ -0,0 +1,331 @@
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
+ **Sampling (`sampleRatio`, `0..1`, default `1`):** the fraction of **traces**
59
+ exported — a cost knob for high-traffic fleets (`0.1` ≈ keep 10%). Whole traces
60
+ are sampled (trace-ID-based + parent-based, so a distributed trace is never
61
+ half-kept), and it does **not** thin the durable **usage counters** — those stay
62
+ exact at any ratio (they ride the event bus, not the sampled trace pipeline). It
63
+ is **not** error-aware: a low ratio drops failed-run traces too, so for "keep all
64
+ errors, sample the rest" use tail-based sampling at an OTel Collector, not this
65
+ knob. Typical: `1.0` in dev, lower (e.g. `0.1`) in high-volume prod.
66
+
67
+ ### Environment variables
68
+
69
+ | Variable | Purpose |
70
+ | --- | --- |
71
+ | `OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP backend URL (referenced from `telemetry.exporter.endpoint`). Auto-passed through to Lambda when an OTLP-family exporter is configured. |
72
+ | `OTEL_EXPORTER_OTLP_HEADERS` | OTLP headers (e.g. auth). |
73
+ | `OTEL_FLUSH_TIMEOUT_MS` | Max time the handler waits to flush telemetry before returning (default `500`). Bounds tail latency if the backend is unreachable. |
74
+ | `OTEL_METRIC_EXPORT_INTERVAL_MS` | Metric reader interval (default `60000`). |
75
+ | `STAGE` | `local` enables the `console` default. |
76
+
77
+ > **VPC note:** a Lambda in a private subnet needs a NAT gateway or VPC endpoint
78
+ > to reach an external OTLP backend. Without egress the exporter fails silently
79
+ > within `OTEL_FLUSH_TIMEOUT_MS`.
80
+
81
+ ## What you get for free (auto-instrumentation)
82
+
83
+ No integration code required — every emission carries `{integration_type, event,
84
+ status, ...}` bounded labels, with high-cardinality ids on span baggage / the bus
85
+ context only.
86
+
87
+ | Seam | Span | Metric |
88
+ | --- | --- | --- |
89
+ | Handler dispatch (USER_ACTION / CRON / QUEUE / WEBHOOK / lifecycle) | `frigg.handler.<type>` | `frigg.handler.invocations{integration_type, event, status}` |
90
+ | Outbound API-module request | `frigg.apimodule.request` | `frigg.apimodule.requests{module, method, status}` |
91
+
92
+ Request URLs are redacted (query string + userinfo stripped) before they touch a
93
+ span, so credentials in query params never leak.
94
+
95
+ > **Usage-attribution boundary.** The OTel metrics above fire for *every* seam
96
+ > invocation. The durable per-integration **usage** rollup, though, only counts
97
+ > emissions that carry an integration context — set by the handler seams. Requests
98
+ > an API module makes *before an integration exists* (OAuth/token exchange, entity
99
+ > discovery during connection setup) are observable in traces but not attributed to
100
+ > an `api.requests` usage counter (there is no integration to attribute them to).
101
+
102
+ ## Custom metrics (integration code)
103
+
104
+ Every integration instance carries `this.telemetry` (auto-tagged with its
105
+ `integration_type`):
106
+
107
+ ```js
108
+ class HubSpotIntegration extends IntegrationBase {
109
+ async deltaSync() {
110
+ // A span wraps the operation (nested under the handler span):
111
+ await this.telemetry.span('delta_sync', async () => {
112
+ const batch = await this.hubspot.api.getContacts();
113
+
114
+ // A counter — declare 'records.synced' in Definition.usage to persist it:
115
+ this.telemetry.count('records.synced', batch.length, {
116
+ entity: 'contact',
117
+ });
118
+
119
+ // A one-off event (attached to the active span):
120
+ this.telemetry.event('workflow_invoked', { workflow: 'lead_route' });
121
+ });
122
+ }
123
+ }
124
+ ```
125
+
126
+ ## Usage counters (durable, comparable)
127
+
128
+ ### 1. Declare which counters an integration reports
129
+
130
+ ```js
131
+ class HubSpotIntegration extends IntegrationBase {
132
+ static Definition = {
133
+ name: 'hubspot',
134
+ usage: {
135
+ // Canonical keys → comparable ACROSS integration types (reporting):
136
+ canonical: ['records.synced', 'webhooks.received', 'api.requests'],
137
+ // Custom keys → comparable only WITHIN this integration type:
138
+ custom: { 'deals.enriched': { unit: 'count', label: 'Deals enriched' } },
139
+ },
140
+ };
141
+ }
142
+ ```
143
+
144
+ **Canonical vocabulary** (core-owned, versioned):
145
+
146
+ | Key | Source |
147
+ | --- | --- |
148
+ | `api.requests` | auto — every outbound API-module request |
149
+ | `user_actions` | auto — every `USER_ACTION` handler |
150
+ | `webhooks.received` | auto — the `ON_WEBHOOK` queue dispatch (per-integration, DB-connected) |
151
+ | `records.synced` | explicit — `this.telemetry.count('records.synced', n, { entity })` |
152
+ | `workflows.invoked` | explicit — `this.telemetry.count('workflows.invoked', 1, { workflow })` |
153
+
154
+ Only **declared** keys are persisted. Declaring a canonical key opts the
155
+ integration into the comparison report.
156
+
157
+ ### 2. Read the usage store
158
+
159
+ `frigg.usage.*` (via `createFriggCommands`) reads the durable store — never an APM:
160
+
161
+ ```js
162
+ const { createFriggCommands } = require('@friggframework/core');
163
+ const frigg = createFriggCommands({ integrationClass: HubSpotIntegration });
164
+
165
+ // Apples-to-apples comparison across integration types:
166
+ await frigg.usage.getTotalsByDimension({
167
+ metric: 'records.synced',
168
+ groupBy: 'integrationType', // or 'metric'
169
+ since: daysAgo(30),
170
+ bucket: 'day', // 'day' (default) | 'hour'
171
+ });
172
+ // → [{ integrationType: 'hubspot', value: 4200 }, { integrationType: 'salesforce', value: 1180 }]
173
+
174
+ // Trend series for one type (aggregated across its instances):
175
+ await frigg.usage.getTimeSeries({
176
+ metric: 'records.synced',
177
+ integrationType: 'hubspot',
178
+ from: daysAgo(7),
179
+ to: new Date(),
180
+ bucket: 'day',
181
+ });
182
+ // → [{ bucket: 'day:2026-07-04', value: 610 }, { bucket: 'day:2026-07-05', value: 720 }]
183
+
184
+ // Manual write (day + hour windows derived from `at`):
185
+ await frigg.usage.recordUsageCounter({
186
+ integrationId: 'int_1',
187
+ integrationType: 'hubspot',
188
+ metric: 'deals.enriched',
189
+ value: 3,
190
+ at: new Date(),
191
+ });
192
+ ```
193
+
194
+ The [reporting endpoint](../reporting/README.md) surfaces these as additive
195
+ `usage` columns on each `byType` bucket.
196
+
197
+ ## North Star metric
198
+
199
+ Declare an adopter North Star that reports/snapshots read as a first-class
200
+ counter — populated by direct emission, or derived from a trace signal with no
201
+ integration code:
202
+
203
+ ```js
204
+ telemetry: {
205
+ northStar: {
206
+ default: { name: 'records.synced' },
207
+ byType: {
208
+ hubspot: {
209
+ name: 'contacts_synced',
210
+ // derive from an auto-emitted signal:
211
+ deriveFrom: { apiRequest: { endpoint: '/contacts', method: 'POST' } },
212
+ // or: deriveFrom: { userAction: { action: 'route_lead' } }
213
+ },
214
+ },
215
+ },
216
+ }
217
+ ```
218
+
219
+ Read it as a first-class metric without knowing the configured key — the North
220
+ Star resolves per integration type (`byType` wins over `default`):
221
+
222
+ ```js
223
+ // The caller passes the North Star config it already holds (from the app
224
+ // definition); this resolves the counter for the type and returns its totals.
225
+ await frigg.usage.getNorthStarTotals({
226
+ northStar: definition.telemetry.northStar,
227
+ integrationType: 'hubspot',
228
+ since: daysAgo(30),
229
+ });
230
+ // → { metric: 'contacts_synced', totals: [{ integrationType: 'hubspot', value: 900 }] }
231
+ // → null when `northStar` is absent or has no entry for the type
232
+ ```
233
+
234
+ Or read it like any counter once you know the key:
235
+ `frigg.usage.getTotalsByDimension({ metric: 'contacts_synced' })`; trends via `frigg.usage.getTimeSeries({ metric })`.
236
+
237
+ > The North Star read takes its config as a **call argument** — nothing
238
+ > telemetry-specific is threaded through `createFriggCommands`. The caller that
239
+ > owns the app definition (e.g. a report runner) passes `telemetry.northStar` in;
240
+ > omit it and `getNorthStarTotals()` returns `null`.
241
+
242
+ ## Plugin / extension tap
243
+
244
+ Telemetry flows onto an internal event stream (independent of OTel export, so
245
+ taps fire even with `exporter: none`). Two ways to subscribe:
246
+
247
+ **Declarative (app definition)** — the framework wires these once per cold start,
248
+ each guarded so a bad subscriber can't break emission or its siblings:
249
+
250
+ ```js
251
+ // backend/index.js
252
+ const Definition = {
253
+ telemetry: {
254
+ subscribers: [
255
+ // (a) declarative object — `event` optional; omit to receive both:
256
+ { event: 'metric', handler: ({ name, value, attributes, context }) => {
257
+ forwardToStatsd(name, value, attributes);
258
+ } },
259
+ // (b) factory — gets the telemetry service, registers itself, may
260
+ // return an unsubscribe:
261
+ (telemetry) => telemetry.on('event', (payload) => auditSink.write(payload)),
262
+ ],
263
+ },
264
+ };
265
+ ```
266
+
267
+ **Imperative** — subscribe from anywhere that runs at startup:
268
+
269
+ ```js
270
+ const { getTelemetry } = require('@friggframework/core');
271
+
272
+ const off = getTelemetry().on('metric', ({ name, value, attributes, context }) => {
273
+ // `attributes` = bounded metric labels; `context` = high-cardinality ids
274
+ // (integrationId, integrationType, userId, url, …). Never throws upstream.
275
+ });
276
+ // off() to unsubscribe
277
+ ```
278
+
279
+ > The bus payload shape (`{ name, value, attributes, context? }` for `'metric'`,
280
+ > `{ name, attributes, context? }` for `'event'`) is a stable contract.
281
+
282
+ ## Cardinality rule
283
+
284
+ High-cardinality identifiers (`integrationId`, `userId`, request `url`, action
285
+ names) ride **span baggage / the bus `context`** — never OTel **metric** labels.
286
+ Metric labels stay bounded (`integration_type`, `event`, `status`, `method`,
287
+ `module`). The usage rollup derives per-integration counts from the bus context,
288
+ not from metric labels.
289
+
290
+ ## How it works
291
+
292
+ ```
293
+ this.telemetry.count / auto-instrumented seam
294
+ │ (bounded metric labels → OTel; ids → bus context via AsyncLocalStorage)
295
+ ├──────────────► OTel exporter (traces + metrics) [observability]
296
+ └──────────────► TelemetryEventBus ('metric'/'event')
297
+
298
+ ├─ UsageRollupSubscriber ── buffers per invocation,
299
+ │ flushes to the UsageCounter store on handler exit
300
+ │ (discards on SQS redelivery — approximate contract)
301
+ └─ your plugin taps
302
+
303
+ frigg.usage.getTotalsByDimension / getTimeSeries ◄── UsageCounter store ──► reporting usage columns
304
+ ```
305
+
306
+ - **Flush is Lambda-safe:** `create-handler` awaits a bounded `forceFlush` in a
307
+ `finally` (background timers can't fire once the container freezes).
308
+ - **Usage accuracy is approximate:** at-least-once delivery means a retried
309
+ handler could double-count. The invocation buffer is discarded only when the
310
+ **whole** SQS batch is a redelivery (`ApproximateReceiveCount > 1`); a mixed
311
+ batch flushes so a redelivered sibling never drops a fresh record's counts.
312
+
313
+ ## Caveats / current limitations
314
+
315
+ - **Usage persistence requires a DB-connected handler.** DB-free handlers (e.g.
316
+ the raw webhook-receipt route) can't write; `webhooks.received` is counted at
317
+ the DB-connected `ON_WEBHOOK` queue dispatch instead.
318
+ - **DocumentDB** uses a raw-command adapter (`$runCommandRaw`) for increment and
319
+ aggregate; command shapes are unit-tested but not yet run against a real cluster.
320
+ - **Retention:** the `UsageCounter` table has no pruning yet — hour-grain rows
321
+ accumulate. Add a scheduled prune for high-volume deployments. (Read paths are
322
+ covered by composite indexes `(metric, window)` and `(metric, integrationType,
323
+ window)`.)
324
+ - Metric `value` is a `BigInt` per `(integrationId, integrationType, metric,
325
+ window)` row; reads coerce the sum to a JS Number (safe below 2^53).
326
+
327
+ ## See also
328
+
329
+ - Architecture: [ADR-011](../../../docs/architecture-decisions/011-integration-telemetry-and-usage-tracking.md)
330
+ - Reporting hand-off: [`reporting/README.md`](../reporting/README.md)
331
+ - Encryption (same repository-triad pattern): [`database/encryption/README.md`](../database/encryption/README.md)
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Wrap a telemetry service so an integration instance's emissions automatically
3
+ * carry its context — with no per-call boilerplate. Two channels are filled:
4
+ * - `integration_type` onto attributes (the one bounded, low-cardinality label);
5
+ * - the full identifier set onto the bus `context` arg (integrationId, userId,
6
+ * version, …), which the usage rollup and traces read.
7
+ *
8
+ * So integration code writes `this.telemetry.count('records.synced', n, { entity })`
9
+ * or `this.telemetry.event('thing')` and both channels are populated. An
10
+ * explicitly passed context still wins (e.g. a requester attaching a per-call
11
+ * `url`), and high-cardinality ids ride the bus context only, never metric
12
+ * labels (Cardinality note).
13
+ *
14
+ * @param {object} base The underlying telemetry service.
15
+ * @param {() => object} getContext Lazy accessor for the instance's context.
16
+ */
17
+ function bindTelemetryContext(base, getContext) {
18
+ if (!base) return base;
19
+
20
+ const readContext = () => {
21
+ try {
22
+ return (getContext && getContext()) || undefined;
23
+ } catch (_) {
24
+ return undefined;
25
+ }
26
+ };
27
+
28
+ const withType = (attributes, ctx) => {
29
+ const integrationType = ctx && ctx.integrationType;
30
+ if (!integrationType || 'integration_type' in attributes) {
31
+ return attributes;
32
+ }
33
+ return { integration_type: integrationType, ...attributes };
34
+ };
35
+
36
+ const resolveContext = (context, ctx) =>
37
+ context !== undefined ? context : ctx;
38
+
39
+ return {
40
+ // Single source of truth for the instance context, so callers (e.g.
41
+ // instrumentHandler) read it here instead of gathering it separately.
42
+ getContext: () => readContext() || {},
43
+ count(name, value = 1, attributes = {}, context) {
44
+ const ctx = readContext();
45
+ return base.count(
46
+ name,
47
+ value,
48
+ withType(attributes, ctx),
49
+ resolveContext(context, ctx)
50
+ );
51
+ },
52
+ event(name, attributes = {}, context) {
53
+ const ctx = readContext();
54
+ return base.event(
55
+ name,
56
+ withType(attributes, ctx),
57
+ resolveContext(context, ctx)
58
+ );
59
+ },
60
+ span: (...args) => base.span(...args),
61
+ startSpan: (...args) => base.startSpan(...args),
62
+ withContext: (...args) => base.withContext(...args),
63
+ on: (...args) => base.on(...args),
64
+ forceFlush: (...args) => base.forceFlush(...args),
65
+ shutdown: (...args) =>
66
+ typeof base.shutdown === 'function'
67
+ ? base.shutdown(...args)
68
+ : undefined,
69
+ isEnabled: (...args) => base.isEnabled(...args),
70
+ };
71
+ }
72
+
73
+ module.exports = { bindTelemetryContext };
@@ -0,0 +1,52 @@
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
+ };
@@ -0,0 +1,85 @@
1
+ // Telemetry exporters — build the OTel { traceExporter, metricExporter } pair
2
+ // for one destination. OTel SDK packages are require'd lazily INSIDE the
3
+ // builders, so loading this module pulls in zero OTel (cold-start invariant).
4
+
5
+ function joinPath(base, path) {
6
+ return `${String(base).replace(/\/$/, '')}${path}`;
7
+ }
8
+
9
+ /**
10
+ * Normalize a descriptor to an OTLP target { endpoint, headers }, folding in
11
+ * vendor presets (Honeycomb). Pure — no OTel required — so it stays unit-testable.
12
+ */
13
+ function otlpTarget({ type, endpoint, headers, apiKey } = {}) {
14
+ if (type === 'honeycomb') {
15
+ return {
16
+ endpoint: endpoint || 'https://api.honeycomb.io',
17
+ headers: apiKey ? { 'x-honeycomb-team': apiKey } : headers,
18
+ };
19
+ }
20
+ return { endpoint, headers };
21
+ }
22
+
23
+ function buildOtlp(descriptor = {}) {
24
+ const {
25
+ OTLPTraceExporter,
26
+ } = require('@opentelemetry/exporter-trace-otlp-http');
27
+ const {
28
+ OTLPMetricExporter,
29
+ } = require('@opentelemetry/exporter-metrics-otlp-http');
30
+ const { endpoint, headers } = otlpTarget(descriptor);
31
+ // With no endpoint the SDK falls back to OTEL_EXPORTER_OTLP_ENDPOINT, so
32
+ // leave `url` unset in that case.
33
+ const options = (path) => ({
34
+ ...(endpoint ? { url: joinPath(endpoint, path) } : {}),
35
+ ...(headers ? { headers } : {}),
36
+ });
37
+ return {
38
+ traceExporter: new OTLPTraceExporter(options('/v1/traces')),
39
+ metricExporter: new OTLPMetricExporter(options('/v1/metrics')),
40
+ };
41
+ }
42
+
43
+ function buildConsole() {
44
+ const { ConsoleSpanExporter } = require('@opentelemetry/sdk-trace-base');
45
+ const { ConsoleMetricExporter } = require('@opentelemetry/sdk-metrics');
46
+ return {
47
+ traceExporter: new ConsoleSpanExporter(),
48
+ metricExporter: new ConsoleMetricExporter(),
49
+ };
50
+ }
51
+
52
+ // type → builder. Add a destination = add one entry. Datadog ingests OTLP/HTTP
53
+ // natively; Honeycomb is an OTLP preset resolved in otlpTarget.
54
+ const EXPORTERS = {
55
+ otlp: buildOtlp,
56
+ datadog: buildOtlp,
57
+ honeycomb: buildOtlp,
58
+ console: buildConsole,
59
+ };
60
+
61
+ /**
62
+ * Resolve a telemetry descriptor to its { traceExporter, metricExporter } pair.
63
+ * Pre-built instances (tests / advanced adopters) win over `type`; an unknown or
64
+ * absent type falls back to plain OTLP (which itself falls back to the standard
65
+ * OTLP env var). The own-property check keeps a `type` matching an
66
+ * Object.prototype member ('constructor', 'toString', …) from resolving an
67
+ * inherited key.
68
+ */
69
+ function resolveExporter(descriptor = {}) {
70
+ if (descriptor.traceExporter || descriptor.metricExporter) {
71
+ return {
72
+ traceExporter: descriptor.traceExporter || null,
73
+ metricExporter: descriptor.metricExporter || null,
74
+ };
75
+ }
76
+ const build = Object.prototype.hasOwnProperty.call(
77
+ EXPORTERS,
78
+ descriptor.type
79
+ )
80
+ ? EXPORTERS[descriptor.type]
81
+ : buildOtlp;
82
+ return build(descriptor);
83
+ }
84
+
85
+ module.exports = { resolveExporter, EXPORTERS, otlpTarget, joinPath };
@@ -0,0 +1,26 @@
1
+ // Public telemetry surface. Framework-internal modules (adapters, bus, config,
2
+ // exporters, subscribers, rollup) are deep-required by their consumers, not
3
+ // re-exported here — every barrel export is semver surface.
4
+ const { createTelemetry } = require('./telemetry-service');
5
+ const {
6
+ getTelemetry,
7
+ setTelemetryForTests,
8
+ resetTelemetryRuntimeForTests,
9
+ } = require('./telemetry-runtime');
10
+ const { bindTelemetryContext } = require('./bind-telemetry-context');
11
+ const { instrumentHandler } = require('./instrument-handler');
12
+ const {
13
+ CANONICAL_COUNTERS,
14
+ isCanonicalCounter,
15
+ } = require('./canonical-counters');
16
+
17
+ module.exports = {
18
+ createTelemetry,
19
+ getTelemetry,
20
+ setTelemetryForTests,
21
+ resetTelemetryRuntimeForTests,
22
+ bindTelemetryContext,
23
+ instrumentHandler,
24
+ CANONICAL_COUNTERS,
25
+ isCanonicalCounter,
26
+ };
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Wraps a single integration handler invocation with a span + the
3
+ * `frigg.handler.invocations` counter. Shared by the two
4
+ * dispatch seams — `IntegrationBase.send()` and `IntegrationEventDispatcher` —
5
+ * so both paths are instrumented identically.
6
+ *
7
+ * Cardinality discipline: the metric is keyed by the **bounded event type**
8
+ * (USER_ACTION / CRON / QUEUE / WEBHOOK / LIFE_CYCLE_EVENT), never the specific
9
+ * event/action name. High-cardinality ids (integrationId, userId) ride span
10
+ * baggage via `telemetry.withContext`, never metric attributes. The full event
11
+ * name is kept on the span only.
12
+ *
13
+ * @param {object|null} telemetry Bound telemetry service (no-op-safe; null → just
14
+ * runs fn). The context is read from `telemetry.getContext()` — the per-instance
15
+ * bound wrapper carries it; a raw service degrades to 'unknown'.
16
+ * @param {{event: string, eventType: string}} descriptor Event name + bounded type.
17
+ * @param {Function} fn The handler invocation.
18
+ */
19
+ async function instrumentHandler(telemetry, descriptor = {}, fn) {
20
+ if (!telemetry || typeof telemetry.span !== 'function') {
21
+ return fn();
22
+ }
23
+
24
+ const context =
25
+ typeof telemetry.getContext === 'function'
26
+ ? telemetry.getContext()
27
+ : {};
28
+ const integrationType = context.integrationType || 'unknown';
29
+ const eventType = descriptor.eventType || 'unknown';
30
+ const eventName = descriptor.event;
31
+
32
+ const runInstrumented = () =>
33
+ telemetry.span(`frigg.handler.${eventType}`, async (span) => {
34
+ if (span && typeof span.setAttributes === 'function') {
35
+ span.setAttributes({
36
+ integration_type: integrationType,
37
+ event: eventType,
38
+ event_name: eventName,
39
+ });
40
+ }
41
+ // `event_name` (potentially high-cardinality action id) rides the
42
+ // bus-only context — used by North Star derived-from-trace matching,
43
+ // never a metric label.
44
+ const busContext = { event_name: eventName };
45
+ try {
46
+ const result = await fn();
47
+ telemetry.count(
48
+ 'frigg.handler.invocations',
49
+ 1,
50
+ {
51
+ integration_type: integrationType,
52
+ event: eventType,
53
+ status: 'ok',
54
+ },
55
+ busContext
56
+ );
57
+ return result;
58
+ } catch (err) {
59
+ telemetry.count(
60
+ 'frigg.handler.invocations',
61
+ 1,
62
+ {
63
+ integration_type: integrationType,
64
+ event: eventType,
65
+ status: 'error',
66
+ },
67
+ busContext
68
+ );
69
+ throw err;
70
+ }
71
+ });
72
+
73
+ if (typeof telemetry.withContext === 'function') {
74
+ return telemetry.withContext(
75
+ {
76
+ integrationId: context.integrationId,
77
+ userId: context.userId,
78
+ integrationType: context.integrationType,
79
+ version: context.version,
80
+ },
81
+ runInstrumented
82
+ );
83
+ }
84
+ return runInstrumented();
85
+ }
86
+
87
+ module.exports = { instrumentHandler };