@friggframework/core 2.0.0--canary.622.faffe5f.0 → 2.0.0--canary.622.ce0381c.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -232,7 +232,7 @@ class HubSpotIntegration extends IntegrationBase {
232
232
 
233
233
  // Read the durable usage store (never an APM):
234
234
  const frigg = createFriggCommands({ integrationClass: HubSpotIntegration });
235
- await frigg.usage.totals({ metric: 'records.synced', groupBy: 'integrationType' });
235
+ await frigg.usage.getTotalsByDimension({ metric: 'records.synced', groupBy: 'integrationType' });
236
236
  ```
237
237
 
238
238
  **See:** [`telemetry/README.md`](telemetry/README.md) for the full guide
@@ -1,31 +1,15 @@
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
- */
1
+ // `frigg.usage.*` — the read/write surface over the durable usage store.
13
2
  const {
14
3
  createUsageRepository,
15
4
  } = require('../../usage/repositories/usage-repository-factory');
16
5
  const { computeUsageWindows } = require('../../telemetry/usage-windows');
17
6
  const { resolveNorthStarEntry } = require('../../telemetry/north-star');
18
7
 
19
- function createUsageCommands({ usageRepository, northStar = null } = {}) {
8
+ function createUsageCommands({ usageRepository } = {}) {
20
9
  const repository = usageRepository || createUsageRepository();
21
10
 
22
11
  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
- */
12
+ // Increments both the day and hour window rows for `at`.
29
13
  async recordUsageCounter({
30
14
  integrationId,
31
15
  integrationType,
@@ -45,25 +29,20 @@ function createUsageCommands({ usageRepository, northStar = null } = {}) {
45
29
  }
46
30
  },
47
31
 
48
- async totals(args) {
49
- return repository.totals(args);
32
+ async getTotalsByDimension(args) {
33
+ return repository.getTotalsByDimension(args);
50
34
  },
51
35
 
52
- async series(args) {
53
- return repository.series(args);
36
+ async getTimeSeries(args) {
37
+ return repository.getTimeSeries(args);
54
38
  },
55
39
 
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 } = {}) {
40
+ // Caller supplies the North Star config (Definition.telemetry.northStar);
41
+ // resolves the counter for the type (byType > default), null if none.
42
+ async getNorthStarTotals({ northStar, integrationType, since, groupBy = 'integrationType', bucket } = {}) {
64
43
  const entry = resolveNorthStarEntry(northStar, integrationType);
65
44
  if (!entry) return null;
66
- const totals = await repository.totals({
45
+ const totals = await repository.getTotalsByDimension({
67
46
  metric: entry.name,
68
47
  groupBy,
69
48
  since,
@@ -17,11 +17,6 @@ const { createUsageCommands } = require('./commands/usage-commands');
17
17
  *
18
18
  * @param {Object} params
19
19
  * @param {Object} params.integrationClass - Integration class (required)
20
- * @param {Object} [params.northStar] - Resolved North Star config
21
- * (`Definition.telemetry.northStar`) so `frigg.usage.northStar(...)` can read
22
- * it. Injected by the caller that owns the app definition
23
- * (the handler/report runner) — the application layer never reaches up to load
24
- * it. Omitted → usage reads report no North Star.
25
20
  * @returns {Object} Unified commands object with all CRUD operations
26
21
  *
27
22
  * @example
@@ -29,7 +24,7 @@ const { createUsageCommands } = require('./commands/usage-commands');
29
24
  * const user = await commands.createUser({ username: 'user@example.com' });
30
25
  * const credential = await commands.createCredential({ userId: user.id, ... });
31
26
  */
32
- function createFriggCommands({ integrationClass, northStar = null }) {
27
+ function createFriggCommands({ integrationClass }) {
33
28
  // All commands use Frigg's default repositories and use cases
34
29
  const integrationCommands = createIntegrationCommands({ integrationClass });
35
30
 
@@ -57,9 +52,10 @@ function createFriggCommands({ integrationClass, northStar = null }) {
57
52
  // Process commands
58
53
  ...processCommands,
59
54
 
60
- // Usage read/write — nested to match `frigg.usage.*`.
61
- // northStar injected by the caller (composition root), not loaded here.
62
- usage: createUsageCommands({ northStar }),
55
+ // Usage read/write — nested to match `frigg.usage.*`. The North Star
56
+ // read takes its config as a call argument, so nothing telemetry-specific
57
+ // is threaded through this general factory.
58
+ usage: createUsageCommands(),
63
59
  };
64
60
  }
65
61
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@friggframework/core",
3
3
  "prettier": "@friggframework/prettier-config",
4
- "version": "2.0.0--canary.622.faffe5f.0",
4
+ "version": "2.0.0--canary.622.ce0381c.0",
5
5
  "dependencies": {
6
6
  "@aws-sdk/client-apigatewaymanagementapi": "^3.588.0",
7
7
  "@aws-sdk/client-kms": "^3.588.0",
@@ -45,9 +45,9 @@
45
45
  }
46
46
  },
47
47
  "devDependencies": {
48
- "@friggframework/eslint-config": "2.0.0--canary.622.faffe5f.0",
49
- "@friggframework/prettier-config": "2.0.0--canary.622.faffe5f.0",
50
- "@friggframework/test": "2.0.0--canary.622.faffe5f.0",
48
+ "@friggframework/eslint-config": "2.0.0--canary.622.ce0381c.0",
49
+ "@friggframework/prettier-config": "2.0.0--canary.622.ce0381c.0",
50
+ "@friggframework/test": "2.0.0--canary.622.ce0381c.0",
51
51
  "@prisma/client": "^6.19.3",
52
52
  "@types/lodash": "4.17.15",
53
53
  "@typescript-eslint/eslint-plugin": "^8.0.0",
@@ -87,5 +87,5 @@
87
87
  "publishConfig": {
88
88
  "access": "public"
89
89
  },
90
- "gitHead": "faffe5ff8b3fea6bbb46a52502cbd5b5ecb35559"
90
+ "gitHead": "ce0381c83a8fdebf49352eb9cd6b38b360130025"
91
91
  }
@@ -118,7 +118,7 @@ class ListIntegrationsReport {
118
118
  try {
119
119
  const totalsByMetric = await Promise.all(
120
120
  metrics.map(async (metric) => {
121
- const totals = await this.usageRepository.totals({
121
+ const totals = await this.usageRepository.getTotalsByDimension({
122
122
  metric,
123
123
  groupBy: 'integrationType',
124
124
  });
@@ -55,6 +55,15 @@ const Definition = {
55
55
  defaults to `none` (no per-event cost, no data written to CloudWatch). Point
56
56
  `exporter` at an OTLP backend to turn export on.
57
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
+
58
67
  ### Environment variables
59
68
 
60
69
  | Variable | Purpose |
@@ -154,7 +163,7 @@ const { createFriggCommands } = require('@friggframework/core');
154
163
  const frigg = createFriggCommands({ integrationClass: HubSpotIntegration });
155
164
 
156
165
  // Apples-to-apples comparison across integration types:
157
- await frigg.usage.totals({
166
+ await frigg.usage.getTotalsByDimension({
158
167
  metric: 'records.synced',
159
168
  groupBy: 'integrationType', // or 'metric'
160
169
  since: daysAgo(30),
@@ -163,7 +172,7 @@ await frigg.usage.totals({
163
172
  // → [{ integrationType: 'hubspot', value: 4200 }, { integrationType: 'salesforce', value: 1180 }]
164
173
 
165
174
  // Trend series for one type (aggregated across its instances):
166
- await frigg.usage.series({
175
+ await frigg.usage.getTimeSeries({
167
176
  metric: 'records.synced',
168
177
  integrationType: 'hubspot',
169
178
  from: daysAgo(7),
@@ -211,19 +220,24 @@ Read it as a first-class metric without knowing the configured key — the North
211
220
  Star resolves per integration type (`byType` wins over `default`):
212
221
 
213
222
  ```js
214
- // Resolves the configured counter for the type, then returns its totals.
215
- await frigg.usage.northStar({ integrationType: 'hubspot', since: daysAgo(30) });
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
+ });
216
230
  // → { metric: 'contacts_synced', totals: [{ integrationType: 'hubspot', value: 900 }] }
217
- // → null when no North Star is configured (caller branches without knowing keys)
231
+ // → null when `northStar` is absent or has no entry for the type
218
232
  ```
219
233
 
220
234
  Or read it like any counter once you know the key:
221
- `frigg.usage.totals({ metric: 'contacts_synced' })`; trends via `frigg.usage.series({ metric })`.
235
+ `frigg.usage.getTotalsByDimension({ metric: 'contacts_synced' })`; trends via `frigg.usage.getTimeSeries({ metric })`.
222
236
 
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`.
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`.
227
241
 
228
242
  ## Plugin / extension tap
229
243
 
@@ -286,7 +300,7 @@ this.telemetry.count / auto-instrumented seam
286
300
  │ (discards on SQS redelivery — approximate contract)
287
301
  └─ your plugin taps
288
302
 
289
- frigg.usage.totals / series ◄── UsageCounter store ──► reporting usage columns
303
+ frigg.usage.getTotalsByDimension / getTimeSeries ◄── UsageCounter store ──► reporting usage columns
290
304
  ```
291
305
 
292
306
  - **Flush is Lambda-safe:** `create-handler` awaits a bounded `forceFlush` in a
@@ -0,0 +1,19 @@
1
+ const {
2
+ TelemetryExporterInterface,
3
+ } = require('./telemetry-exporter-interface');
4
+
5
+ /** Console span/metric exporters — local-dev visibility, no network. */
6
+ class ConsoleExporter extends TelemetryExporterInterface {
7
+ build() {
8
+ const {
9
+ ConsoleSpanExporter,
10
+ } = require('@opentelemetry/sdk-trace-base');
11
+ const { ConsoleMetricExporter } = require('@opentelemetry/sdk-metrics');
12
+ return {
13
+ traceExporter: new ConsoleSpanExporter(),
14
+ metricExporter: new ConsoleMetricExporter(),
15
+ };
16
+ }
17
+ }
18
+
19
+ module.exports = { ConsoleExporter };
@@ -0,0 +1,11 @@
1
+ const { OtlpExporter } = require('./otlp-exporter');
2
+
3
+ /**
4
+ * Datadog ingests OTLP/HTTP directly, so today it behaves exactly like
5
+ * `OtlpExporter` (endpoint + headers). It is its own class so Datadog-specific
6
+ * presets (e.g. a default `DD-API-KEY` header) can be added later without
7
+ * touching the OTLP exporter or the registry.
8
+ */
9
+ class DatadogExporter extends OtlpExporter {}
10
+
11
+ module.exports = { DatadogExporter };
@@ -0,0 +1,13 @@
1
+ const { OtlpExporter } = require('./otlp-exporter');
2
+
3
+ /** Honeycomb: an OTLP preset — default endpoint + `x-honeycomb-team` from apiKey. */
4
+ class HoneycombExporter extends OtlpExporter {
5
+ constructor({ endpoint, apiKey, headers } = {}) {
6
+ super({
7
+ endpoint: endpoint || 'https://api.honeycomb.io',
8
+ headers: apiKey ? { 'x-honeycomb-team': apiKey } : headers,
9
+ });
10
+ }
11
+ }
12
+
13
+ module.exports = { HoneycombExporter };
@@ -0,0 +1,42 @@
1
+ const {
2
+ TelemetryExporterInterface,
3
+ } = require('./telemetry-exporter-interface');
4
+
5
+ /**
6
+ * OTLP/HTTP exporter to a configured endpoint (+ optional headers). With no
7
+ * endpoint the OTel SDK falls back to the standard `OTEL_EXPORTER_OTLP_ENDPOINT`
8
+ * env var, so `url` is left unset in that case.
9
+ */
10
+ class OtlpExporter extends TelemetryExporterInterface {
11
+ constructor({ endpoint, headers } = {}) {
12
+ super();
13
+ this.endpoint = endpoint;
14
+ this.headers = headers;
15
+ }
16
+
17
+ build() {
18
+ const {
19
+ OTLPTraceExporter,
20
+ } = require('@opentelemetry/exporter-trace-otlp-http');
21
+ const {
22
+ OTLPMetricExporter,
23
+ } = require('@opentelemetry/exporter-metrics-otlp-http');
24
+ return {
25
+ traceExporter: new OTLPTraceExporter(this._options('/v1/traces')),
26
+ metricExporter: new OTLPMetricExporter(this._options('/v1/metrics')),
27
+ };
28
+ }
29
+
30
+ _options(path) {
31
+ return {
32
+ ...(this.endpoint ? { url: joinPath(this.endpoint, path) } : {}),
33
+ ...(this.headers ? { headers: this.headers } : {}),
34
+ };
35
+ }
36
+ }
37
+
38
+ function joinPath(base, path) {
39
+ return `${String(base).replace(/\/$/, '')}${path}`;
40
+ }
41
+
42
+ module.exports = { OtlpExporter };
@@ -0,0 +1,25 @@
1
+ const {
2
+ TelemetryExporterInterface,
3
+ } = require('./telemetry-exporter-interface');
4
+
5
+ /**
6
+ * Adopter/test-supplied exporter instances, used as-is (e.g. in-memory exporters
7
+ * in tests, or an advanced adopter wiring its own OTel exporter). Pre-built
8
+ * instances win over a `type`.
9
+ */
10
+ class PassthroughExporter extends TelemetryExporterInterface {
11
+ constructor({ traceExporter = null, metricExporter = null } = {}) {
12
+ super();
13
+ this.traceExporter = traceExporter;
14
+ this.metricExporter = metricExporter;
15
+ }
16
+
17
+ build() {
18
+ return {
19
+ traceExporter: this.traceExporter,
20
+ metricExporter: this.metricExporter,
21
+ };
22
+ }
23
+ }
24
+
25
+ module.exports = { PassthroughExporter };
@@ -0,0 +1,39 @@
1
+ const { PassthroughExporter } = require('./passthrough-exporter');
2
+ const { ConsoleExporter } = require('./console-exporter');
3
+ const { OtlpExporter } = require('./otlp-exporter');
4
+ const { HoneycombExporter } = require('./honeycomb-exporter');
5
+ const { DatadogExporter } = require('./datadog-exporter');
6
+
7
+ // type → exporter adapter. Add a destination by adding a class + one entry.
8
+ const EXPORTERS = {
9
+ console: ConsoleExporter,
10
+ otlp: OtlpExporter,
11
+ datadog: DatadogExporter,
12
+ honeycomb: HoneycombExporter,
13
+ };
14
+
15
+ /**
16
+ * Resolve the exporter adapter for a telemetry descriptor. Pre-built exporter
17
+ * instances (tests / advanced adopters) win over `type`; an unknown or absent
18
+ * type falls back to plain OTLP (which itself falls back to the standard OTLP
19
+ * env var). Replaces the former switch-based factory with polymorphic adapters.
20
+ *
21
+ * @returns {import('./telemetry-exporter-interface').TelemetryExporterInterface}
22
+ */
23
+ function resolveExporter(descriptor = {}) {
24
+ if (descriptor.traceExporter || descriptor.metricExporter) {
25
+ return new PassthroughExporter(descriptor);
26
+ }
27
+ // Own-property check so a `type` matching an Object.prototype member
28
+ // ('constructor', 'toString', …) can't resolve an inherited key — unknown
29
+ // types must fall through to plain OTLP.
30
+ const Exporter = Object.prototype.hasOwnProperty.call(
31
+ EXPORTERS,
32
+ descriptor.type
33
+ )
34
+ ? EXPORTERS[descriptor.type]
35
+ : OtlpExporter;
36
+ return new Exporter(descriptor);
37
+ }
38
+
39
+ module.exports = { resolveExporter, EXPORTERS };
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Port for a telemetry exporter: builds the OTel trace + metric exporter
3
+ * instances for one destination. Adapters (console, otlp, honeycomb, datadog, a
4
+ * passthrough for pre-built instances) implement `build()`. OpenTelemetry SDK
5
+ * packages are `require`d lazily inside `build()` so merely loading an adapter
6
+ * pulls in no OTel modules.
7
+ */
8
+ class TelemetryExporterInterface {
9
+ /** @returns {{ traceExporter: object|null, metricExporter: object|null }} */
10
+ build() {
11
+ throw new Error(
12
+ 'build() must be implemented by a TelemetryExporter subclass'
13
+ );
14
+ }
15
+ }
16
+
17
+ module.exports = { TelemetryExporterInterface };
@@ -1,12 +1,15 @@
1
1
  const { createTelemetry, isNoOpExporter } = require('./telemetry-service');
2
- const { createNoOpTelemetry } = require('./no-op-telemetry');
2
+ const {
3
+ TelemetryServiceInterface,
4
+ } = require('./telemetry-service-interface');
5
+ const { NoOpTelemetry } = require('./no-op-telemetry');
3
6
  const { createTelemetryEventBus } = require('./telemetry-event-bus');
4
7
  const { resolveTelemetryConfig } = require('./telemetry-config');
5
8
  const {
6
9
  getTelemetry,
7
10
  resetTelemetryForTests,
8
11
  } = require('./telemetry-singleton');
9
- const { buildExporters } = require('./exporters/exporter-factory');
12
+ const { resolveExporter } = require('./exporters/resolve-exporter');
10
13
  const { instrumentHandler } = require('./instrument-handler');
11
14
  const { bindTelemetryContext } = require('./bind-telemetry-context');
12
15
  const { createUsageRollupSubscriber } = require('./usage-rollup-subscriber');
@@ -32,13 +35,14 @@ const {
32
35
 
33
36
  module.exports = {
34
37
  createTelemetry,
35
- createNoOpTelemetry,
38
+ TelemetryServiceInterface,
39
+ NoOpTelemetry,
36
40
  createTelemetryEventBus,
37
41
  resolveTelemetryConfig,
38
42
  getTelemetry,
39
43
  resetTelemetryForTests,
40
44
  isNoOpExporter,
41
- buildExporters,
45
+ resolveExporter,
42
46
  instrumentHandler,
43
47
  bindTelemetryContext,
44
48
  createUsageRollupSubscriber,
@@ -1,3 +1,6 @@
1
+ const {
2
+ TelemetryServiceInterface,
3
+ } = require('./telemetry-service-interface');
1
4
  const { createTelemetryEventBus } = require('./telemetry-event-bus');
2
5
  const {
3
6
  runWithTelemetryContext,
@@ -5,62 +8,64 @@ const {
5
8
  } = require('./telemetry-context');
6
9
 
7
10
  /**
8
- * No-op telemetry implementation.
9
- *
10
- * This is the default when no OTel exporter is configured, so telemetry "rides
11
- * for free": integration code can call `this.telemetry.*`
12
- * unconditionally and it costs nothing on the OTel side. Critically, this module
13
- * imports **zero** OpenTelemetry packages — the no-op path never loads the OTel
14
- * SDK (guarded by a require-graph test), protecting Lambda cold-start.
15
- *
16
- * The internal event bus is still active here (Decision 7): `count`/`event` are
17
- * mirrored onto it so the durable usage rollup and plugin/extension taps work
18
- * even with OTel export disabled. Emitting to a bus with no subscribers is a
19
- * cheap no-op.
20
- *
21
- * @param {object} [options]
22
- * @param {object} [options.bus] Event bus to mirror emissions onto.
11
+ * No-op telemetry adapter: the default when no exporter is configured, so
12
+ * telemetry "rides for free". Imports **zero** OpenTelemetry packages (guarded by
13
+ * a require-graph test) — the no-op path never loads the OTel SDK, protecting
14
+ * Lambda cold-start. The internal event bus is still active: `count`/`event` are
15
+ * mirrored onto it so the durable usage rollup + plugin taps work even with
16
+ * export off (emitting to a bus with no subscribers is cheap).
23
17
  */
24
- function createNoOpTelemetry({ bus = createTelemetryEventBus() } = {}) {
25
- const noop = {
26
- count(name, value = 1, attributes = {}, context) {
27
- const merged = mergeTelemetryContext(context);
28
- const payload = { name, value, attributes };
29
- if (merged) payload.context = merged;
30
- bus.emit('metric', payload);
31
- },
32
- event(name, attributes = {}, context) {
33
- const merged = mergeTelemetryContext(context);
34
- const payload = { name, attributes };
35
- if (merged) payload.context = merged;
36
- bus.emit('event', payload);
37
- },
38
- async span(_name, fn) {
39
- return typeof fn === 'function' ? fn() : undefined;
40
- },
41
- startSpan() {
42
- return {
43
- setAttributes() {},
44
- setAttribute() {},
45
- recordException() {},
46
- setStatus() {},
47
- end() {},
48
- };
49
- },
50
- async withContext(context, fn) {
51
- return runWithTelemetryContext(context, () =>
52
- typeof fn === 'function' ? fn() : undefined
53
- );
54
- },
55
- on(eventType, callback) {
56
- return bus.on(eventType, callback);
57
- },
58
- async forceFlush() {},
59
- isEnabled() {
60
- return false;
61
- },
62
- };
63
- return noop;
18
+ class NoOpTelemetry extends TelemetryServiceInterface {
19
+ constructor({ bus = createTelemetryEventBus() } = {}) {
20
+ super();
21
+ this._bus = bus;
22
+ }
23
+
24
+ count(name, value = 1, attributes = {}, context) {
25
+ const merged = mergeTelemetryContext(context);
26
+ const payload = { name, value, attributes };
27
+ if (merged) payload.context = merged;
28
+ this._bus.emit('metric', payload);
29
+ }
30
+
31
+ event(name, attributes = {}, context) {
32
+ const merged = mergeTelemetryContext(context);
33
+ const payload = { name, attributes };
34
+ if (merged) payload.context = merged;
35
+ this._bus.emit('event', payload);
36
+ }
37
+
38
+ async span(_name, fn) {
39
+ return typeof fn === 'function' ? fn() : undefined;
40
+ }
41
+
42
+ startSpan() {
43
+ return {
44
+ setAttributes() {},
45
+ setAttribute() {},
46
+ recordException() {},
47
+ setStatus() {},
48
+ end() {},
49
+ };
50
+ }
51
+
52
+ async withContext(context, fn) {
53
+ return runWithTelemetryContext(context, () =>
54
+ typeof fn === 'function' ? fn() : undefined
55
+ );
56
+ }
57
+
58
+ on(eventType, callback) {
59
+ return this._bus.on(eventType, callback);
60
+ }
61
+
62
+ async forceFlush() {}
63
+
64
+ async shutdown() {}
65
+
66
+ isEnabled() {
67
+ return false;
68
+ }
64
69
  }
65
70
 
66
- module.exports = { createNoOpTelemetry };
71
+ module.exports = { NoOpTelemetry };
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Adopter North Star metric. A North Star simply references
3
3
  * a usage-counter key (canonical or custom); its value is then read like any
4
- * other counter via `frigg.usage.totals({ metric: northStarKey })`. It is
4
+ * other counter via `frigg.usage.getTotalsByDimension({ metric: northStarKey })`. It is
5
5
  * populated either by (a) direct emission of that counter, or (b) derived from a
6
6
  * framework trace signal — this module implements (b) as a small bus subscriber
7
7
  * that emits the North Star counter when a configured signal matches.
@@ -11,7 +11,10 @@ const {
11
11
  PeriodicExportingMetricReader,
12
12
  } = require('@opentelemetry/sdk-metrics');
13
13
  const { resourceFromAttributes } = require('@opentelemetry/resources');
14
- const { buildExporters } = require('./exporters/exporter-factory');
14
+ const {
15
+ TelemetryServiceInterface,
16
+ } = require('./telemetry-service-interface');
17
+ const { resolveExporter } = require('./exporters/resolve-exporter');
15
18
  const { createTelemetryEventBus } = require('./telemetry-event-bus');
16
19
  const {
17
20
  runWithTelemetryContext,
@@ -47,169 +50,169 @@ function ensureContextManager() {
47
50
  }
48
51
 
49
52
  /**
50
- * The OpenTelemetry-backed telemetry service. Reached only
51
- * when a real exporter is configured; `createTelemetry` returns the no-op
52
- * otherwise. `BatchSpanProcessor` is used uniformly — spans are delivered by the
53
- * bounded `forceFlush()` the Lambda handler awaits before the container freezes
54
- * (`callbackWaitsForEmptyEventLoop=false`), never by background timers.
53
+ * OpenTelemetry-backed telemetry adapter. Constructed only when a real exporter
54
+ * is configured (`createTelemetry` returns the no-op otherwise). `BatchSpanProcessor`
55
+ * is used uniformly — spans are delivered by the bounded `forceFlush()` the Lambda
56
+ * handler awaits before the container freezes (`callbackWaitsForEmptyEventLoop=false`),
57
+ * never by background timers.
55
58
  */
56
- function createOtelTelemetry({
57
- exporter,
58
- resource = {},
59
- sampleRatio,
60
- bus = createTelemetryEventBus(),
61
- } = {}) {
62
- ensureContextManager();
63
- const { traceExporter, metricExporter } = buildExporters(exporter);
64
-
65
- const resourceAttrs = {
66
- 'service.name': resource.service || 'frigg',
67
- };
68
- if (resource.stage) {
69
- resourceAttrs['deployment.environment'] = resource.stage;
70
- resourceAttrs.stage = resource.stage;
59
+ class OtelTelemetry extends TelemetryServiceInterface {
60
+ constructor({
61
+ exporter,
62
+ resource = {},
63
+ sampleRatio,
64
+ bus = createTelemetryEventBus(),
65
+ } = {}) {
66
+ super();
67
+ ensureContextManager();
68
+ const { traceExporter, metricExporter } =
69
+ resolveExporter(exporter).build();
70
+
71
+ const resourceAttrs = { 'service.name': resource.service || 'frigg' };
72
+ if (resource.stage) {
73
+ resourceAttrs['deployment.environment'] = resource.stage;
74
+ resourceAttrs.stage = resource.stage;
75
+ }
76
+ if (resource.appName) resourceAttrs.appName = resource.appName;
77
+ const res = resourceFromAttributes(resourceAttrs);
78
+
79
+ const sampler =
80
+ typeof sampleRatio === 'number' && sampleRatio < 1
81
+ ? new ParentBasedSampler({
82
+ root: new TraceIdRatioBasedSampler(sampleRatio),
83
+ })
84
+ : new AlwaysOnSampler();
85
+
86
+ this._tracerProvider = new BasicTracerProvider({
87
+ resource: res,
88
+ sampler,
89
+ spanProcessors: traceExporter
90
+ ? [new BatchSpanProcessor(traceExporter)]
91
+ : [],
92
+ });
93
+
94
+ const metricReader = metricExporter
95
+ ? new PeriodicExportingMetricReader({
96
+ exporter: metricExporter,
97
+ exportIntervalMillis: METRIC_EXPORT_INTERVAL_MS,
98
+ })
99
+ : null;
100
+ this._meterProvider = new MeterProvider({
101
+ resource: res,
102
+ readers: metricReader ? [metricReader] : [],
103
+ });
104
+
105
+ this._bus = bus;
106
+ this._tracer = this._tracerProvider.getTracer(TRACER_NAME);
107
+ this._meter = this._meterProvider.getMeter(TRACER_NAME);
108
+ // One instrument instance per metric name — OTel requires it, and
109
+ // re-creating a counter would drop data points.
110
+ this._counters = new Map();
71
111
  }
72
- if (resource.appName) resourceAttrs.appName = resource.appName;
73
- const res = resourceFromAttributes(resourceAttrs);
74
112
 
75
- const sampler =
76
- typeof sampleRatio === 'number' && sampleRatio < 1
77
- ? new ParentBasedSampler({
78
- root: new TraceIdRatioBasedSampler(sampleRatio),
79
- })
80
- : new AlwaysOnSampler();
81
-
82
- const tracerProvider = new BasicTracerProvider({
83
- resource: res,
84
- sampler,
85
- spanProcessors: traceExporter
86
- ? [new BatchSpanProcessor(traceExporter)]
87
- : [],
88
- });
89
-
90
- const metricReader = metricExporter
91
- ? new PeriodicExportingMetricReader({
92
- exporter: metricExporter,
93
- exportIntervalMillis: METRIC_EXPORT_INTERVAL_MS,
94
- })
95
- : null;
96
- const meterProvider = new MeterProvider({
97
- resource: res,
98
- readers: metricReader ? [metricReader] : [],
99
- });
100
-
101
- const tracer = tracerProvider.getTracer(TRACER_NAME);
102
- const meter = meterProvider.getMeter(TRACER_NAME);
103
-
104
- // Counters/UpDownCounters are cached — OTel requires one instrument instance
105
- // per metric name, and re-creating them would drop data points.
106
- const counters = new Map();
107
- function getCounter(name) {
108
- if (!counters.has(name)) counters.set(name, meter.createCounter(name));
109
- return counters.get(name);
113
+ _getCounter(name) {
114
+ if (!this._counters.has(name)) {
115
+ this._counters.set(name, this._meter.createCounter(name));
116
+ }
117
+ return this._counters.get(name);
110
118
  }
111
119
 
112
- const service = {
113
- count(name, value = 1, attributes = {}, context) {
120
+ count(name, value = 1, attributes = {}, context) {
121
+ try {
122
+ this._getCounter(name).add(value, attributes);
123
+ } catch (_) {
124
+ // Telemetry must never break the wrapped path.
125
+ }
126
+ // Mirror onto the internal stream for the usage rollup + plugin taps; the
127
+ // bus context (incl. high-cardinality ids) rides the bus only, never the
128
+ // OTel metric attributes.
129
+ const merged = mergeTelemetryContext(context);
130
+ const payload = { name, value, attributes };
131
+ if (merged) payload.context = merged;
132
+ this._bus.emit('metric', payload);
133
+ }
134
+
135
+ event(name, attributes = {}, context) {
136
+ try {
137
+ const active = otelApi.trace.getActiveSpan();
138
+ if (active) active.addEvent(name, attributes);
139
+ } catch (_) {}
140
+ const merged = mergeTelemetryContext(context);
141
+ const payload = { name, attributes };
142
+ if (merged) payload.context = merged;
143
+ this._bus.emit('event', payload);
144
+ }
145
+
146
+ startSpan(name, options) {
147
+ return this._tracer.startSpan(name, options);
148
+ }
149
+
150
+ async span(name, fn, options = {}) {
151
+ return this._tracer.startActiveSpan(name, options, async (span) => {
114
152
  try {
115
- getCounter(name).add(value, attributes);
116
- } catch (_) {
117
- // Telemetry must never break the wrapped path.
153
+ const result =
154
+ typeof fn === 'function' ? await fn(span) : undefined;
155
+ span.setStatus({ code: otelApi.SpanStatusCode.OK });
156
+ return result;
157
+ } catch (err) {
158
+ span.recordException(err);
159
+ span.setStatus({
160
+ code: otelApi.SpanStatusCode.ERROR,
161
+ message: err && err.message,
162
+ });
163
+ throw err;
164
+ } finally {
165
+ span.end();
118
166
  }
119
- // Mirror onto the internal stream for the usage rollup + plugin taps.
120
- // The bus `context` merges the ambient handler context (incl.
121
- // high-cardinality ids) with any explicit per-call context — it rides
122
- // the bus only, never the OTel metric attributes (Cardinality note).
123
- const merged = mergeTelemetryContext(context);
124
- const payload = { name, value, attributes };
125
- if (merged) payload.context = merged;
126
- bus.emit('metric', payload);
127
- },
128
-
129
- event(name, attributes = {}, context) {
130
- try {
131
- const active = otelApi.trace.getActiveSpan();
132
- if (active) active.addEvent(name, attributes);
133
- } catch (_) {}
134
- const merged = mergeTelemetryContext(context);
135
- const payload = { name, attributes };
136
- if (merged) payload.context = merged;
137
- bus.emit('event', payload);
138
- },
139
-
140
- startSpan(name, options) {
141
- return tracer.startSpan(name, options);
142
- },
143
-
144
- async span(name, fn, options = {}) {
145
- return tracer.startActiveSpan(name, options, async (span) => {
146
- try {
147
- const result =
148
- typeof fn === 'function' ? await fn(span) : undefined;
149
- span.setStatus({ code: otelApi.SpanStatusCode.OK });
150
- return result;
151
- } catch (err) {
152
- span.recordException(err);
153
- span.setStatus({
154
- code: otelApi.SpanStatusCode.ERROR,
155
- message: err && err.message,
156
- });
157
- throw err;
158
- } finally {
159
- span.end();
160
- }
161
- });
162
- },
163
-
164
- /**
165
- * Run `fn` with the given identifiers on (a) the AsyncLocalStorage
166
- * telemetry context — which the usage rollup reads to attribute emissions
167
- * per-integration on ANY path — and (b) OTel baggage for trace
168
- * propagation. High-cardinality ids ride here, never on metric labels.
169
- */
170
- async withContext(attributes = {}, fn) {
171
- const entries = {};
172
- for (const [key, value] of Object.entries(attributes)) {
173
- if (value !== undefined && value !== null) {
174
- entries[key] = { value: String(value) };
175
- }
167
+ });
168
+ }
169
+
170
+ /**
171
+ * Run `fn` with the given identifiers on (a) the AsyncLocalStorage telemetry
172
+ * context — which the usage rollup reads to attribute emissions per-integration
173
+ * on ANY path — and (b) OTel baggage for trace propagation. High-cardinality
174
+ * ids ride here, never on metric labels.
175
+ */
176
+ async withContext(attributes = {}, fn) {
177
+ const entries = {};
178
+ for (const [key, value] of Object.entries(attributes)) {
179
+ if (value !== undefined && value !== null) {
180
+ entries[key] = { value: String(value) };
176
181
  }
177
- const baggage = otelApi.propagation.createBaggage(entries);
178
- const ctx = otelApi.propagation.setBaggage(
179
- otelApi.context.active(),
180
- baggage
181
- );
182
- return runWithTelemetryContext(attributes, () =>
183
- otelApi.context.with(ctx, () =>
184
- typeof fn === 'function' ? fn() : undefined
185
- )
186
- );
187
- },
188
-
189
- on(eventType, callback) {
190
- return bus.on(eventType, callback);
191
- },
192
-
193
- async forceFlush() {
194
- await Promise.allSettled([
195
- tracerProvider.forceFlush(),
196
- meterProvider.forceFlush(),
197
- ]);
198
- },
199
-
200
- async shutdown() {
201
- await Promise.allSettled([
202
- tracerProvider.shutdown(),
203
- meterProvider.shutdown(),
204
- ]);
205
- },
206
-
207
- isEnabled() {
208
- return true;
209
- },
210
- };
211
-
212
- return service;
182
+ }
183
+ const baggage = otelApi.propagation.createBaggage(entries);
184
+ const ctx = otelApi.propagation.setBaggage(
185
+ otelApi.context.active(),
186
+ baggage
187
+ );
188
+ return runWithTelemetryContext(attributes, () =>
189
+ otelApi.context.with(ctx, () =>
190
+ typeof fn === 'function' ? fn() : undefined
191
+ )
192
+ );
193
+ }
194
+
195
+ on(eventType, callback) {
196
+ return this._bus.on(eventType, callback);
197
+ }
198
+
199
+ async forceFlush() {
200
+ await Promise.allSettled([
201
+ this._tracerProvider.forceFlush(),
202
+ this._meterProvider.forceFlush(),
203
+ ]);
204
+ }
205
+
206
+ async shutdown() {
207
+ await Promise.allSettled([
208
+ this._tracerProvider.shutdown(),
209
+ this._meterProvider.shutdown(),
210
+ ]);
211
+ }
212
+
213
+ isEnabled() {
214
+ return true;
215
+ }
213
216
  }
214
217
 
215
- module.exports = { createOtelTelemetry };
218
+ module.exports = { OtelTelemetry };
@@ -37,6 +37,8 @@ function resolveExporter(exporter, stage) {
37
37
  return exporter;
38
38
  }
39
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.
40
42
  function resolveSampleRatio(sampleRatio) {
41
43
  if (sampleRatio === undefined || sampleRatio === null) return 1;
42
44
  if (
@@ -61,7 +63,7 @@ function validateNorthStarEntry(entry, where) {
61
63
  }
62
64
 
63
65
  /**
64
- * Adopter-declared telemetry subscribers (Decision 6). Each is either a factory
66
+ * Adopter-declared telemetry subscribers. Each is either a factory
65
67
  * function `fn(telemetry)` or a declarative `{ event?, handler }` object. Kept as
66
68
  * an array so `wireTelemetrySubscribers` can attach them to the bus at runtime.
67
69
  */
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Telemetry service port. Application code uses this contract via `this.telemetry`;
3
+ * adapters implement it — `OtelTelemetry` (real OTLP export) and `NoOpTelemetry`
4
+ * (default, zero OTel). Kept free of OpenTelemetry imports so the no-op path
5
+ * loads no OTel modules.
6
+ *
7
+ * `count`/`event` mirror onto the internal event bus (for the usage rollup +
8
+ * plugin taps); `span`/`startSpan`/`withContext` handle tracing + context
9
+ * propagation; `forceFlush`/`shutdown` drain before the Lambda container freezes.
10
+ */
11
+ class TelemetryServiceInterface {
12
+ count(/* name, value, attributes, context */) {
13
+ throw new Error('count must be implemented by subclass');
14
+ }
15
+
16
+ event(/* name, attributes, context */) {
17
+ throw new Error('event must be implemented by subclass');
18
+ }
19
+
20
+ startSpan(/* name, options */) {
21
+ throw new Error('startSpan must be implemented by subclass');
22
+ }
23
+
24
+ async span(/* name, fn, options */) {
25
+ throw new Error('span must be implemented by subclass');
26
+ }
27
+
28
+ async withContext(/* attributes, fn */) {
29
+ throw new Error('withContext must be implemented by subclass');
30
+ }
31
+
32
+ on(/* eventType, callback */) {
33
+ throw new Error('on must be implemented by subclass');
34
+ }
35
+
36
+ async forceFlush() {
37
+ throw new Error('forceFlush must be implemented by subclass');
38
+ }
39
+
40
+ async shutdown() {
41
+ throw new Error('shutdown must be implemented by subclass');
42
+ }
43
+
44
+ isEnabled() {
45
+ throw new Error('isEnabled must be implemented by subclass');
46
+ }
47
+ }
48
+
49
+ module.exports = { TelemetryServiceInterface };
@@ -1,4 +1,4 @@
1
- const { createNoOpTelemetry } = require('./no-op-telemetry');
1
+ const { NoOpTelemetry } = require('./no-op-telemetry');
2
2
  const { createTelemetryEventBus } = require('./telemetry-event-bus');
3
3
 
4
4
  /**
@@ -12,12 +12,10 @@ function isNoOpExporter(exporter) {
12
12
  }
13
13
 
14
14
  /**
15
- * Create the vendor-neutral telemetry service.
16
- *
17
- * Integration code uses the returned object (`this.telemetry`) and never imports
18
- * a backend SDK. When no exporter is configured the service is a pure no-op that
19
- * loads zero OpenTelemetry modules; a real exporter lazily initialises the OTel
20
- * SDK (see `./otel-telemetry`).
15
+ * Create the vendor-neutral telemetry service — the adapter selector. Integration
16
+ * code uses the returned instance (`this.telemetry`) and never imports a backend
17
+ * SDK. With no exporter configured it returns `NoOpTelemetry` (loads zero OTel);
18
+ * a real exporter lazily constructs `OtelTelemetry`.
21
19
  *
22
20
  * @param {object} [options]
23
21
  * @param {object} [options.exporter] Exporter descriptor, e.g. `{ type: 'otlp', endpoint }`.
@@ -33,12 +31,12 @@ function createTelemetry(options = {}) {
33
31
  const bus = options.bus || createTelemetryEventBus();
34
32
 
35
33
  if (isNoOpExporter(options.exporter)) {
36
- return createNoOpTelemetry({ bus });
34
+ return new NoOpTelemetry({ bus });
37
35
  }
38
36
 
39
37
  // Lazy-required so the no-op path never loads the OTel SDK.
40
- const { createOtelTelemetry } = require('./otel-telemetry');
41
- return createOtelTelemetry({ ...options, bus });
38
+ const { OtelTelemetry } = require('./otel-telemetry');
39
+ return new OtelTelemetry({ ...options, bus });
42
40
  }
43
41
 
44
42
  module.exports = { createTelemetry, isNoOpExporter };
@@ -1,5 +1,5 @@
1
1
  const { createTelemetry } = require('./telemetry-service');
2
- const { createNoOpTelemetry } = require('./no-op-telemetry');
2
+ const { NoOpTelemetry } = require('./no-op-telemetry');
3
3
 
4
4
  /**
5
5
  * Process-wide telemetry singleton. Created lazily on first use and
@@ -28,7 +28,7 @@ function getTelemetry() {
28
28
  },
29
29
  });
30
30
  } catch (_) {
31
- instance = createNoOpTelemetry();
31
+ instance = new NoOpTelemetry();
32
32
  }
33
33
  return instance;
34
34
  }
package/usage/README.md CHANGED
@@ -32,21 +32,21 @@ const { createUsageRepository } = require('@friggframework/core'); // usage-repo
32
32
 
33
33
  class UsageRepositoryInterface {
34
34
  async increment({ integrationId, integrationType, metric, window, value }) {} // atomic upsert
35
- async totals({ metric, groupBy, since, bucket }) {} // comparison (one window granularity)
36
- async series({ metric, integrationType, from, to, bucket }) {} // trend (aggregated across instances)
35
+ async getTotalsByDimension({ metric, groupBy, since, bucket }) {} // comparison (one window granularity)
36
+ async getTimeSeries({ metric, integrationType, from, to, bucket }) {} // trend (aggregated across instances)
37
37
  }
38
38
  ```
39
39
 
40
40
  - **`increment`** is atomic: PostgreSQL uses Prisma `upsert` with
41
41
  `{ value: { increment } }`; a concurrent first-insert race (`P2002`) retries
42
42
  once onto the atomic update path.
43
- - **`totals`** filters to a single window granularity (default `day`) so day and
43
+ - **`getTotalsByDimension`** filters to a single window granularity (default `day`) so day and
44
44
  hour rows are never double-summed. Requires a `metric`; `groupBy` is
45
45
  allow-listed to `integrationType` / `metric`.
46
- - **`series`** aggregates across integration instances (`groupBy(window) + sum`)
46
+ - **`getTimeSeries`** aggregates across integration instances (`groupBy(window) + sum`)
47
47
  and range-filters on the window key. Requires an `integrationType`.
48
48
 
49
- > **DocumentDB:** the adapter overrides increment/totals/series with raw commands
49
+ > **DocumentDB:** the adapter overrides increment/getTotalsByDimension/getTimeSeries with raw commands
50
50
  > (`$runCommandRaw`: a `$inc` upsert via `documentdb-utils.updateOne`, and a
51
51
  > cursor-drained `$aggregate` `$group/$sum`) — matching every other DocumentDB
52
52
  > adapter, since Prisma's Mongo engine emits upsert/groupBy shapes DocumentDB
@@ -14,7 +14,7 @@ const VALID_BUCKETS = new Set(['day', 'hour']);
14
14
  * and `groupBy({ _sum })`, and cursor reads truncate at ~101 docs — so, like
15
15
  * every other DocumentDB adapter in this repo, these operations are issued as
16
16
  * raw commands (`$runCommandRaw`) via the validated documentdb-utils helpers and
17
- * a drained aggregate cursor. No timestamps are managed: totals/series filter on
17
+ * a drained aggregate cursor. No timestamps are managed: getTotalsByDimension/getTimeSeries filter on
18
18
  * the window KEY (not write-time), so createdAt/updatedAt are unnecessary here.
19
19
  */
20
20
  class UsageRepositoryDocumentDB extends UsageRepositoryMongo {
@@ -44,20 +44,20 @@ class UsageRepositoryDocumentDB extends UsageRepositoryMongo {
44
44
  );
45
45
  }
46
46
 
47
- async totals({
47
+ async getTotalsByDimension({
48
48
  metric,
49
49
  groupBy = 'integrationType',
50
50
  since,
51
51
  bucket = 'day',
52
52
  } = {}) {
53
53
  if (!metric) {
54
- throw new Error('totals requires a metric (units are per-metric)');
54
+ throw new Error('getTotalsByDimension requires a metric (units are per-metric)');
55
55
  }
56
56
  assertGroupBy(groupBy);
57
57
  assertBucket(bucket);
58
58
 
59
59
  // Single window granularity (day: OR hour:) — never sum across both.
60
- // `since` bounds on the window KEY (mirrors series / the Prisma adapter).
60
+ // `since` bounds on the window KEY (mirrors getTimeSeries / the Prisma adapter).
61
61
  const rows = await this._aggregateDrained([
62
62
  {
63
63
  $match: {
@@ -76,10 +76,10 @@ class UsageRepositoryDocumentDB extends UsageRepositoryMongo {
76
76
  }));
77
77
  }
78
78
 
79
- async series({ metric, integrationType, from, to, bucket = 'day' } = {}) {
79
+ async getTimeSeries({ metric, integrationType, from, to, bucket = 'day' } = {}) {
80
80
  assertBucket(bucket);
81
81
  if (!integrationType) {
82
- throw new Error('series requires an integrationType');
82
+ throw new Error('getTimeSeries requires an integrationType');
83
83
  }
84
84
 
85
85
  const rows = await this._aggregateDrained([
@@ -21,16 +21,16 @@ class UsageRepositoryInterface {
21
21
  * Sum a metric grouped by a bounded dimension since an optional timestamp.
22
22
  * @returns {Promise<Array<{[groupBy]: string, value: number}>>}
23
23
  */
24
- async totals(/* { metric, groupBy, since } */) {
25
- throw new Error('totals must be implemented by subclass');
24
+ async getTotalsByDimension(/* { metric, groupBy, since } */) {
25
+ throw new Error('getTotalsByDimension must be implemented by subclass');
26
26
  }
27
27
 
28
28
  /**
29
29
  * Time series of a metric for one integration type.
30
30
  * @returns {Promise<Array<{bucket: string, value: number}>>}
31
31
  */
32
- async series(/* { metric, integrationType, from, to, bucket } */) {
33
- throw new Error('series must be implemented by subclass');
32
+ async getTimeSeries(/* { metric, integrationType, from, to, bucket } */) {
33
+ throw new Error('getTimeSeries must be implemented by subclass');
34
34
  }
35
35
  }
36
36
 
@@ -52,14 +52,14 @@ class UsageRepositoryPostgres extends UsageRepositoryInterface {
52
52
  }
53
53
  }
54
54
 
55
- async totals({
55
+ async getTotalsByDimension({
56
56
  metric,
57
57
  groupBy = 'integrationType',
58
58
  since,
59
59
  bucket = 'day',
60
60
  } = {}) {
61
61
  if (!metric) {
62
- throw new Error('totals requires a metric (units are per-metric)');
62
+ throw new Error('getTotalsByDimension requires a metric (units are per-metric)');
63
63
  }
64
64
  assertGroupBy(groupBy);
65
65
  assertBucket(bucket);
@@ -67,7 +67,7 @@ class UsageRepositoryPostgres extends UsageRepositoryInterface {
67
67
  // Filter to ONE window granularity — every event is written to both a
68
68
  // day: and an hour: row, so summing across granularities would double
69
69
  // (or worse) the true count.
70
- // Bound `since` on the WINDOW key (mirrors series) — write-time
70
+ // Bound `since` on the WINDOW key (mirrors getTimeSeries) — write-time
71
71
  // updatedAt would misplace a late/redelivered increment for an earlier
72
72
  // window, over- or under-counting the time-bounded total.
73
73
  const where = { metric, window: { startsWith: `${bucket}:` } };
@@ -87,10 +87,10 @@ class UsageRepositoryPostgres extends UsageRepositoryInterface {
87
87
  }));
88
88
  }
89
89
 
90
- async series({ metric, integrationType, from, to, bucket = 'day' } = {}) {
90
+ async getTimeSeries({ metric, integrationType, from, to, bucket = 'day' } = {}) {
91
91
  assertBucket(bucket);
92
92
  if (!integrationType) {
93
- throw new Error('series requires an integrationType');
93
+ throw new Error('getTimeSeries requires an integrationType');
94
94
  }
95
95
 
96
96
  // Range-filter on the WINDOW key (write-time `updatedAt` would misplace a
@@ -1,79 +0,0 @@
1
- /**
2
- * Builds OpenTelemetry span/metric exporters from an app-definition descriptor.
3
- * All OTel packages are lazy-`require`d here so the no-op
4
- * path (which never calls this) loads zero OTel modules.
5
- *
6
- * Supported descriptor `type`s:
7
- * - 'console' → ConsoleSpanExporter / ConsoleMetricExporter (local dev)
8
- * - 'otlp' → OTLP/HTTP to descriptor.endpoint (+ headers)
9
- * - 'honeycomb' → OTLP preset (api.honeycomb.io, x-honeycomb-team header)
10
- * - 'datadog' → OTLP preset (descriptor.endpoint required)
11
- *
12
- * A descriptor may also carry pre-built exporter instances
13
- * (`traceExporter` / `metricExporter`) — used by tests (in-memory exporters) and
14
- * advanced adopters. Instances win over `type`.
15
- *
16
- * @returns {{ traceExporter: object|null, metricExporter: object|null }}
17
- */
18
- function buildExporters(descriptor = {}) {
19
- if (descriptor.traceExporter || descriptor.metricExporter) {
20
- return {
21
- traceExporter: descriptor.traceExporter || null,
22
- metricExporter: descriptor.metricExporter || null,
23
- };
24
- }
25
-
26
- const type = descriptor.type;
27
-
28
- if (type === 'console') {
29
- const {
30
- ConsoleSpanExporter,
31
- } = require('@opentelemetry/sdk-trace-base');
32
- const { ConsoleMetricExporter } = require('@opentelemetry/sdk-metrics');
33
- return {
34
- traceExporter: new ConsoleSpanExporter(),
35
- metricExporter: new ConsoleMetricExporter(),
36
- };
37
- }
38
-
39
- const { endpoint, headers } = resolveOtlpTarget(descriptor);
40
- const {
41
- OTLPTraceExporter,
42
- } = require('@opentelemetry/exporter-trace-otlp-http');
43
- const {
44
- OTLPMetricExporter,
45
- } = require('@opentelemetry/exporter-metrics-otlp-http');
46
-
47
- // When no endpoint is set the exporters fall back to the standard
48
- // OTEL_EXPORTER_OTLP_ENDPOINT env var, so leave `url` undefined in that case.
49
- return {
50
- traceExporter: new OTLPTraceExporter({
51
- ...(endpoint ? { url: joinPath(endpoint, '/v1/traces') } : {}),
52
- ...(headers ? { headers } : {}),
53
- }),
54
- metricExporter: new OTLPMetricExporter({
55
- ...(endpoint ? { url: joinPath(endpoint, '/v1/metrics') } : {}),
56
- ...(headers ? { headers } : {}),
57
- }),
58
- };
59
- }
60
-
61
- /** Resolve endpoint + headers for OTLP and its vendor presets. */
62
- function resolveOtlpTarget(descriptor) {
63
- if (descriptor.type === 'honeycomb') {
64
- return {
65
- endpoint: descriptor.endpoint || 'https://api.honeycomb.io',
66
- headers: descriptor.apiKey
67
- ? { 'x-honeycomb-team': descriptor.apiKey }
68
- : descriptor.headers,
69
- };
70
- }
71
- // 'otlp' and 'datadog' both use a plain OTLP endpoint + optional headers.
72
- return { endpoint: descriptor.endpoint, headers: descriptor.headers };
73
- }
74
-
75
- function joinPath(base, path) {
76
- return `${String(base).replace(/\/$/, '')}${path}`;
77
- }
78
-
79
- module.exports = { buildExporters };