@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 +1 -1
- package/application/commands/usage-commands.js +11 -32
- package/application/index.js +5 -9
- package/package.json +5 -5
- package/reporting/use-cases/list-integrations-report.js +1 -1
- package/telemetry/README.md +25 -11
- package/telemetry/exporters/console-exporter.js +19 -0
- package/telemetry/exporters/datadog-exporter.js +11 -0
- package/telemetry/exporters/honeycomb-exporter.js +13 -0
- package/telemetry/exporters/otlp-exporter.js +42 -0
- package/telemetry/exporters/passthrough-exporter.js +25 -0
- package/telemetry/exporters/resolve-exporter.js +39 -0
- package/telemetry/exporters/telemetry-exporter-interface.js +17 -0
- package/telemetry/index.js +8 -4
- package/telemetry/no-op-telemetry.js +61 -56
- package/telemetry/north-star.js +1 -1
- package/telemetry/otel-telemetry.js +160 -157
- package/telemetry/telemetry-config.js +3 -1
- package/telemetry/telemetry-service-interface.js +49 -0
- package/telemetry/telemetry-service.js +8 -10
- package/telemetry/telemetry-singleton.js +2 -2
- package/usage/README.md +5 -5
- package/usage/repositories/usage-repository-documentdb.js +6 -6
- package/usage/repositories/usage-repository-interface.js +4 -4
- package/usage/repositories/usage-repository-postgres.js +5 -5
- package/telemetry/exporters/exporter-factory.js +0 -79
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.
|
|
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
|
|
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
|
|
49
|
-
return repository.
|
|
32
|
+
async getTotalsByDimension(args) {
|
|
33
|
+
return repository.getTotalsByDimension(args);
|
|
50
34
|
},
|
|
51
35
|
|
|
52
|
-
async
|
|
53
|
-
return repository.
|
|
36
|
+
async getTimeSeries(args) {
|
|
37
|
+
return repository.getTimeSeries(args);
|
|
54
38
|
},
|
|
55
39
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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.
|
|
45
|
+
const totals = await repository.getTotalsByDimension({
|
|
67
46
|
metric: entry.name,
|
|
68
47
|
groupBy,
|
|
69
48
|
since,
|
package/application/index.js
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
62
|
-
|
|
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.
|
|
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.
|
|
49
|
-
"@friggframework/prettier-config": "2.0.0--canary.622.
|
|
50
|
-
"@friggframework/test": "2.0.0--canary.622.
|
|
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": "
|
|
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.
|
|
121
|
+
const totals = await this.usageRepository.getTotalsByDimension({
|
|
122
122
|
metric,
|
|
123
123
|
groupBy: 'integrationType',
|
|
124
124
|
});
|
package/telemetry/README.md
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
-
//
|
|
215
|
-
|
|
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
|
|
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.
|
|
235
|
+
`frigg.usage.getTotalsByDimension({ metric: 'contacts_synced' })`; trends via `frigg.usage.getTimeSeries({ metric })`.
|
|
222
236
|
|
|
223
|
-
>
|
|
224
|
-
>
|
|
225
|
-
>
|
|
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.
|
|
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 };
|
package/telemetry/index.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
const { createTelemetry, isNoOpExporter } = require('./telemetry-service');
|
|
2
|
-
const {
|
|
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 {
|
|
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
|
-
|
|
38
|
+
TelemetryServiceInterface,
|
|
39
|
+
NoOpTelemetry,
|
|
36
40
|
createTelemetryEventBus,
|
|
37
41
|
resolveTelemetryConfig,
|
|
38
42
|
getTelemetry,
|
|
39
43
|
resetTelemetryForTests,
|
|
40
44
|
isNoOpExporter,
|
|
41
|
-
|
|
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
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
)
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}
|
|
63
|
-
|
|
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 = {
|
|
71
|
+
module.exports = { NoOpTelemetry };
|
package/telemetry/north-star.js
CHANGED
|
@@ -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.
|
|
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 {
|
|
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
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
113
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
)
|
|
186
|
-
)
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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 = {
|
|
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
|
|
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 {
|
|
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
|
-
*
|
|
18
|
-
* a
|
|
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
|
|
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 {
|
|
41
|
-
return
|
|
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 {
|
|
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 =
|
|
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
|
|
36
|
-
async
|
|
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
|
-
- **`
|
|
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
|
-
- **`
|
|
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/
|
|
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:
|
|
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
|
|
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('
|
|
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
|
|
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
|
|
79
|
+
async getTimeSeries({ metric, integrationType, from, to, bucket = 'day' } = {}) {
|
|
80
80
|
assertBucket(bucket);
|
|
81
81
|
if (!integrationType) {
|
|
82
|
-
throw new Error('
|
|
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
|
|
25
|
-
throw new Error('
|
|
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
|
|
33
|
-
throw new Error('
|
|
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
|
|
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('
|
|
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
|
|
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
|
|
90
|
+
async getTimeSeries({ metric, integrationType, from, to, bucket = 'day' } = {}) {
|
|
91
91
|
assertBucket(bucket);
|
|
92
92
|
if (!integrationType) {
|
|
93
|
-
throw new Error('
|
|
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 };
|