@fougere/observability 0.9.2-alpha.0 → 0.9.3-alpha.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/dist/extension.d.ts +19 -0
- package/dist/extension.d.ts.map +1 -1
- package/dist/extension.js +29 -6
- package/dist/extension.js.map +1 -1
- package/dist/index.d.ts +84 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +103 -10
- package/dist/index.js.map +1 -1
- package/dist/metrics.d.ts +13 -3
- package/dist/metrics.d.ts.map +1 -1
- package/dist/metrics.js +90 -20
- package/dist/metrics.js.map +1 -1
- package/dist/otlp.d.ts.map +1 -1
- package/dist/otlp.js +7 -1
- package/dist/otlp.js.map +1 -1
- package/package.json +14 -7
- package/src/extension.ts +50 -6
- package/src/index.ts +183 -10
- package/src/metrics.ts +96 -20
- package/src/otlp.ts +8 -1
package/src/index.ts
CHANGED
|
@@ -6,8 +6,24 @@ import { parseTraceparent, traceparentOf, randomHex, type SpanContext } from './
|
|
|
6
6
|
/** A step while it runs. */
|
|
7
7
|
interface Running extends SpanContext {
|
|
8
8
|
frond: string | undefined;
|
|
9
|
+
/**
|
|
10
|
+
* When this step began, on both clocks — carried so what runs UNDER it is dated in ITS
|
|
11
|
+
* frame rather than on a clock of its own. `Date.now()` resolves to the millisecond and a
|
|
12
|
+
* whole call can take a fraction of one, so a statement dating itself lands anywhere:
|
|
13
|
+
* measured on SigNoz 2026-09-13, six queries drawn 17 µs to the LEFT of the call that made
|
|
14
|
+
* them, then all six stacked on its first instant. Offset from the monotonic clock, they
|
|
15
|
+
* fall where they ran.
|
|
16
|
+
*/
|
|
17
|
+
startedAt: number;
|
|
18
|
+
start: number;
|
|
9
19
|
}
|
|
10
20
|
|
|
21
|
+
/**
|
|
22
|
+
* What a span is a span OF. An operation was dispatched and has an address; a statement ran
|
|
23
|
+
* underneath one and has a table. A reader that counts operations has to say which it takes.
|
|
24
|
+
*/
|
|
25
|
+
export type SpanKind = 'operation' | 'statement';
|
|
26
|
+
|
|
11
27
|
/** A step that has finished, and what it did. */
|
|
12
28
|
export interface FinishedSpan extends SpanContext {
|
|
13
29
|
parentId: string | undefined;
|
|
@@ -18,17 +34,80 @@ export interface FinishedSpan extends SpanContext {
|
|
|
18
34
|
callerFrond: string | undefined;
|
|
19
35
|
/** Which frond owned the op — the deployment unit, so the first thing a reader groups by. */
|
|
20
36
|
frond: string | undefined;
|
|
37
|
+
kind: SpanKind;
|
|
21
38
|
entity: string;
|
|
22
39
|
operation: string;
|
|
23
40
|
/** When it started, in epoch milliseconds — an INSTANT, not an offset. */
|
|
24
41
|
startedAt: number;
|
|
25
42
|
ms: number;
|
|
43
|
+
/**
|
|
44
|
+
* `ms` minus what its OBSERVED children account for — the time this step spent itself. An op
|
|
45
|
+
* that delegates everything reports a duration and almost no self, which is how a report
|
|
46
|
+
* names the step that is actually slow instead of the one that waited for it.
|
|
47
|
+
*
|
|
48
|
+
* Never below zero, and that is not a guard: children running TOGETHER — the four queries
|
|
49
|
+
* behind one `Promise.all` — add up to more than the step that holds them. What is
|
|
50
|
+
* measured is the time nothing else accounts for, and there is no less of it than none.
|
|
51
|
+
*
|
|
52
|
+
* A child that finishes AFTER its parent is not deducted either: nothing was observed when
|
|
53
|
+
* the subtraction ran. The figure stays true, it is only cleaner than it should be.
|
|
54
|
+
*/
|
|
55
|
+
selfMs: number;
|
|
56
|
+
/** How many statements ran under this step — one per row is the shape of an N+1. */
|
|
57
|
+
statements: number;
|
|
26
58
|
/** The FougereError code when it refused, absent when it answered. */
|
|
27
59
|
error: string | undefined;
|
|
28
60
|
}
|
|
29
61
|
|
|
30
62
|
export type SpanSink = (span: FinishedSpan) => void;
|
|
31
63
|
|
|
64
|
+
/** A statement as its producer describes it — never the values it was given. */
|
|
65
|
+
export interface Statement {
|
|
66
|
+
/** What it read or wrote: the table, or the source when the statement names none. */
|
|
67
|
+
subject: string;
|
|
68
|
+
/** `select`, `insert`, `update`, `delete`. */
|
|
69
|
+
verb: string;
|
|
70
|
+
ms: number;
|
|
71
|
+
failed: boolean;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** What an app's tracer answers for. */
|
|
75
|
+
export interface Tracing {
|
|
76
|
+
/** One span per operation — handed to `app.use`. */
|
|
77
|
+
middleware: AppMiddleware;
|
|
78
|
+
/** A statement that ran under the operation in flight: its time charged upward, and its own span when asked. */
|
|
79
|
+
statement(ran: Statement): void;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface TracingOptions {
|
|
83
|
+
/**
|
|
84
|
+
* A span of its OWN for every statement, and not only the time charged to the operation
|
|
85
|
+
* above it. Off by default, and the default is not prudence: `selfMs` and `statements` are
|
|
86
|
+
* counted either way, so what this adds is WHICH queries ran — a diagnosis, against a
|
|
87
|
+
* volume that multiplies by however many rows the page held. A backend charges per span.
|
|
88
|
+
*
|
|
89
|
+
* The pair splits the way profiling splits from monitoring: the count DETECTS, and it runs
|
|
90
|
+
* always; the detail EXPLAINS, and it is turned on over the operation the count named.
|
|
91
|
+
*/
|
|
92
|
+
spanPerStatement?: boolean;
|
|
93
|
+
/**
|
|
94
|
+
* What share of the operations that stay in this process are traced, `0` to `1`.
|
|
95
|
+
*
|
|
96
|
+
* A rate is a BUDGET — how much a backend is worth per day — and no reading of the code
|
|
97
|
+
* answers it, so it is the operator's to set. What the code does answer is which spans the
|
|
98
|
+
* budget may not touch: an operation that crosses a process produces the one signal nothing
|
|
99
|
+
* else carries, since the difference between the caller's span and the callee's IS the wire
|
|
100
|
+
* cost, and neither process can measure it alone. So a rate below one thins what is already
|
|
101
|
+
* described by its own histogram, and never what only a trace can show.
|
|
102
|
+
*
|
|
103
|
+
* Default `1`, which is what a process did before this existed. The gain is not the default:
|
|
104
|
+
* it is that turning the rate down no longer drops the traces that matter most.
|
|
105
|
+
*/
|
|
106
|
+
sample?: number;
|
|
107
|
+
/** How far an operation's work goes, by `entity.op` — the boot reads it off the model. */
|
|
108
|
+
hopsOf?: (entity: string, operation: string) => number;
|
|
109
|
+
}
|
|
110
|
+
|
|
32
111
|
/** The step running here and now. */
|
|
33
112
|
|
|
34
113
|
/** The step running right now, if any. */
|
|
@@ -68,15 +147,33 @@ export function registerFlush(send: () => Promise<void>): () => void {
|
|
|
68
147
|
}
|
|
69
148
|
|
|
70
149
|
/**
|
|
71
|
-
* The
|
|
150
|
+
* The tracer of ONE app, and it is HANDED who takes the spans it finishes.
|
|
72
151
|
*
|
|
73
152
|
* The list belongs to the app the middleware was installed on, never to the process: a
|
|
74
153
|
* discarded app went on feeding the takers of the app that replaced it, so every metric
|
|
75
154
|
* counted twice. A module-level array made that a leak you had to remember to undo; a list
|
|
76
155
|
* held beside the app cannot outlive it. Read at every end, so a taker added later counts.
|
|
156
|
+
*
|
|
157
|
+
* Two doors and not one, because a statement is not dispatched: nothing calls a middleware
|
|
158
|
+
* around a query. What both need is the same table — the time a step's children accounted
|
|
159
|
+
* for — so the two sit in one closure rather than reaching for each other.
|
|
160
|
+
*
|
|
161
|
+
* Documented: [observability](https://fougere.dev/docs/infra/observability).
|
|
77
162
|
*/
|
|
78
|
-
export function
|
|
79
|
-
|
|
163
|
+
export function tracing(takers: readonly SpanSink[], options: TracingOptions = {}): Tracing {
|
|
164
|
+
/** What a step's children have accounted for, by the step they ran under. */
|
|
165
|
+
const charged = new Map<string, { ms: number; statements: number }>();
|
|
166
|
+
const rate = options.sample ?? 1;
|
|
167
|
+
const hopsOf = options.hopsOf ?? (() => 0);
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Whether a root span is kept. An operation that leaves the process always is — the budget is
|
|
171
|
+
* for the ones a histogram already describes.
|
|
172
|
+
*/
|
|
173
|
+
const keeps = (entity: string, operation: string) =>
|
|
174
|
+
rate >= 1 || hopsOf(entity, operation) > 0 || Math.random() < rate;
|
|
175
|
+
|
|
176
|
+
const middleware: AppMiddleware = (ctx, next) => {
|
|
80
177
|
if (takers.length === 0) return next();
|
|
81
178
|
// An op that CARRIES a line is not a call this process made: counting it puts the
|
|
82
179
|
// delivery of a log line in the saturation figure, and spanning it puts a line about
|
|
@@ -88,11 +185,20 @@ export function trace(takers: readonly SpanSink[]): AppMiddleware {
|
|
|
88
185
|
const inherited = parseTraceparent(ctx.invocation?.trace);
|
|
89
186
|
const ambient = traceContext.current<Running>();
|
|
90
187
|
const parent = inherited ?? ambient;
|
|
188
|
+
// Both, and they are not the same measurement: the wall clock says WHEN so two
|
|
189
|
+
// processes land on one timeline, the monotonic one says HOW LONG without being
|
|
190
|
+
// moved by an NTP correction mid-call.
|
|
191
|
+
const startedAt = Date.now();
|
|
192
|
+
const start = performance.now();
|
|
91
193
|
const span: Running = {
|
|
92
194
|
traceId: parent?.traceId ?? randomHex(16),
|
|
93
195
|
spanId: randomHex(8),
|
|
94
|
-
|
|
196
|
+
// A trace is whole or it is nothing: a call under a sampled parent is sampled, whatever
|
|
197
|
+
// this process would have decided on its own. Only a root is decided here.
|
|
198
|
+
sampled: parent?.sampled ?? keeps(ctx.entity, ctx.operation),
|
|
95
199
|
frond: ctx.frond,
|
|
200
|
+
startedAt,
|
|
201
|
+
start,
|
|
96
202
|
};
|
|
97
203
|
// An edge exists only when the parent is IN this process and belongs to another frond.
|
|
98
204
|
// `inherited` won means the parent is across a wire, and it did not name its frond.
|
|
@@ -105,23 +211,26 @@ export function trace(takers: readonly SpanSink[]): AppMiddleware {
|
|
|
105
211
|
// carries the invocation.
|
|
106
212
|
if (ctx.invocation) ctx.invocation = { ...ctx.invocation, trace: traceparentOf(span) };
|
|
107
213
|
|
|
108
|
-
// Both, and they are not the same measurement: the wall clock says WHEN so two
|
|
109
|
-
// processes land on one timeline, the monotonic one says HOW LONG without being
|
|
110
|
-
// moved by an NTP correction mid-call.
|
|
111
|
-
const startedAt = Date.now();
|
|
112
|
-
const start = performance.now();
|
|
113
214
|
active += 1;
|
|
114
215
|
const finish = (error: string | undefined) => {
|
|
115
216
|
active -= 1;
|
|
217
|
+
const ms = performance.now() - start;
|
|
218
|
+
// Read and dropped in one gesture: a step ends once, and what its children owed it
|
|
219
|
+
// is of no use to anyone after that.
|
|
220
|
+
const owed = charged.get(span.spanId);
|
|
221
|
+
charged.delete(span.spanId);
|
|
116
222
|
const done: FinishedSpan = {
|
|
117
223
|
...span,
|
|
118
224
|
parentId: parent?.spanId,
|
|
119
225
|
callerFrond,
|
|
120
226
|
frond: ctx.frond,
|
|
227
|
+
kind: 'operation',
|
|
121
228
|
entity: ctx.entity,
|
|
122
229
|
operation: ctx.operation,
|
|
123
230
|
startedAt,
|
|
124
|
-
ms
|
|
231
|
+
ms,
|
|
232
|
+
selfMs: Math.max(0, ms - (owed?.ms ?? 0)),
|
|
233
|
+
statements: owed?.statements ?? 0,
|
|
125
234
|
error,
|
|
126
235
|
};
|
|
127
236
|
// A taker that throws is a broken exporter, never a broken call.
|
|
@@ -139,6 +248,70 @@ export function trace(takers: readonly SpanSink[]): AppMiddleware {
|
|
|
139
248
|
}
|
|
140
249
|
});
|
|
141
250
|
};
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
middleware,
|
|
254
|
+
|
|
255
|
+
statement(ran: Statement): void {
|
|
256
|
+
if (takers.length === 0) return;
|
|
257
|
+
// A statement with no operation above it — a migration, a seed — belongs to no step,
|
|
258
|
+
// and inventing a trace for it would put a root span in the viewer per row planted.
|
|
259
|
+
const under = traceContext.current<Running>();
|
|
260
|
+
if (!under) return;
|
|
261
|
+
|
|
262
|
+
const owed = charged.get(under.spanId) ?? { ms: 0, statements: 0 };
|
|
263
|
+
owed.ms += ran.ms;
|
|
264
|
+
owed.statements += 1;
|
|
265
|
+
charged.set(under.spanId, owed);
|
|
266
|
+
|
|
267
|
+
// The op above now reports what it waited for and how many times, which is the whole
|
|
268
|
+
// of what a dashboard reads. WHICH queries ran leaves only when someone asks.
|
|
269
|
+
if (!options.spanPerStatement) return;
|
|
270
|
+
|
|
271
|
+
const done: FinishedSpan = {
|
|
272
|
+
traceId: under.traceId,
|
|
273
|
+
spanId: randomHex(8),
|
|
274
|
+
sampled: under.sampled,
|
|
275
|
+
parentId: under.spanId,
|
|
276
|
+
callerFrond: undefined,
|
|
277
|
+
frond: under.frond,
|
|
278
|
+
kind: 'statement',
|
|
279
|
+
entity: ran.subject,
|
|
280
|
+
operation: ran.verb,
|
|
281
|
+
startedAt: under.startedAt + Math.max(0, performance.now() - under.start - ran.ms),
|
|
282
|
+
ms: ran.ms,
|
|
283
|
+
selfMs: ran.ms,
|
|
284
|
+
statements: 0,
|
|
285
|
+
error: ran.failed ? 'STATEMENT_FAILED' : undefined,
|
|
286
|
+
};
|
|
287
|
+
for (const take of takers) { try { take(done); } catch { /* observing never refuses */ } }
|
|
288
|
+
},
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* The join this package exists to make: a statement runs SYNCHRONOUSLY inside the async
|
|
294
|
+
* context `tracing()` opened around the operation, so the sink finds the step it belongs to
|
|
295
|
+
* without the app ever holding its sources — which is what made the two ends of `onQuery`
|
|
296
|
+
* unable to see each other.
|
|
297
|
+
*
|
|
298
|
+
* `@fougere/adapter-sql` is optional, and dynamic for the reason `@fougere/calls` states:
|
|
299
|
+
* this package declares no dependency on it. An app on another storage observes no
|
|
300
|
+
* statement and every operation still reports its own time.
|
|
301
|
+
*/
|
|
302
|
+
export async function statementsUnder(tracer: Tracing): Promise<() => void> {
|
|
303
|
+
try {
|
|
304
|
+
const { onQuery } = await import('@fougere/adapter-sql');
|
|
305
|
+
|
|
306
|
+
return onQuery((event) => tracer.statement({
|
|
307
|
+
subject: event.subject,
|
|
308
|
+
verb: event.verb,
|
|
309
|
+
ms: event.ms,
|
|
310
|
+
failed: event.failed,
|
|
311
|
+
}));
|
|
312
|
+
} catch {
|
|
313
|
+
return () => {};
|
|
314
|
+
}
|
|
142
315
|
}
|
|
143
316
|
|
|
144
317
|
function codeOf(err: unknown): string {
|
package/src/metrics.ts
CHANGED
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
/** The four signals every service is judged on, derived from the span that already exists. */
|
|
2
|
-
import type
|
|
2
|
+
import { declaredTopologyOf, type App, type Edge, type FrondPlacement, type TopologyReport } from '@fougere/core';
|
|
3
3
|
|
|
4
4
|
export type { Edge, FrondPlacement, TopologyReport } from '@fougere/core';
|
|
5
5
|
import { activeCalls, type FinishedSpan, type SpanSink } from './index.js';
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
* Bucket bounds in SECONDS, the
|
|
9
|
-
*
|
|
8
|
+
* Bucket bounds in SECONDS, and the operator's to choose — this is the default, not the rule.
|
|
9
|
+
*
|
|
10
|
+
* The OpenTelemetry recommendation for request durations: dense under 100 ms because that is
|
|
11
|
+
* where a healthy op lives, and open above 10 s. A system whose ops all cross two processes has
|
|
12
|
+
* its floor elsewhere and wastes half these buckets.
|
|
13
|
+
*
|
|
14
|
+
* Deliberately NOT derived per operation, though `reach` could: an explicit-bucket histogram only
|
|
15
|
+
* aggregates across series that share its bounds, so a panel asking for the p95 of the whole
|
|
16
|
+
* service — which sums over every op — would stop meaning anything, silently. One list per
|
|
17
|
+
* process is what keeps that reading honest. The number that IS per-op is the load threshold,
|
|
18
|
+
* where nothing aggregates.
|
|
10
19
|
*/
|
|
11
20
|
const BOUNDS = [0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10];
|
|
12
21
|
|
|
@@ -19,6 +28,11 @@ interface Bucketed {
|
|
|
19
28
|
sum: number;
|
|
20
29
|
/** One more than the bounds: the last holds everything above the highest bound. */
|
|
21
30
|
buckets: number[];
|
|
31
|
+
/** The same measurement over `selfMs` — what the op did rather than what it waited for. */
|
|
32
|
+
selfSum: number;
|
|
33
|
+
selfBuckets: number[];
|
|
34
|
+
/** Every statement these calls ran. Against `count`, it is statements per call. */
|
|
35
|
+
statements: number;
|
|
22
36
|
}
|
|
23
37
|
|
|
24
38
|
export interface Metrics {
|
|
@@ -45,7 +59,7 @@ export interface MetricsSnapshot {
|
|
|
45
59
|
* `app` is optional and only feeds the topology: what fronds this process found, and which of them
|
|
46
60
|
* run elsewhere.
|
|
47
61
|
*/
|
|
48
|
-
export function metrics(app?: App): Metrics {
|
|
62
|
+
export function metrics(app?: App, bounds: readonly number[] = BOUNDS): Metrics {
|
|
49
63
|
const since = Date.now();
|
|
50
64
|
const series = new Map<string, Bucketed>();
|
|
51
65
|
/** Every frond this process has actually CALLED — the other half of the topology. */
|
|
@@ -55,6 +69,11 @@ export function metrics(app?: App): Metrics {
|
|
|
55
69
|
|
|
56
70
|
return {
|
|
57
71
|
sink: (span: FinishedSpan) => {
|
|
72
|
+
// The one reader that has to choose: a statement is a step, not a call this process
|
|
73
|
+
// answered. Counted here it would publish `select.post` as an operation nothing
|
|
74
|
+
// serves, and every topology and saturation figure would count it too.
|
|
75
|
+
if (span.kind !== 'operation') return;
|
|
76
|
+
|
|
58
77
|
const key = `${span.entity}\0${span.operation}\0${span.error ?? ''}`;
|
|
59
78
|
let row = series.get(key);
|
|
60
79
|
if (!row) {
|
|
@@ -65,7 +84,10 @@ export function metrics(app?: App): Metrics {
|
|
|
65
84
|
error: span.error,
|
|
66
85
|
count: 0,
|
|
67
86
|
sum: 0,
|
|
68
|
-
buckets: new Array(
|
|
87
|
+
buckets: new Array(bounds.length + 1).fill(0),
|
|
88
|
+
selfSum: 0,
|
|
89
|
+
selfBuckets: new Array(bounds.length + 1).fill(0),
|
|
90
|
+
statements: 0,
|
|
69
91
|
};
|
|
70
92
|
series.set(key, row);
|
|
71
93
|
}
|
|
@@ -80,13 +102,18 @@ export function metrics(app?: App): Metrics {
|
|
|
80
102
|
const seconds = span.ms / 1000;
|
|
81
103
|
row.count += 1;
|
|
82
104
|
row.sum += seconds;
|
|
83
|
-
row.buckets[bucketOf(seconds)] += 1;
|
|
105
|
+
row.buckets[bucketOf(seconds, bounds)] += 1;
|
|
106
|
+
|
|
107
|
+
const self = span.selfMs / 1000;
|
|
108
|
+
row.selfSum += self;
|
|
109
|
+
row.selfBuckets[bucketOf(self, bounds)] += 1;
|
|
110
|
+
row.statements += span.statements;
|
|
84
111
|
},
|
|
85
112
|
snapshot: () => ({
|
|
86
113
|
since,
|
|
87
114
|
series: [...series.values()],
|
|
88
115
|
active: activeCalls(),
|
|
89
|
-
bounds:
|
|
116
|
+
bounds: [...bounds],
|
|
90
117
|
topology: topologyOf(app, seen),
|
|
91
118
|
edges: [...edges.values()],
|
|
92
119
|
}),
|
|
@@ -100,8 +127,14 @@ export function metrics(app?: App): Metrics {
|
|
|
100
127
|
function topologyOf(app: App | undefined, seen: Set<string>): FrondPlacement[] {
|
|
101
128
|
// What the app SERVES: a frond an extension brought is instrumentation, and reporting it
|
|
102
129
|
// would describe this package to itself.
|
|
130
|
+
//
|
|
131
|
+
// A frond named in `remotes:` is scanned when its code sits in the same project, and stays
|
|
132
|
+
// in `app.fronds` — the boot says `declared remote — not hosted locally` and keeps it. This
|
|
133
|
+
// half reports what RUNS here, so it is not one of them: it used to answer `local` for a
|
|
134
|
+
// frond every call reached over HTTP. Not a config-derived node either — it is named below
|
|
135
|
+
// only once it has answered, like any other remote.
|
|
103
136
|
const local = new Map((app?.fronds ?? [])
|
|
104
|
-
.filter((frond) => !frond.brought)
|
|
137
|
+
.filter((frond) => !frond.brought && !app?.remotes[frond.name])
|
|
105
138
|
.map((frond) => [frond.name, frond] as const));
|
|
106
139
|
|
|
107
140
|
const here: FrondPlacement[] = [...local.values()].map((frond) => ({
|
|
@@ -121,16 +154,23 @@ function topologyOf(app: App | undefined, seen: Set<string>): FrondPlacement[] {
|
|
|
121
154
|
}
|
|
122
155
|
|
|
123
156
|
/** The first bound this duration does not exceed, or the overflow bucket. */
|
|
124
|
-
function bucketOf(seconds: number): number {
|
|
125
|
-
for (let i = 0; i <
|
|
126
|
-
|
|
157
|
+
function bucketOf(seconds: number, bounds: readonly number[]): number {
|
|
158
|
+
for (let i = 0; i < bounds.length; i++) if (seconds <= bounds[i]!) return i;
|
|
159
|
+
|
|
160
|
+
return bounds.length;
|
|
127
161
|
}
|
|
128
162
|
|
|
129
|
-
/**
|
|
163
|
+
/**
|
|
164
|
+
* Serve the topology on `rpc.topology` — read from inside the process it describes.
|
|
165
|
+
*
|
|
166
|
+
* The declared half is read at every call rather than once here: `reloadFougere()` builds the app
|
|
167
|
+
* again, and a config read at boot would keep answering for the app that was released.
|
|
168
|
+
*/
|
|
130
169
|
export function serveTopology(app: App, measured: Metrics): void {
|
|
131
170
|
app.serveRpc('topology', (): TopologyReport => {
|
|
132
171
|
const { since, active, topology, edges } = measured.snapshot();
|
|
133
|
-
|
|
172
|
+
|
|
173
|
+
return { since, active, fronds: topology, edges, declared: declaredTopologyOf(app) };
|
|
134
174
|
});
|
|
135
175
|
}
|
|
136
176
|
|
|
@@ -139,6 +179,14 @@ export function metricsPayload(service: string, snapshot: MetricsSnapshot) {
|
|
|
139
179
|
const since = `${snapshot.since * 1e6}`;
|
|
140
180
|
const now = `${Date.now() * 1e6}`;
|
|
141
181
|
const attr = (key: string, value: string) => ({ key, value: { stringValue: value } });
|
|
182
|
+
/** What an operation's three series are all sliced by — said once, so they stay comparable. */
|
|
183
|
+
const dimensions = (row: Bucketed) => [
|
|
184
|
+
...(row.frond ? [attr('fougere.frond', row.frond)] : []),
|
|
185
|
+
attr('fougere.entity', row.entity),
|
|
186
|
+
attr('fougere.operation', row.operation),
|
|
187
|
+
attr('fougere.outcome', row.error ? 'error' : 'ok'),
|
|
188
|
+
...(row.error ? [attr('fougere.error.code', row.error)] : []),
|
|
189
|
+
];
|
|
142
190
|
|
|
143
191
|
return {
|
|
144
192
|
resourceMetrics: [
|
|
@@ -158,13 +206,7 @@ export function metricsPayload(service: string, snapshot: MetricsSnapshot) {
|
|
|
158
206
|
// only temporality Prometheus reads without a collector in between.
|
|
159
207
|
aggregationTemporality: 2,
|
|
160
208
|
dataPoints: snapshot.series.map((row) => ({
|
|
161
|
-
attributes:
|
|
162
|
-
...(row.frond ? [attr('fougere.frond', row.frond)] : []),
|
|
163
|
-
attr('fougere.entity', row.entity),
|
|
164
|
-
attr('fougere.operation', row.operation),
|
|
165
|
-
attr('fougere.outcome', row.error ? 'error' : 'ok'),
|
|
166
|
-
...(row.error ? [attr('fougere.error.code', row.error)] : []),
|
|
167
|
-
],
|
|
209
|
+
attributes: dimensions(row),
|
|
168
210
|
startTimeUnixNano: since,
|
|
169
211
|
timeUnixNano: now,
|
|
170
212
|
count: `${row.count}`,
|
|
@@ -174,6 +216,40 @@ export function metricsPayload(service: string, snapshot: MetricsSnapshot) {
|
|
|
174
216
|
})),
|
|
175
217
|
},
|
|
176
218
|
},
|
|
219
|
+
{
|
|
220
|
+
name: 'fougere.operation.self',
|
|
221
|
+
description: 'How long an operation took on its own, with what it waited for taken out.',
|
|
222
|
+
unit: 's',
|
|
223
|
+
histogram: {
|
|
224
|
+
aggregationTemporality: 2,
|
|
225
|
+
dataPoints: snapshot.series.map((row) => ({
|
|
226
|
+
attributes: dimensions(row),
|
|
227
|
+
startTimeUnixNano: since,
|
|
228
|
+
timeUnixNano: now,
|
|
229
|
+
count: `${row.count}`,
|
|
230
|
+
sum: row.selfSum,
|
|
231
|
+
bucketCounts: row.selfBuckets.map((n) => `${n}`),
|
|
232
|
+
explicitBounds: snapshot.bounds,
|
|
233
|
+
})),
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
name: 'fougere.operation.statements',
|
|
238
|
+
description: 'Statements run under an operation. Against its count, statements per call.',
|
|
239
|
+
unit: '{statement}',
|
|
240
|
+
sum: {
|
|
241
|
+
aggregationTemporality: 2,
|
|
242
|
+
isMonotonic: true,
|
|
243
|
+
dataPoints: snapshot.series
|
|
244
|
+
.filter((row) => row.statements > 0)
|
|
245
|
+
.map((row) => ({
|
|
246
|
+
attributes: dimensions(row),
|
|
247
|
+
startTimeUnixNano: since,
|
|
248
|
+
timeUnixNano: now,
|
|
249
|
+
asInt: `${row.statements}`,
|
|
250
|
+
})),
|
|
251
|
+
},
|
|
252
|
+
},
|
|
177
253
|
{
|
|
178
254
|
name: 'fougere.operations.active',
|
|
179
255
|
description: 'Operations running right now — the saturation signal.',
|
package/src/otlp.ts
CHANGED
|
@@ -32,6 +32,10 @@ export interface OtlpExporter {
|
|
|
32
32
|
const OK = 1;
|
|
33
33
|
const ERROR = 2;
|
|
34
34
|
|
|
35
|
+
/** OTLP span kinds, of the six only these two are ours. */
|
|
36
|
+
const INTERNAL = 1;
|
|
37
|
+
const CLIENT = 3;
|
|
38
|
+
|
|
35
39
|
export function otlp(options: OtlpOptions): OtlpExporter {
|
|
36
40
|
const url = options.url ?? 'http://localhost:4318/v1/traces';
|
|
37
41
|
const traces = Endpoint.at(url, options.onError);
|
|
@@ -75,7 +79,10 @@ function payload(service: string, spans: FinishedSpan[]) {
|
|
|
75
79
|
spanId: span.spanId,
|
|
76
80
|
...(span.parentId ? { parentSpanId: span.parentId } : {}),
|
|
77
81
|
name: `${span.entity}.${span.operation}`,
|
|
78
|
-
|
|
82
|
+
// 1 INTERNAL, 3 CLIENT: a statement left this process for an engine, and a
|
|
83
|
+
// viewer draws the two differently. `selfMs` is NOT sent — a collector
|
|
84
|
+
// derives it from the tree it already holds.
|
|
85
|
+
kind: span.kind === 'statement' ? CLIENT : INTERNAL,
|
|
79
86
|
startTimeUnixNano: nanos(span.startedAt),
|
|
80
87
|
endTimeUnixNano: nanos(span.startedAt + span.ms),
|
|
81
88
|
status: span.error ? { code: ERROR, message: span.error } : { code: OK },
|