pgkiln 0.71.0 → 0.72.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/db/migrations/139_opentelemetry.sql +36 -0
- package/docs/apex-feature-parity.md +5 -4
- package/docs/guide/01-installation.md +108 -0
- package/docs/guide/03-builder.md +9 -0
- package/docs/guide/09-reference.md +3 -0
- package/docs/guide/12-development.md +13 -0
- package/package.json +1 -1
- package/public/app.js +50 -0
- package/public/builder.css +3 -0
- package/src/ai/service.ts +14 -0
- package/src/app.ts +10 -0
- package/src/builder/home.ts +1 -0
- package/src/builder/i18n/de.ts +2 -0
- package/src/builder/i18n/en.ts +2 -0
- package/src/builder/i18n/es.ts +2 -0
- package/src/builder/i18n/fr.ts +2 -0
- package/src/builder/i18n/it.ts +2 -0
- package/src/builder/i18n/ja.ts +2 -0
- package/src/builder/i18n/ko.ts +2 -0
- package/src/builder/i18n/nl.ts +2 -0
- package/src/builder/i18n/pt.ts +2 -0
- package/src/builder/i18n/zh-cn.ts +2 -0
- package/src/builder/i18n/zh-tw.ts +2 -0
- package/src/builder/telemetry.ts +206 -0
- package/src/builder/workspaces.ts +2 -1
- package/src/instance-checks.ts +11 -4
- package/src/layout.ts +2 -1
- package/src/mail/mail.ts +14 -0
- package/src/runtime/ai-agents.ts +9 -0
- package/src/runtime/routes.ts +3 -0
- package/src/session.ts +26 -0
- package/src/telemetry/browser.ts +91 -0
- package/src/telemetry/config.ts +342 -0
- package/src/telemetry/export.ts +484 -0
- package/src/telemetry/http.ts +126 -0
- package/src/telemetry/trace.ts +456 -0
- package/src/webclient.ts +25 -0
- package/src/workflow.ts +14 -7
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
import { hostname } from 'node:os';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import { owner, sharedRuntime } from '../db.ts';
|
|
4
|
+
import { scopeForWorkspace } from '../network-acl.ts';
|
|
5
|
+
import { signText } from '../security.ts';
|
|
6
|
+
import { webRequest } from '../webclient.ts';
|
|
7
|
+
import { VERSION } from '../release-check.ts';
|
|
8
|
+
import { anyOwnCollector, appOf, appIdOf, instanceTelemetry, onTelemetryConfig, refreshTelemetry, workspaceTelemetry, type InstanceTelemetry } from './config.ts';
|
|
9
|
+
import {
|
|
10
|
+
BUCKETS, count, counters, histograms, meteringSince, now, outside, redactText, sanitizeSql, setSink,
|
|
11
|
+
type Attr, type Attrs, type LogRecord, type Span, type Trace,
|
|
12
|
+
} from './trace.ts';
|
|
13
|
+
|
|
14
|
+
// (sprint 139, AP-6) Sending: a queue per destination (the instance's
|
|
15
|
+
// collector, "ws:<id>" for a workspace's own), sent as OTLP/HTTP JSON every
|
|
16
|
+
// few seconds or when a batch is full. A queue that can't keep up drops the
|
|
17
|
+
// oldest (counted in pgkiln.telemetry.dropped); a collector that is down
|
|
18
|
+
// costs a failed request per batch, never a request of a user. The instance's
|
|
19
|
+
// collector is the administrator's (any address, usually a Collector on the
|
|
20
|
+
// same network); a workspace's own goes through webclient.ts with the
|
|
21
|
+
// workspace's network access list and the installation's allow-list, like a
|
|
22
|
+
// workspace's mail service.
|
|
23
|
+
|
|
24
|
+
const BATCH = 512;
|
|
25
|
+
const MAX_QUEUE = 8192;
|
|
26
|
+
const FLUSH_MS = Number(process.env.OTEL_BSP_SCHEDULE_DELAY ?? 5000);
|
|
27
|
+
const TIMEOUT_MS = Number(process.env.OTEL_EXPORTER_OTLP_TIMEOUT ?? 10_000);
|
|
28
|
+
|
|
29
|
+
interface Destination {
|
|
30
|
+
key: string;
|
|
31
|
+
/** null: the instance's */
|
|
32
|
+
workspaceId: number | null;
|
|
33
|
+
values: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface Queued {
|
|
37
|
+
span: Span;
|
|
38
|
+
trace: Trace;
|
|
39
|
+
values: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface DestinationStatus {
|
|
43
|
+
sent: number;
|
|
44
|
+
dropped: number;
|
|
45
|
+
lastOkAt: number | null;
|
|
46
|
+
lastError: string | null;
|
|
47
|
+
lastErrorAt: number | null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const spanQueues = new Map<string, Queued[]>();
|
|
51
|
+
const logQueues = new Map<string, { r: LogRecord; values: boolean }[]>();
|
|
52
|
+
const status = new Map<string, DestinationStatus>();
|
|
53
|
+
export const destinationStatus = (key: string): DestinationStatus =>
|
|
54
|
+
status.get(key) ?? { sent: 0, dropped: 0, lastOkAt: null, lastError: null, lastErrorAt: null };
|
|
55
|
+
|
|
56
|
+
function note(key: string, ok: boolean, n: number, error?: string) {
|
|
57
|
+
const s = { ...destinationStatus(key) };
|
|
58
|
+
if (ok) {
|
|
59
|
+
s.sent += n;
|
|
60
|
+
s.lastOkAt = Date.now();
|
|
61
|
+
} else {
|
|
62
|
+
s.dropped += n;
|
|
63
|
+
s.lastError = (error ?? 'failed').slice(0, 300);
|
|
64
|
+
s.lastErrorAt = Date.now();
|
|
65
|
+
}
|
|
66
|
+
status.set(key, s);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Where a trace (or log) of an application goes. */
|
|
70
|
+
function destinations(appId: number | null | undefined, alias: string | undefined, signal: 'traces' | 'logs'): Destination[] {
|
|
71
|
+
const t = instanceTelemetry();
|
|
72
|
+
const id = appId ?? appIdOf(alias);
|
|
73
|
+
const ws = workspaceTelemetry(appOf(id)?.[1]);
|
|
74
|
+
if (ws?.mode === 'off') return [];
|
|
75
|
+
const out: Destination[] = [];
|
|
76
|
+
if (t.enabled && t[signal]) out.push({ key: 'instance', workspaceId: null, values: t.values });
|
|
77
|
+
if (ws?.mode === 'own' && ws.endpoint) out.push({ key: `ws:${ws.id}`, workspaceId: ws.id, values: ws.values });
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function enqueue<T>(queues: Map<string, T[]>, key: string, items: T[]) {
|
|
82
|
+
let q = queues.get(key);
|
|
83
|
+
if (!q) queues.set(key, (q = []));
|
|
84
|
+
q.push(...items);
|
|
85
|
+
if (q.length > MAX_QUEUE) {
|
|
86
|
+
const n = q.length - MAX_QUEUE;
|
|
87
|
+
q.splice(0, n);
|
|
88
|
+
note(key, false, n, 'queue full: the collector is slower than the server');
|
|
89
|
+
count('pgkiln.telemetry.dropped', { destination: key }, n);
|
|
90
|
+
}
|
|
91
|
+
if (q.length >= BATCH) void flush();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function finish(t: Trace) {
|
|
95
|
+
// a span with an error (a REST call answered 500) keeps its trace whatever the sample ratio
|
|
96
|
+
if (!t.error && t.spans.some((s) => s.error !== undefined)) t.error = true;
|
|
97
|
+
if (!t.sampled && !t.error) return;
|
|
98
|
+
for (const d of destinations(t.appId, t.alias, 'traces'))
|
|
99
|
+
enqueue(spanQueues, d.key, t.spans.filter((s) => s.end !== undefined).map((span) => ({ span, trace: t, values: d.values })));
|
|
100
|
+
if (t.dropped) count('pgkiln.telemetry.dropped', { destination: 'spans per trace' }, t.dropped);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function late(t: Trace, s: Span) {
|
|
104
|
+
if (!t.sampled && !t.error) return;
|
|
105
|
+
for (const d of destinations(t.appId, t.alias, 'traces')) enqueue(spanQueues, d.key, [{ span: s, trace: t, values: d.values }]);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** (src/telemetry/http.ts relay) spans a page reported, for its trace (sampled: the page only asks when it was). */
|
|
109
|
+
export function browserSpans(traceId: string, alias: string, spans: Span[]) {
|
|
110
|
+
const t: Trace = { id: traceId, sampled: true, spans, dropped: 0, error: false, alias, ended: true };
|
|
111
|
+
for (const s of spans) s.spanId = randomBytes(8).toString('hex');
|
|
112
|
+
for (const d of destinations(undefined, alias, 'traces')) enqueue(spanQueues, d.key, spans.map((span) => ({ span, trace: t, values: d.values })));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function log(r: LogRecord) {
|
|
116
|
+
for (const d of destinations(r.appId, undefined, 'logs')) enqueue(logQueues, d.key, [{ r, values: d.values }]);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** May the trace's page send a browser span? Known once the application is (loadContext). */
|
|
120
|
+
async function app(t: Trace) {
|
|
121
|
+
if (!instanceTelemetry().browser) return;
|
|
122
|
+
if (appOf(t.appId) === undefined) await refreshTelemetry(true);
|
|
123
|
+
t.browser = destinations(t.appId, t.alias, 'traces').length > 0;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------- OTLP JSON
|
|
127
|
+
|
|
128
|
+
const nanos = (ms: number) => (BigInt(Math.floor(ms)) * 1_000_000n + BigInt(Math.round((ms % 1) * 1e6))).toString();
|
|
129
|
+
|
|
130
|
+
function value(v: Attr) {
|
|
131
|
+
if (typeof v === 'boolean') return { boolValue: v };
|
|
132
|
+
if (typeof v === 'number') return Number.isInteger(v) ? { intValue: String(v) } : { doubleValue: v };
|
|
133
|
+
return { stringValue: v };
|
|
134
|
+
}
|
|
135
|
+
const attributes = (a: Attrs) => Object.entries(a).filter(([, v]) => v !== null && v !== undefined).map(([key, v]) => ({ key, value: value(v as Attr) }));
|
|
136
|
+
|
|
137
|
+
/** The enduser.id of a user name under the instance's rule. */
|
|
138
|
+
export function userAttr(user: string | null | undefined, mode = instanceTelemetry().user): string | null {
|
|
139
|
+
if (!user || mode === 'none') return null;
|
|
140
|
+
return mode === 'name' ? user : `u:${signText('otel-user', user.toLowerCase())}`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function resource(t: InstanceTelemetry, workspaceId: number | null) {
|
|
144
|
+
return {
|
|
145
|
+
attributes: attributes({
|
|
146
|
+
'service.name': t.serviceName,
|
|
147
|
+
'service.version': VERSION,
|
|
148
|
+
'service.instance.id': `${hostname()}:${process.pid}`,
|
|
149
|
+
'host.name': hostname(),
|
|
150
|
+
...(workspaceId == null ? t.resource : { 'pgkiln.workspace.id': workspaceId }),
|
|
151
|
+
}),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const SCOPE = () => ({ name: 'pgkiln', version: VERSION });
|
|
156
|
+
|
|
157
|
+
/** One span as OTLP JSON for a destination with or without values. */
|
|
158
|
+
export function encodeSpan(q: Queued) {
|
|
159
|
+
const s = q.span;
|
|
160
|
+
const attrs: Attrs = { ...s.attrs };
|
|
161
|
+
if (s.sql !== undefined) attrs['db.query.text'] = q.values ? s.sql.slice(0, 4000) : sanitizeSql(s.sql);
|
|
162
|
+
if (q.values && s.vals) Object.assign(attrs, s.vals);
|
|
163
|
+
if (s === q.trace.spans[0]) {
|
|
164
|
+
const u = userAttr(q.trace.user);
|
|
165
|
+
if (u) attrs['enduser.id'] = u;
|
|
166
|
+
if (q.trace.dropped) attrs['pgkiln.spans_dropped'] = q.trace.dropped;
|
|
167
|
+
}
|
|
168
|
+
return {
|
|
169
|
+
traceId: s.traceId,
|
|
170
|
+
spanId: s.spanId,
|
|
171
|
+
...(s.parentId ? { parentSpanId: s.parentId } : {}),
|
|
172
|
+
name: s.name,
|
|
173
|
+
kind: s.kind,
|
|
174
|
+
startTimeUnixNano: nanos(s.start),
|
|
175
|
+
endTimeUnixNano: nanos(s.end ?? s.start),
|
|
176
|
+
attributes: attributes(attrs),
|
|
177
|
+
status: s.error !== undefined ? { code: 2, message: q.values ? s.error : redactText(s.error) } : { code: 0 },
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const SEVERITY = { INFO: 9, WARN: 13, ERROR: 17 } as const;
|
|
182
|
+
|
|
183
|
+
export function encodeLog(r: LogRecord, values: boolean) {
|
|
184
|
+
const attrs: Attrs = { ...r.attrs };
|
|
185
|
+
const u = userAttr(r.user);
|
|
186
|
+
if (u) attrs['enduser.id'] = u;
|
|
187
|
+
const detail = r.detail ? (values ? r.detail : redactText(r.detail)) : '';
|
|
188
|
+
return {
|
|
189
|
+
timeUnixNano: nanos(r.time),
|
|
190
|
+
observedTimeUnixNano: nanos(r.time),
|
|
191
|
+
severityNumber: SEVERITY[r.severity],
|
|
192
|
+
severityText: r.severity,
|
|
193
|
+
body: { stringValue: detail ? `${r.body}: ${detail}` : r.body },
|
|
194
|
+
attributes: attributes(attrs),
|
|
195
|
+
...(r.traceId ? { traceId: r.traceId, spanId: r.spanId } : {}),
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ---------------------------------------------------------------- sending
|
|
200
|
+
|
|
201
|
+
async function post(url: string, headers: Record<string, string>, workspaceId: number | null, body: string) {
|
|
202
|
+
const h = { ...headers, 'content-type': 'application/json' };
|
|
203
|
+
if (workspaceId == null) {
|
|
204
|
+
let res: Response;
|
|
205
|
+
try {
|
|
206
|
+
res = await fetch(url, { method: 'POST', headers: h, body, signal: AbortSignal.timeout(TIMEOUT_MS), redirect: 'error' });
|
|
207
|
+
} catch (e) {
|
|
208
|
+
// fetch says "fetch failed"; the cause says why (ECONNREFUSED, a certificate, the timeout)
|
|
209
|
+
const cause = (e as { cause?: { code?: string; message?: string } }).cause;
|
|
210
|
+
throw new Error(cause?.code ?? cause?.message ?? (e as Error).message);
|
|
211
|
+
}
|
|
212
|
+
await res.arrayBuffer().catch(() => undefined);
|
|
213
|
+
if (!res.ok) throw new Error(`the collector answered HTTP ${res.status}`);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
const scope = await scopeForWorkspace(workspaceId, 'OpenTelemetry collector');
|
|
217
|
+
if (!scope) throw new Error('the workspace is gone');
|
|
218
|
+
const res = await webRequest(url, { method: 'POST', headers: h, body, scope, timeoutMs: TIMEOUT_MS, secretHeaders: Object.keys(headers).map((k) => k.toLowerCase()) });
|
|
219
|
+
if (res.status < 200 || res.status >= 300) throw new Error(`the collector answered HTTP ${res.status}`);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function target(key: string, signal: 'traces' | 'logs' | 'metrics') {
|
|
223
|
+
const t = instanceTelemetry();
|
|
224
|
+
if (key === 'instance') return { url: t.urls[signal], headers: t.headers, workspaceId: null as number | null };
|
|
225
|
+
const id = Number(key.slice(3));
|
|
226
|
+
const ws = workspaceTelemetry(id);
|
|
227
|
+
return ws?.endpoint ? { url: `${ws.endpoint.replace(/\/+$/, '')}/v1/${signal}`, headers: ws.headers, workspaceId: id } : null;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
let flushing: Promise<void> | null = null;
|
|
231
|
+
|
|
232
|
+
/** Send what is queued (also on shutdown and in tests). */
|
|
233
|
+
export function flush(): Promise<void> {
|
|
234
|
+
if (flushing) return flushing;
|
|
235
|
+
// cleared after the assignment (the body may finish at once, before ??= would have stored it)
|
|
236
|
+
flushing = outside(async () => {
|
|
237
|
+
await Promise.resolve();
|
|
238
|
+
try {
|
|
239
|
+
const t = instanceTelemetry();
|
|
240
|
+
for (const [key, q] of spanQueues) {
|
|
241
|
+
while (q.length) {
|
|
242
|
+
const batch = q.splice(0, BATCH);
|
|
243
|
+
const to = target(key, 'traces');
|
|
244
|
+
if (!to) continue;
|
|
245
|
+
const body = JSON.stringify({ resourceSpans: [{ resource: resource(t, to.workspaceId), scopeSpans: [{ scope: SCOPE(), spans: batch.map(encodeSpan) }] }] });
|
|
246
|
+
try {
|
|
247
|
+
await post(to.url, to.headers, to.workspaceId, body);
|
|
248
|
+
note(key, true, batch.length);
|
|
249
|
+
} catch (e) {
|
|
250
|
+
note(key, false, batch.length, (e as Error).message);
|
|
251
|
+
count('pgkiln.telemetry.dropped', { destination: key }, batch.length);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
for (const [key, q] of logQueues) {
|
|
256
|
+
while (q.length) {
|
|
257
|
+
const batch = q.splice(0, BATCH);
|
|
258
|
+
const to = target(key, 'logs');
|
|
259
|
+
if (!to) continue;
|
|
260
|
+
const body = JSON.stringify({ resourceLogs: [{ resource: resource(t, to.workspaceId), scopeLogs: [{ scope: SCOPE(), logRecords: batch.map((x) => encodeLog(x.r, x.values)) }] }] });
|
|
261
|
+
try {
|
|
262
|
+
await post(to.url, to.headers, to.workspaceId, body);
|
|
263
|
+
note(key, true, batch.length);
|
|
264
|
+
} catch (e) {
|
|
265
|
+
note(key, false, batch.length, (e as Error).message);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
} finally {
|
|
270
|
+
flushing = null;
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
return flushing;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ---------------------------------------------------------------- metrics
|
|
277
|
+
|
|
278
|
+
interface Gauge { name: string; labels: Record<string, string>; value: number; unit?: string }
|
|
279
|
+
let gaugeCache: { at: number; rows: Gauge[] } | null = null;
|
|
280
|
+
|
|
281
|
+
/** The levels read when metrics are collected: queues, workflows, connections, memory (cached 15 s). */
|
|
282
|
+
export async function gauges(): Promise<Gauge[]> {
|
|
283
|
+
if (gaugeCache && Date.now() - gaugeCache.at < 15_000) return gaugeCache.rows;
|
|
284
|
+
const rows: Gauge[] = [];
|
|
285
|
+
await outside(async () => {
|
|
286
|
+
try {
|
|
287
|
+
const r = await owner.one<Record<string, number>>(`select
|
|
288
|
+
(select count(*) from meta.mail_queue where status = 'queued')::int as mail_queued,
|
|
289
|
+
(select count(*) from meta.mail_queue where status = 'error' and created_at > now() - interval '1 day')::int as mail_failed,
|
|
290
|
+
(select count(*) from meta.process_job where state = 'queued')::int as jobs_queued,
|
|
291
|
+
(select count(*) from meta.process_job where state = 'running')::int as jobs_running,
|
|
292
|
+
(select count(*) from meta.workflow where state = 'active')::int as wf_active,
|
|
293
|
+
(select count(*) from meta.workflow where state = 'waiting')::int as wf_waiting,
|
|
294
|
+
(select count(*) from meta.workflow where state = 'faulted')::int as wf_faulted,
|
|
295
|
+
(select count(*) from meta.ai_request where status in ('queued', 'running'))::int as ai_pending,
|
|
296
|
+
(select count(*) from meta.embedding_queue)::int as embeddings`);
|
|
297
|
+
if (r) {
|
|
298
|
+
rows.push(
|
|
299
|
+
{ name: 'pgkiln.mail.queue', labels: { status: 'queued' }, value: r.mail_queued },
|
|
300
|
+
{ name: 'pgkiln.mail.queue', labels: { status: 'failed_1d' }, value: r.mail_failed },
|
|
301
|
+
{ name: 'pgkiln.jobs.queue', labels: { state: 'queued' }, value: r.jobs_queued },
|
|
302
|
+
{ name: 'pgkiln.jobs.queue', labels: { state: 'running' }, value: r.jobs_running },
|
|
303
|
+
{ name: 'pgkiln.workflows', labels: { state: 'active' }, value: r.wf_active },
|
|
304
|
+
{ name: 'pgkiln.workflows', labels: { state: 'waiting' }, value: r.wf_waiting },
|
|
305
|
+
{ name: 'pgkiln.workflows', labels: { state: 'faulted' }, value: r.wf_faulted },
|
|
306
|
+
{ name: 'pgkiln.ai.requests.pending', labels: {}, value: r.ai_pending },
|
|
307
|
+
{ name: 'pgkiln.embeddings.queue', labels: {}, value: r.embeddings },
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
} catch {
|
|
311
|
+
// the database is away: the other metrics still go out
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
for (const [pool, db] of [['builder', owner], ['runtime', sharedRuntime]] as const) {
|
|
315
|
+
rows.push(
|
|
316
|
+
{ name: 'pgkiln.db.pool.connections', labels: { pool, state: 'total' }, value: db.pool.totalCount },
|
|
317
|
+
{ name: 'pgkiln.db.pool.connections', labels: { pool, state: 'idle' }, value: db.pool.idleCount },
|
|
318
|
+
{ name: 'pgkiln.db.pool.connections', labels: { pool, state: 'waiting' }, value: db.pool.waitingCount },
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
rows.push(
|
|
322
|
+
{ name: 'process.memory.rss', labels: {}, value: process.memoryUsage().rss, unit: 'By' },
|
|
323
|
+
{ name: 'process.uptime', labels: {}, value: Math.round(process.uptime()), unit: 's' },
|
|
324
|
+
);
|
|
325
|
+
gaugeCache = { at: Date.now(), rows };
|
|
326
|
+
return rows;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Counters, histograms and gauges as an OTLP metrics request body. */
|
|
330
|
+
export async function metricsBody() {
|
|
331
|
+
const t = instanceTelemetry();
|
|
332
|
+
const at = nanos(now());
|
|
333
|
+
const since = nanos(meteringSince);
|
|
334
|
+
const by = new Map<string, unknown[]>();
|
|
335
|
+
const push = (name: string, point: unknown) => by.set(name, [...(by.get(name) ?? []), point]);
|
|
336
|
+
for (const c of counters.values()) push(c.name, { attributes: attributes(c.labels), startTimeUnixNano: since, timeUnixNano: at, asDouble: c.value });
|
|
337
|
+
const metrics: unknown[] = [...by].map(([name, dataPoints]) => ({ name, sum: { dataPoints, aggregationTemporality: 2, isMonotonic: true } }));
|
|
338
|
+
const hs = new Map<string, unknown[]>();
|
|
339
|
+
for (const h of histograms.values()) {
|
|
340
|
+
const over = h.count - h.buckets.reduce((a, b) => a + b, 0);
|
|
341
|
+
hs.set(h.name, [...(hs.get(h.name) ?? []), {
|
|
342
|
+
attributes: attributes(h.labels), startTimeUnixNano: since, timeUnixNano: at,
|
|
343
|
+
count: String(h.count), sum: h.sum, bucketCounts: [...h.buckets, over].map(String), explicitBounds: BUCKETS,
|
|
344
|
+
}]);
|
|
345
|
+
}
|
|
346
|
+
for (const [name, dataPoints] of hs) metrics.push({ name, unit: 'ms', histogram: { dataPoints, aggregationTemporality: 2 } });
|
|
347
|
+
const gs = new Map<string, { unit?: string; points: unknown[] }>();
|
|
348
|
+
for (const g of await gauges()) {
|
|
349
|
+
const e = gs.get(g.name) ?? { unit: g.unit, points: [] };
|
|
350
|
+
e.points.push({ attributes: attributes(g.labels), timeUnixNano: at, asDouble: g.value });
|
|
351
|
+
gs.set(g.name, e);
|
|
352
|
+
}
|
|
353
|
+
for (const [name, e] of gs) metrics.push({ name, ...(e.unit ? { unit: e.unit } : {}), gauge: { dataPoints: e.points } });
|
|
354
|
+
return { resourceMetrics: [{ resource: resource(t, null), scopeMetrics: [{ scope: SCOPE(), metrics }] }] };
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export async function exportMetrics() {
|
|
358
|
+
const t = instanceTelemetry();
|
|
359
|
+
if (!t.enabled || !t.metrics) return;
|
|
360
|
+
await outside(async () => {
|
|
361
|
+
try {
|
|
362
|
+
await post(t.urls.metrics, t.headers, null, JSON.stringify(await metricsBody()));
|
|
363
|
+
note('instance', true, 1);
|
|
364
|
+
} catch (e) {
|
|
365
|
+
note('instance', false, 0, (e as Error).message);
|
|
366
|
+
}
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const promName = (n: string) => n.replace(/[^a-zA-Z0-9_]/g, '_');
|
|
371
|
+
const promLabels = (l: Record<string, string>, extra = '') => {
|
|
372
|
+
const parts = Object.entries(l).map(([k, v]) => `${promName(k)}="${v.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n')}"`);
|
|
373
|
+
if (extra) parts.push(extra);
|
|
374
|
+
return parts.length ? `{${parts.join(',')}}` : '';
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
/** The Prometheus text format (GET /metrics). */
|
|
378
|
+
export async function prometheusText(): Promise<string> {
|
|
379
|
+
const lines: string[] = [];
|
|
380
|
+
const typed = new Set<string>();
|
|
381
|
+
const type = (name: string, kind: string) => {
|
|
382
|
+
if (!typed.has(name)) lines.push(`# TYPE ${name} ${kind}`), typed.add(name);
|
|
383
|
+
};
|
|
384
|
+
for (const c of [...counters.values()].sort((a, b) => a.name.localeCompare(b.name))) {
|
|
385
|
+
const n = `${promName(c.name)}_total`;
|
|
386
|
+
type(n, 'counter');
|
|
387
|
+
lines.push(`${n}${promLabels(c.labels)} ${c.value}`);
|
|
388
|
+
}
|
|
389
|
+
for (const h of [...histograms.values()].sort((a, b) => a.name.localeCompare(b.name))) {
|
|
390
|
+
const n = `${promName(h.name)}_milliseconds`;
|
|
391
|
+
type(n, 'histogram');
|
|
392
|
+
let cum = 0;
|
|
393
|
+
BUCKETS.forEach((b, i) => {
|
|
394
|
+
cum += h.buckets[i];
|
|
395
|
+
lines.push(`${n}_bucket${promLabels(h.labels, `le="${b}"`)} ${cum}`);
|
|
396
|
+
});
|
|
397
|
+
lines.push(`${n}_bucket${promLabels(h.labels, 'le="+Inf"')} ${h.count}`, `${n}_sum${promLabels(h.labels)} ${h.sum}`, `${n}_count${promLabels(h.labels)} ${h.count}`);
|
|
398
|
+
}
|
|
399
|
+
for (const g of await gauges()) {
|
|
400
|
+
const n = promName(g.name) + (g.unit === 'By' ? '_bytes' : g.unit === 's' ? '_seconds' : '');
|
|
401
|
+
type(n, 'gauge');
|
|
402
|
+
lines.push(`${n}${promLabels(g.labels)} ${g.value}`);
|
|
403
|
+
}
|
|
404
|
+
return `${lines.join('\n')}\n`;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// ---------------------------------------------------------------- start
|
|
408
|
+
|
|
409
|
+
let flushTimer: NodeJS.Timeout | null = null;
|
|
410
|
+
let metricsTimer: NodeJS.Timeout | null = null;
|
|
411
|
+
let metricsEvery = 0;
|
|
412
|
+
|
|
413
|
+
function timers() {
|
|
414
|
+
const t = instanceTelemetry();
|
|
415
|
+
if (!flushTimer) {
|
|
416
|
+
flushTimer = setInterval(() => void flush(), FLUSH_MS);
|
|
417
|
+
flushTimer.unref();
|
|
418
|
+
}
|
|
419
|
+
const want = t.enabled && t.metrics ? t.intervalMs : 0;
|
|
420
|
+
if (want !== metricsEvery) {
|
|
421
|
+
if (metricsTimer) clearInterval(metricsTimer);
|
|
422
|
+
metricsTimer = null;
|
|
423
|
+
metricsEvery = want;
|
|
424
|
+
if (want) {
|
|
425
|
+
metricsTimer = setInterval(() => void exportMetrics(), want);
|
|
426
|
+
metricsTimer.unref();
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** Plug the exporter in (src/app.ts); the settings are read at once and every 30 s. */
|
|
432
|
+
export async function startTelemetry() {
|
|
433
|
+
setSink({
|
|
434
|
+
tracing: () => {
|
|
435
|
+
const t = instanceTelemetry();
|
|
436
|
+
return (t.enabled && t.traces) || anyOwnCollector();
|
|
437
|
+
},
|
|
438
|
+
metering: () => {
|
|
439
|
+
const t = instanceTelemetry();
|
|
440
|
+
return (t.enabled && t.metrics) || t.prometheus;
|
|
441
|
+
},
|
|
442
|
+
logging: () => {
|
|
443
|
+
const t = instanceTelemetry();
|
|
444
|
+
return (t.enabled && t.logs) || anyOwnCollector();
|
|
445
|
+
},
|
|
446
|
+
finish,
|
|
447
|
+
late,
|
|
448
|
+
log,
|
|
449
|
+
app,
|
|
450
|
+
});
|
|
451
|
+
onTelemetryConfig(timers);
|
|
452
|
+
await refreshTelemetry(true);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** Stop the timers and send what is left (tests, shutdown). */
|
|
456
|
+
export async function stopTelemetry() {
|
|
457
|
+
if (flushTimer) clearInterval(flushTimer);
|
|
458
|
+
if (metricsTimer) clearInterval(metricsTimer);
|
|
459
|
+
flushTimer = metricsTimer = null;
|
|
460
|
+
metricsEvery = 0;
|
|
461
|
+
await flush();
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** (the builder's Send a test span) One span to a collector now: null when it took it, else the problem. */
|
|
465
|
+
export async function sendTest(workspaceId: number | null): Promise<string | null> {
|
|
466
|
+
const key = workspaceId == null ? 'instance' : `ws:${workspaceId}`;
|
|
467
|
+
await refreshTelemetry(true);
|
|
468
|
+
if (workspaceId == null && !instanceTelemetry().endpoint) return 'No collector URL is set.';
|
|
469
|
+
const to = target(key, 'traces');
|
|
470
|
+
if (!to) return 'No collector URL is set.';
|
|
471
|
+
const end = now();
|
|
472
|
+
const span: Span = { traceId: randomBytes(16).toString('hex'), spanId: randomBytes(8).toString('hex'), name: 'pgkiln test span', kind: 1, start: end - 1, end, attrs: { 'pgkiln.test': true } };
|
|
473
|
+
const trace: Trace = { id: span.traceId, sampled: true, spans: [span], dropped: 0, error: false, ended: true };
|
|
474
|
+
const body = JSON.stringify({ resourceSpans: [{ resource: resource(instanceTelemetry(), to.workspaceId), scopeSpans: [{ scope: SCOPE(), spans: [encodeSpan({ span, trace, values: false })] }] }] });
|
|
475
|
+
try {
|
|
476
|
+
await outside(() => post(to.url, to.headers, to.workspaceId, body));
|
|
477
|
+
note(key, true, 1);
|
|
478
|
+
return null;
|
|
479
|
+
} catch (e) {
|
|
480
|
+
const m = (e as Error & { cause?: Error }).cause?.message ?? (e as Error).message;
|
|
481
|
+
note(key, false, 1, m);
|
|
482
|
+
return m;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
|
3
|
+
import { hashToken } from '../security.ts';
|
|
4
|
+
import { cookieName, getSession } from '../session.ts';
|
|
5
|
+
import { isAdmin } from '../builder/locks.ts';
|
|
6
|
+
import { appIdOf, instanceTelemetry } from './config.ts';
|
|
7
|
+
import { prometheusText } from './export.ts';
|
|
8
|
+
import { relay } from './browser.ts';
|
|
9
|
+
import { count, emitLog, endTrace, meteringOn, observe, runIn, SPAN_KIND, startTrace, tracingOn, type Ctx } from './trace.ts';
|
|
10
|
+
|
|
11
|
+
// (sprint 139, AP-6) A trace per request (applications; builder requests when
|
|
12
|
+
// the instance asks for them), request metrics, GET /metrics (Prometheus) and
|
|
13
|
+
// the browser relay POST /a/:alias/telemetry: a page whose trace is sent
|
|
14
|
+
// carries a signed <meta name="pgkiln-trace">, and app.js reports the page
|
|
15
|
+
// load and its Ajax calls to pgkiln, which turns them into spans of the page's
|
|
16
|
+
// trace. No collector address or token ever reaches the browser.
|
|
17
|
+
|
|
18
|
+
const CTX = Symbol('pgkiln.trace');
|
|
19
|
+
const T0 = Symbol('pgkiln.t0');
|
|
20
|
+
const ERR = Symbol('pgkiln.error');
|
|
21
|
+
type Tagged = FastifyRequest & { [CTX]?: Ctx; [T0]?: number; [ERR]?: string };
|
|
22
|
+
|
|
23
|
+
const QUIET = /^\/(static\/|healthz$|metrics$|favicon)|^\/a\/[^/]+\/(telemetry|sw\.js|manifest)/;
|
|
24
|
+
|
|
25
|
+
function appAlias(path: string): string | undefined {
|
|
26
|
+
const m = /^\/a\/([^/?#]{1,100})/.exec(path);
|
|
27
|
+
if (!m) return undefined;
|
|
28
|
+
try {
|
|
29
|
+
return decodeURIComponent(m[1]);
|
|
30
|
+
} catch {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The metric label of a request: a known application's alias, "builder" or "other" (never what a URL makes up). */
|
|
36
|
+
function appLabel(path: string) {
|
|
37
|
+
const alias = appAlias(path);
|
|
38
|
+
if (alias) return appIdOf(alias) ? alias.toLowerCase() : 'unknown';
|
|
39
|
+
return path.startsWith('/builder') ? 'builder' : 'other';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function telemetryHooks(app: FastifyInstance) {
|
|
43
|
+
app.addHook('onRequest', (req: Tagged, _reply, done) => {
|
|
44
|
+
req[T0] = performance.now();
|
|
45
|
+
const path = req.url.split('?')[0];
|
|
46
|
+
if (QUIET.test(path) || !tracingOn()) return done();
|
|
47
|
+
const alias = appAlias(path);
|
|
48
|
+
const t = instanceTelemetry();
|
|
49
|
+
// builder requests go to the instance's collector only, and only when asked for
|
|
50
|
+
if (!alias && !(path.startsWith('/builder') && t.enabled && t.traces && t.builder)) return done();
|
|
51
|
+
const q = req.url.indexOf('?');
|
|
52
|
+
const ctx = startTrace(`${req.method} ${path.slice(0, 120)}`, SPAN_KIND.server, {
|
|
53
|
+
'http.request.method': req.method,
|
|
54
|
+
'url.path': path.slice(0, 500),
|
|
55
|
+
'url.scheme': req.protocol,
|
|
56
|
+
'server.address': req.hostname,
|
|
57
|
+
'pgkiln.app.alias': alias ?? null,
|
|
58
|
+
}, req.headers.traceparent as string | undefined);
|
|
59
|
+
if (!ctx) return done();
|
|
60
|
+
ctx.trace.alias = alias;
|
|
61
|
+
ctx.span.vals = { 'url.query': q >= 0 ? req.url.slice(q + 1, q + 2001) : null, 'client.address': req.ip };
|
|
62
|
+
req[CTX] = ctx;
|
|
63
|
+
runIn(ctx, done);
|
|
64
|
+
});
|
|
65
|
+
app.addHook('onError', async (req: Tagged, _reply, err) => {
|
|
66
|
+
req[ERR] = err.message;
|
|
67
|
+
emitLog({ severity: 'ERROR', body: 'unhandled error', detail: err.message, attrs: { 'pgkiln.event': 'error', 'url.path': req.url.split('?')[0].slice(0, 500) }, appId: appIdOf(appAlias(req.url)) ?? null });
|
|
68
|
+
});
|
|
69
|
+
app.addHook('onResponse', async (req: Tagged, reply) => {
|
|
70
|
+
const code = reply.statusCode;
|
|
71
|
+
const path = req.url.split('?')[0];
|
|
72
|
+
const route = req.routeOptions?.url;
|
|
73
|
+
if (meteringOn() && !QUIET.test(path) && req[T0] !== undefined)
|
|
74
|
+
observe('pgkiln.http.server.duration', { app: appLabel(path), method: req.method, status_class: `${Math.floor(code / 100)}xx` }, performance.now() - req[T0]);
|
|
75
|
+
const ctx = req[CTX];
|
|
76
|
+
if (!ctx) return;
|
|
77
|
+
if (route) ctx.span.name = `${req.method} ${route}`;
|
|
78
|
+
endTrace(ctx, { 'http.response.status_code': code, 'http.route': route ?? null }, code >= 500 ? req[ERR] ?? `HTTP ${code}` : undefined);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------- routes
|
|
83
|
+
|
|
84
|
+
async function metricsAllowed(req: FastifyRequest, reply: FastifyReply) {
|
|
85
|
+
const t = instanceTelemetry();
|
|
86
|
+
const auth = req.headers.authorization;
|
|
87
|
+
if (auth?.startsWith('Bearer ') && t.prometheusTokenHash) {
|
|
88
|
+
const given = Buffer.from(hashToken(auth.slice(7).trim()));
|
|
89
|
+
const want = Buffer.from(t.prometheusTokenHash);
|
|
90
|
+
if (given.length === want.length && timingSafeEqual(given, want)) return true;
|
|
91
|
+
}
|
|
92
|
+
// an administrator's builder session (the browser)
|
|
93
|
+
if (req.cookies[cookieName(null)]) {
|
|
94
|
+
const s = await getSession(req, reply, null, '/builder');
|
|
95
|
+
if (s.username && (await isAdmin(s.username))) return true;
|
|
96
|
+
}
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function telemetryRoutes(app: FastifyInstance) {
|
|
101
|
+
app.get('/metrics', async (req, reply) => {
|
|
102
|
+
if (!instanceTelemetry().prometheus) return reply.code(404).type('text/plain').send('Not found');
|
|
103
|
+
if (!(await metricsAllowed(req, reply)))
|
|
104
|
+
return reply.code(401).header('www-authenticate', 'Bearer realm="pgkiln metrics"').type('text/plain').send('A metrics token or an administrator\'s sign-in is needed.\n');
|
|
105
|
+
return reply.header('cache-control', 'no-store').type('text/plain; version=0.0.4; charset=utf-8').send(await prometheusText());
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
app.post('/a/:alias/telemetry', { bodyLimit: 32 * 1024 }, async (req, reply) => {
|
|
109
|
+
const { alias } = req.params as { alias: string };
|
|
110
|
+
if (!instanceTelemetry().browser) return reply.code(404).send();
|
|
111
|
+
let body = req.body as { t?: unknown; spans?: unknown } | string | undefined;
|
|
112
|
+
if (typeof body === 'string') {
|
|
113
|
+
try {
|
|
114
|
+
body = JSON.parse(body);
|
|
115
|
+
} catch {
|
|
116
|
+
return reply.code(400).send();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const kept = relay(alias, (body as { t?: unknown })?.t, (body as { spans?: unknown })?.spans);
|
|
120
|
+
if (kept === null) {
|
|
121
|
+
count('pgkiln.telemetry.refused', { reason: 'browser token' });
|
|
122
|
+
return reply.code(403).send();
|
|
123
|
+
}
|
|
124
|
+
return reply.code(204).send();
|
|
125
|
+
});
|
|
126
|
+
}
|