@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.
- package/README.md +40 -0
- package/application/commands/usage-commands.js +56 -0
- package/application/index.js +10 -9
- package/core/create-handler.js +112 -10
- package/generated/prisma-mongodb/edge.js +16 -4
- package/generated/prisma-mongodb/index-browser.js +13 -1
- package/generated/prisma-mongodb/index.d.ts +1503 -105
- package/generated/prisma-mongodb/index.js +16 -4
- package/generated/prisma-mongodb/package.json +1 -1
- package/generated/prisma-mongodb/schema.prisma +23 -0
- package/generated/prisma-mongodb/wasm.js +16 -4
- package/generated/prisma-postgresql/edge.js +16 -4
- package/generated/prisma-postgresql/index-browser.js +13 -1
- package/generated/prisma-postgresql/index.d.ts +1540 -91
- package/generated/prisma-postgresql/index.js +16 -4
- package/generated/prisma-postgresql/package.json +1 -1
- package/generated/prisma-postgresql/schema.prisma +22 -0
- package/generated/prisma-postgresql/wasm.js +16 -4
- package/handlers/app-definition-loader.js +26 -3
- package/handlers/integration-event-dispatcher.js +29 -15
- package/handlers/routers/integration-webhook-routers.js +20 -7
- package/index.js +16 -9
- package/integrations/integration-base.js +64 -7
- package/modules/requester/requester.js +106 -5
- package/package.json +12 -5
- package/prisma-mongodb/schema.prisma +23 -0
- package/prisma-postgresql/migrations/20260705000000_create_usage_counter/migration.sql +26 -0
- package/prisma-postgresql/schema.prisma +22 -0
- package/reporting/README.md +8 -1
- package/reporting/reporting-router.js +8 -1
- package/reporting/use-cases/list-integrations-report.js +53 -6
- package/telemetry/README.md +331 -0
- package/telemetry/bind-telemetry-context.js +73 -0
- package/telemetry/canonical-counters.js +52 -0
- package/telemetry/exporters.js +85 -0
- package/telemetry/index.js +26 -0
- package/telemetry/instrument-handler.js +87 -0
- package/telemetry/no-op-telemetry.js +67 -0
- package/telemetry/north-star.js +103 -0
- package/telemetry/otel-telemetry.js +213 -0
- package/telemetry/plugin-subscribers.js +77 -0
- package/telemetry/telemetry-config.js +120 -0
- package/telemetry/telemetry-context.js +40 -0
- package/telemetry/telemetry-event-bus.js +58 -0
- package/telemetry/telemetry-runtime.js +147 -0
- package/telemetry/telemetry-service.js +51 -0
- package/telemetry/usage-rollup-subscriber.js +116 -0
- package/usage/README.md +54 -0
- package/usage/index.js +17 -0
- package/usage/repositories/usage-repository-documentdb.js +194 -0
- package/usage/repositories/usage-repository-factory.js +25 -0
- package/usage/repositories/usage-repository-interface.js +37 -0
- package/usage/repositories/usage-repository-prisma.js +146 -0
- package/usage/tracked-metrics.js +38 -0
- 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 };
|