@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,67 @@
1
+ const { createTelemetryEventBus } = require('./telemetry-event-bus');
2
+ const {
3
+ runWithTelemetryContext,
4
+ mergeTelemetryContext,
5
+ } = require('./telemetry-context');
6
+
7
+ /**
8
+ * No-op telemetry adapter: the default when no exporter is configured, so
9
+ * telemetry "rides for free". Imports **zero** OpenTelemetry packages (guarded by
10
+ * a require-graph test) — the no-op path never loads the OTel SDK, protecting
11
+ * Lambda cold-start. The internal event bus is still active: `count`/`event` are
12
+ * mirrored onto it so the durable usage rollup + plugin taps work even with
13
+ * export off (emitting to a bus with no subscribers is cheap).
14
+ */
15
+ class NoOpTelemetry {
16
+ constructor({ bus = createTelemetryEventBus() } = {}) {
17
+ this._bus = bus;
18
+ }
19
+
20
+ count(name, value = 1, attributes = {}, context) {
21
+ const merged = mergeTelemetryContext(context);
22
+ const payload = { name, value, attributes };
23
+ if (merged) payload.context = merged;
24
+ this._bus.emit('metric', payload);
25
+ }
26
+
27
+ event(name, attributes = {}, context) {
28
+ const merged = mergeTelemetryContext(context);
29
+ const payload = { name, attributes };
30
+ if (merged) payload.context = merged;
31
+ this._bus.emit('event', payload);
32
+ }
33
+
34
+ async span(_name, fn) {
35
+ return typeof fn === 'function' ? fn() : undefined;
36
+ }
37
+
38
+ startSpan() {
39
+ return {
40
+ setAttributes() {},
41
+ setAttribute() {},
42
+ recordException() {},
43
+ setStatus() {},
44
+ end() {},
45
+ };
46
+ }
47
+
48
+ async withContext(context, fn) {
49
+ return runWithTelemetryContext(context, () =>
50
+ typeof fn === 'function' ? fn() : undefined
51
+ );
52
+ }
53
+
54
+ on(eventType, callback) {
55
+ return this._bus.on(eventType, callback);
56
+ }
57
+
58
+ async forceFlush() {}
59
+
60
+ async shutdown() {}
61
+
62
+ isEnabled() {
63
+ return false;
64
+ }
65
+ }
66
+
67
+ module.exports = { NoOpTelemetry };
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Adopter North Star metric. A North Star simply references
3
+ * a usage-counter key (canonical or custom); its value is then read like any
4
+ * other counter via `frigg.usage.getTotalsByDimension({ metric: northStarKey })`. It is
5
+ * populated either by (a) direct emission of that counter, or (b) derived from a
6
+ * framework trace signal — this module implements (b) as a small bus subscriber
7
+ * that emits the North Star counter when a configured signal matches.
8
+ */
9
+
10
+ /** Resolve the North Star counter entry for an integration type (byType wins). */
11
+ function resolveNorthStarEntry(northStar, integrationType) {
12
+ if (!northStar) return null;
13
+ return (
14
+ (northStar.byType && northStar.byType[integrationType]) ||
15
+ northStar.default ||
16
+ null
17
+ );
18
+ }
19
+
20
+ /** The set of counter keys any North Star references — added to trackedMetrics. */
21
+ function northStarKeys(northStar) {
22
+ const keys = new Set();
23
+ if (!northStar) return keys;
24
+ if (northStar.default?.name) keys.add(northStar.default.name);
25
+ for (const entry of Object.values(northStar.byType || {})) {
26
+ if (entry?.name) keys.add(entry.name);
27
+ }
28
+ return keys;
29
+ }
30
+
31
+ function matchesDeriveFrom(
32
+ deriveFrom,
33
+ { name, attributes = {}, context = {} }
34
+ ) {
35
+ if (!deriveFrom) return false;
36
+
37
+ if (deriveFrom.userAction) {
38
+ const rule = deriveFrom.userAction;
39
+ return (
40
+ name === 'frigg.handler.invocations' &&
41
+ attributes.event === 'USER_ACTION' &&
42
+ (!rule.action || context.event_name === rule.action)
43
+ );
44
+ }
45
+
46
+ if (deriveFrom.apiRequest) {
47
+ const rule = deriveFrom.apiRequest;
48
+ return (
49
+ name === 'frigg.apimodule.requests' &&
50
+ (!rule.method || attributes.method === rule.method) &&
51
+ (!rule.endpoint ||
52
+ (typeof context.url === 'string' &&
53
+ context.url.includes(rule.endpoint)))
54
+ );
55
+ }
56
+
57
+ return false;
58
+ }
59
+
60
+ /**
61
+ * Subscribe to the bus and emit the North Star counter whenever a configured
62
+ * `deriveFrom` signal matches (config-only, no integration code). The emitted
63
+ * counter flows back through the bus to the usage rollup like any other metric.
64
+ */
65
+ function createNorthStarDerivationSubscriber({ telemetry, northStar }) {
66
+ if (!telemetry || !northStar) return { unsubscribe() {} };
67
+
68
+ // Hoisted — the referenced-key set is fixed for the life of the subscriber.
69
+ const keys = northStarKeys(northStar);
70
+
71
+ function onMetric(payload) {
72
+ try {
73
+ // Never react to a North Star counter's own emission (no loop).
74
+ if (keys.has(payload.name)) return;
75
+
76
+ const integrationType =
77
+ payload.context?.integrationType ||
78
+ payload.attributes?.integration_type ||
79
+ null;
80
+ const entry = resolveNorthStarEntry(northStar, integrationType);
81
+ if (!entry || !entry.deriveFrom) return;
82
+ if (!matchesDeriveFrom(entry.deriveFrom, payload)) return;
83
+
84
+ telemetry.count(
85
+ entry.name,
86
+ 1,
87
+ integrationType ? { integration_type: integrationType } : {},
88
+ payload.context
89
+ );
90
+ } catch (_) {
91
+ // Derivation must never break emission.
92
+ }
93
+ }
94
+
95
+ const unsubscribe = telemetry.on('metric', onMetric);
96
+ return { unsubscribe };
97
+ }
98
+
99
+ module.exports = {
100
+ resolveNorthStarEntry,
101
+ northStarKeys,
102
+ createNorthStarDerivationSubscriber,
103
+ };
@@ -0,0 +1,213 @@
1
+ const otelApi = require('@opentelemetry/api');
2
+ const {
3
+ BasicTracerProvider,
4
+ BatchSpanProcessor,
5
+ ParentBasedSampler,
6
+ TraceIdRatioBasedSampler,
7
+ AlwaysOnSampler,
8
+ } = require('@opentelemetry/sdk-trace-base');
9
+ const {
10
+ MeterProvider,
11
+ PeriodicExportingMetricReader,
12
+ } = require('@opentelemetry/sdk-metrics');
13
+ const { resourceFromAttributes } = require('@opentelemetry/resources');
14
+ const { resolveExporter } = require('./exporters');
15
+ const { createTelemetryEventBus } = require('./telemetry-event-bus');
16
+ const {
17
+ runWithTelemetryContext,
18
+ mergeTelemetryContext,
19
+ } = require('./telemetry-context');
20
+
21
+ const TRACER_NAME = 'frigg';
22
+ const METRIC_EXPORT_INTERVAL_MS =
23
+ Number(process.env.OTEL_METRIC_EXPORT_INTERVAL_MS) || 60000;
24
+
25
+ /**
26
+ * Register an AsyncLocalStorage-backed OTel context manager once per process.
27
+ * Without it `BasicTracerProvider` uses the Noop context manager, so
28
+ * `startActiveSpan` never sets an active context — spans would be unparented
29
+ * roots, `getActiveSpan()` always undefined, and baggage inert. Guarded so
30
+ * repeated telemetry construction (tests) doesn't re-register.
31
+ */
32
+ let contextManagerRegistered = false;
33
+ function ensureContextManager() {
34
+ if (contextManagerRegistered) return;
35
+ contextManagerRegistered = true;
36
+ try {
37
+ const {
38
+ AsyncLocalStorageContextManager,
39
+ } = require('@opentelemetry/context-async-hooks');
40
+ const manager = new AsyncLocalStorageContextManager();
41
+ manager.enable();
42
+ otelApi.context.setGlobalContextManager(manager);
43
+ } catch (_) {
44
+ // If registration fails, traces are flat but usage attribution (which
45
+ // rides the separate telemetry-context ALS) is unaffected.
46
+ }
47
+ }
48
+
49
+ /**
50
+ * OpenTelemetry-backed telemetry adapter. Constructed only when a real exporter
51
+ * is configured (`createTelemetry` returns the no-op otherwise). `BatchSpanProcessor`
52
+ * is used uniformly — spans are delivered by the bounded `forceFlush()` the Lambda
53
+ * handler awaits before the container freezes (`callbackWaitsForEmptyEventLoop=false`),
54
+ * never by background timers.
55
+ */
56
+ class OtelTelemetry {
57
+ constructor({
58
+ exporter,
59
+ resource = {},
60
+ sampleRatio,
61
+ bus = createTelemetryEventBus(),
62
+ } = {}) {
63
+ ensureContextManager();
64
+ const { traceExporter, metricExporter } = resolveExporter(exporter);
65
+
66
+ const resourceAttrs = { 'service.name': resource.service || 'frigg' };
67
+ if (resource.stage) {
68
+ resourceAttrs['deployment.environment'] = resource.stage;
69
+ resourceAttrs.stage = resource.stage;
70
+ }
71
+ if (resource.appName) resourceAttrs.appName = resource.appName;
72
+ const res = resourceFromAttributes(resourceAttrs);
73
+
74
+ const sampler =
75
+ typeof sampleRatio === 'number' && sampleRatio < 1
76
+ ? new ParentBasedSampler({
77
+ root: new TraceIdRatioBasedSampler(sampleRatio),
78
+ })
79
+ : new AlwaysOnSampler();
80
+
81
+ this._tracerProvider = new BasicTracerProvider({
82
+ resource: res,
83
+ sampler,
84
+ spanProcessors: traceExporter
85
+ ? [new BatchSpanProcessor(traceExporter)]
86
+ : [],
87
+ });
88
+
89
+ const metricReader = metricExporter
90
+ ? new PeriodicExportingMetricReader({
91
+ exporter: metricExporter,
92
+ exportIntervalMillis: METRIC_EXPORT_INTERVAL_MS,
93
+ })
94
+ : null;
95
+ this._meterProvider = new MeterProvider({
96
+ resource: res,
97
+ readers: metricReader ? [metricReader] : [],
98
+ });
99
+
100
+ this._bus = bus;
101
+ this._tracer = this._tracerProvider.getTracer(TRACER_NAME);
102
+ this._meter = this._meterProvider.getMeter(TRACER_NAME);
103
+ // One instrument instance per metric name — OTel requires it, and
104
+ // re-creating a counter would drop data points.
105
+ this._counters = new Map();
106
+ }
107
+
108
+ _getCounter(name) {
109
+ if (!this._counters.has(name)) {
110
+ this._counters.set(name, this._meter.createCounter(name));
111
+ }
112
+ return this._counters.get(name);
113
+ }
114
+
115
+ count(name, value = 1, attributes = {}, context) {
116
+ try {
117
+ this._getCounter(name).add(value, attributes);
118
+ } catch (_) {
119
+ // Telemetry must never break the wrapped path.
120
+ }
121
+ // Mirror onto the internal stream for the usage rollup + plugin taps; the
122
+ // bus context (incl. high-cardinality ids) rides the bus only, never the
123
+ // OTel metric attributes.
124
+ const merged = mergeTelemetryContext(context);
125
+ const payload = { name, value, attributes };
126
+ if (merged) payload.context = merged;
127
+ this._bus.emit('metric', payload);
128
+ }
129
+
130
+ event(name, attributes = {}, context) {
131
+ try {
132
+ const active = otelApi.trace.getActiveSpan();
133
+ if (active) active.addEvent(name, attributes);
134
+ } catch (_) {}
135
+ const merged = mergeTelemetryContext(context);
136
+ const payload = { name, attributes };
137
+ if (merged) payload.context = merged;
138
+ this._bus.emit('event', payload);
139
+ }
140
+
141
+ startSpan(name, options) {
142
+ return this._tracer.startSpan(name, options);
143
+ }
144
+
145
+ async span(name, fn, options = {}) {
146
+ return this._tracer.startActiveSpan(name, options, async (span) => {
147
+ try {
148
+ const result =
149
+ typeof fn === 'function' ? await fn(span) : undefined;
150
+ span.setStatus({ code: otelApi.SpanStatusCode.OK });
151
+ return result;
152
+ } catch (err) {
153
+ span.recordException(err);
154
+ span.setStatus({
155
+ code: otelApi.SpanStatusCode.ERROR,
156
+ message: err && err.message,
157
+ });
158
+ throw err;
159
+ } finally {
160
+ span.end();
161
+ }
162
+ });
163
+ }
164
+
165
+ /**
166
+ * Run `fn` with the given identifiers on (a) the AsyncLocalStorage telemetry
167
+ * context — which the usage rollup reads to attribute emissions per-integration
168
+ * on ANY path — and (b) OTel baggage for trace propagation. High-cardinality
169
+ * ids ride here, never on metric labels.
170
+ */
171
+ async withContext(attributes = {}, fn) {
172
+ const entries = {};
173
+ for (const [key, value] of Object.entries(attributes)) {
174
+ if (value !== undefined && value !== null) {
175
+ entries[key] = { value: String(value) };
176
+ }
177
+ }
178
+ const baggage = otelApi.propagation.createBaggage(entries);
179
+ const ctx = otelApi.propagation.setBaggage(
180
+ otelApi.context.active(),
181
+ baggage
182
+ );
183
+ return runWithTelemetryContext(attributes, () =>
184
+ otelApi.context.with(ctx, () =>
185
+ typeof fn === 'function' ? fn() : undefined
186
+ )
187
+ );
188
+ }
189
+
190
+ on(eventType, callback) {
191
+ return this._bus.on(eventType, callback);
192
+ }
193
+
194
+ async forceFlush() {
195
+ await Promise.allSettled([
196
+ this._tracerProvider.forceFlush(),
197
+ this._meterProvider.forceFlush(),
198
+ ]);
199
+ }
200
+
201
+ async shutdown() {
202
+ await Promise.allSettled([
203
+ this._tracerProvider.shutdown(),
204
+ this._meterProvider.shutdown(),
205
+ ]);
206
+ }
207
+
208
+ isEnabled() {
209
+ return true;
210
+ }
211
+ }
212
+
213
+ module.exports = { OtelTelemetry };
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Declarative plugin/extension telemetry taps.
3
+ *
4
+ * The built-in usage rollup and North Star derivation subscribe to the telemetry
5
+ * bus in framework code; this lets an ADOPTER subscribe declaratively from the
6
+ * app definition (`Definition.telemetry.subscribers: [...]`) — forward to a
7
+ * custom sink, compute aggregates, persist counters — without writing wiring code.
8
+ *
9
+ * A subscriber is one of:
10
+ * - a **factory** `fn(telemetry)` that registers itself (e.g. calls
11
+ * `telemetry.on('metric', ...)`) and MAY return an unsubscribe function; or
12
+ * - a **declarative object** `{ event?: 'metric'|'event', handler(payload, eventType) }`
13
+ * — when `event` is omitted the handler receives both metric and event payloads.
14
+ *
15
+ * Every attach and every handler invocation is guarded so a misbehaving
16
+ * subscriber can never break emission, its siblings, or a handler (mirrors the
17
+ * bus's own per-subscriber isolation).
18
+ *
19
+ * @param {object} params
20
+ * @param {object} params.telemetry Telemetry service exposing `on(eventType, cb)`.
21
+ * @param {Array<Function|{event?: string, handler: Function}>} [params.subscribers]
22
+ * @returns {Array<Function>} unsubscribe functions for the wired subscribers.
23
+ */
24
+ const EVENT_TYPES = ['metric', 'event'];
25
+
26
+ function wireTelemetrySubscribers({ telemetry, subscribers = [] } = {}) {
27
+ if (
28
+ !telemetry ||
29
+ typeof telemetry.on !== 'function' ||
30
+ !Array.isArray(subscribers)
31
+ ) {
32
+ return [];
33
+ }
34
+
35
+ const unsubscribes = [];
36
+
37
+ for (const subscriber of subscribers) {
38
+ try {
39
+ if (typeof subscriber === 'function') {
40
+ const off = subscriber(telemetry);
41
+ if (typeof off === 'function') unsubscribes.push(off);
42
+ continue;
43
+ }
44
+
45
+ if (subscriber && typeof subscriber.handler === 'function') {
46
+ const events = subscriber.event
47
+ ? [subscriber.event]
48
+ : EVENT_TYPES;
49
+ for (const eventType of events) {
50
+ const off = telemetry.on(eventType, (payload) => {
51
+ try {
52
+ subscriber.handler(payload, eventType);
53
+ } catch (error) {
54
+ console.warn(
55
+ `[Frigg][telemetry] subscriber handler for "${eventType}" threw: ${
56
+ error && error.message
57
+ }`
58
+ );
59
+ }
60
+ });
61
+ if (typeof off === 'function') unsubscribes.push(off);
62
+ }
63
+ }
64
+ } catch (error) {
65
+ // A single bad subscriber must never break wiring of the others.
66
+ console.warn(
67
+ `[Frigg][telemetry] failed to wire a telemetry subscriber: ${
68
+ error && error.message
69
+ }`
70
+ );
71
+ }
72
+ }
73
+
74
+ return unsubscribes;
75
+ }
76
+
77
+ module.exports = { wireTelemetrySubscribers };
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Resolves and validates the telemetry section of an app definition
3
+ * into a normalized config the TelemetryService and
4
+ * usage rollup consume: `{ exporter, northStar, sampleRatio }`.
5
+ *
6
+ * Exporter default is keyed off STAGE: only a genuinely LOCAL run (STAGE=local)
7
+ * gets `console` (visible, free); every deployed stage — including `dev` —
8
+ * defaults to `none`. `dev` is a deployed AWS stage, and `console` there would
9
+ * write full spans to CloudWatch by default (cost + a data-exposure surface).
10
+ * Adopters opt into `console`/`otlp` explicitly for deployed stages.
11
+ */
12
+ const VALID_EXPORTER_TYPES = [
13
+ 'none',
14
+ 'noop',
15
+ 'console',
16
+ 'otlp',
17
+ 'honeycomb',
18
+ 'datadog',
19
+ ];
20
+
21
+ function normalizeExporterConfig(exporter, stage) {
22
+ if (!exporter) {
23
+ return stage === 'local' ? { type: 'console' } : { type: 'none' };
24
+ }
25
+
26
+ // Pre-built exporter instances (tests / advanced adopters) bypass type checks.
27
+ if (exporter.traceExporter || exporter.metricExporter) {
28
+ return exporter;
29
+ }
30
+
31
+ if (!VALID_EXPORTER_TYPES.includes(exporter.type)) {
32
+ throw new Error(
33
+ `[Frigg][telemetry] invalid exporter type "${exporter.type}". ` +
34
+ `Supported: ${VALID_EXPORTER_TYPES.join(', ')}`
35
+ );
36
+ }
37
+ return exporter;
38
+ }
39
+
40
+ // Fraction of TRACES exported (0..1, default 1) — a cost knob wired to the OTel
41
+ // sampler in otel-telemetry. Does NOT thin usage counters; those stay exact.
42
+ function resolveSampleRatio(sampleRatio) {
43
+ if (sampleRatio === undefined || sampleRatio === null) return 1;
44
+ if (
45
+ typeof sampleRatio !== 'number' ||
46
+ Number.isNaN(sampleRatio) ||
47
+ sampleRatio < 0 ||
48
+ sampleRatio > 1
49
+ ) {
50
+ throw new Error(
51
+ `[Frigg][telemetry] sampleRatio must be a number in [0,1], got ${sampleRatio}`
52
+ );
53
+ }
54
+ return sampleRatio;
55
+ }
56
+
57
+ function validateNorthStarEntry(entry, where) {
58
+ if (!entry || typeof entry.name !== 'string' || !entry.name) {
59
+ throw new Error(
60
+ `[Frigg][telemetry] northStar.${where} must reference a counter { name }`
61
+ );
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Adopter-declared telemetry subscribers. Each is either a factory
67
+ * function `fn(telemetry)` or a declarative `{ event?, handler }` object. Kept as
68
+ * an array so `wireTelemetrySubscribers` can attach them to the bus at runtime.
69
+ */
70
+ function resolveSubscribers(subscribers) {
71
+ if (subscribers === undefined || subscribers === null) return [];
72
+ if (!Array.isArray(subscribers)) {
73
+ throw new Error(
74
+ '[Frigg][telemetry] subscribers must be an array of functions or { handler } objects'
75
+ );
76
+ }
77
+ for (const subscriber of subscribers) {
78
+ const ok =
79
+ typeof subscriber === 'function' ||
80
+ (subscriber && typeof subscriber.handler === 'function');
81
+ if (!ok) {
82
+ throw new Error(
83
+ '[Frigg][telemetry] each subscriber must be a function or an object with a handler function'
84
+ );
85
+ }
86
+ }
87
+ return subscribers;
88
+ }
89
+
90
+ function resolveNorthStar(northStar) {
91
+ if (!northStar) return null;
92
+ if (northStar.default) validateNorthStarEntry(northStar.default, 'default');
93
+ if (northStar.byType) {
94
+ for (const [type, entry] of Object.entries(northStar.byType)) {
95
+ validateNorthStarEntry(entry, `byType.${type}`);
96
+ }
97
+ }
98
+ return northStar;
99
+ }
100
+
101
+ /**
102
+ * @param {object} appDefinition The backend `Definition` export.
103
+ * @param {object} [ctx]
104
+ * @param {string} [ctx.stage] Deployment stage (defaults to STAGE/NODE_ENV).
105
+ * @returns {{ exporter: object, northStar: object|null, sampleRatio: number }}
106
+ */
107
+ function resolveTelemetryConfig(appDefinition = {}, ctx = {}) {
108
+ const stage =
109
+ ctx.stage || process.env.STAGE || process.env.NODE_ENV || 'production';
110
+ const telemetry = appDefinition.telemetry || {};
111
+
112
+ return {
113
+ exporter: normalizeExporterConfig(telemetry.exporter, stage),
114
+ northStar: resolveNorthStar(telemetry.northStar),
115
+ sampleRatio: resolveSampleRatio(telemetry.sampleRatio),
116
+ subscribers: resolveSubscribers(telemetry.subscribers),
117
+ };
118
+ }
119
+
120
+ module.exports = { resolveTelemetryConfig };
@@ -0,0 +1,40 @@
1
+ const { AsyncLocalStorage } = require('node:async_hooks');
2
+
3
+ /**
4
+ * Ambient telemetry context. A single process-wide AsyncLocalStorage
5
+ * holds the standard identifiers ({integrationId, integrationType, userId,
6
+ * version, ...}) for the duration of a handler, set once at the handler seam via
7
+ * `telemetry.withContext(...)`. Any emission during that async scope — including
8
+ * deep calls like an API-module request — reads the ambient context, so the
9
+ * usage rollup can attribute counters per-integration WITHOUT threading context
10
+ * through every call and WITHOUT relying on OTel baggage (which does not exist
11
+ * on the no-op path). This is the propagation mechanism the plan intended.
12
+ *
13
+ * Only bus-payload `context` is populated from here; high-cardinality ids never
14
+ * touch OTel metric labels.
15
+ */
16
+ const store = new AsyncLocalStorage();
17
+
18
+ function runWithTelemetryContext(context, fn) {
19
+ return store.run(context || {}, fn);
20
+ }
21
+
22
+ function getTelemetryContextStore() {
23
+ return store.getStore() || null;
24
+ }
25
+
26
+ /**
27
+ * Merge the ambient context with an explicit per-call context (explicit keys
28
+ * win, e.g. a request `url`). Returns undefined when there is nothing to attach.
29
+ */
30
+ function mergeTelemetryContext(explicit) {
31
+ const ambient = store.getStore();
32
+ if (!ambient && !explicit) return undefined;
33
+ return { ...(ambient || {}), ...(explicit || {}) };
34
+ }
35
+
36
+ module.exports = {
37
+ runWithTelemetryContext,
38
+ getTelemetryContextStore,
39
+ mergeTelemetryContext,
40
+ };
@@ -0,0 +1,58 @@
1
+ /**
2
+ * TelemetryEventBus — the internal event stream telemetry flows onto.
3
+ * Plugins/extensions and the built-in usage rollup
4
+ * subscribe here; it is deliberately independent of OTel export (Decision 7),
5
+ * so usage counters persist even when the OTel exporter is a no-op.
6
+ *
7
+ * This is intentionally a **synchronous** in-process fan-out (Frigg runs one
8
+ * invocation per Lambda container): a plain subscriber list with per-subscriber
9
+ * `try/catch` so a misbehaving subscriber can never break emission or its
10
+ * siblings. No queue/backpressure machinery — emission volume is bounded by a
11
+ * single invocation's work.
12
+ *
13
+ * ## Public event contract (semver-stable)
14
+ * Event types and their payload shapes:
15
+ * - `'metric'` → `{ name: string, value: number, attributes: object, context?: object }`
16
+ * - `'event'` → `{ name: string, attributes: object, context?: object }`
17
+ * `attributes` are the bounded OTel metric labels. `context` (present when an
18
+ * ambient telemetry context is active) carries the high-cardinality identifiers
19
+ * — {integrationId, integrationType, userId, version, ...} plus per-call extras
20
+ * like a request `url` — which the usage rollup reads for attribution. Those ids
21
+ * NEVER appear in `attributes` (Cardinality note).
22
+ */
23
+ function createTelemetryEventBus() {
24
+ /** @type {Map<string, Set<Function>>} */
25
+ const subscribers = new Map();
26
+
27
+ function on(eventType, callback) {
28
+ if (!subscribers.has(eventType)) {
29
+ subscribers.set(eventType, new Set());
30
+ }
31
+ const set = subscribers.get(eventType);
32
+ set.add(callback);
33
+ return function off() {
34
+ set.delete(callback);
35
+ };
36
+ }
37
+
38
+ function emit(eventType, payload) {
39
+ const set = subscribers.get(eventType);
40
+ if (!set || set.size === 0) return;
41
+ for (const callback of set) {
42
+ try {
43
+ callback(payload);
44
+ } catch (error) {
45
+ // A subscriber must never break emission or its siblings.
46
+ console.warn(
47
+ `[Frigg][telemetry] subscriber for "${eventType}" threw: ${
48
+ error && error.message
49
+ }`
50
+ );
51
+ }
52
+ }
53
+ }
54
+
55
+ return { on, emit };
56
+ }
57
+
58
+ module.exports = { createTelemetryEventBus };