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,456 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import pg from 'pg';
|
|
4
|
+
|
|
5
|
+
// (sprint 139, AP-6) Traces, logs and metrics for OpenTelemetry, without the
|
|
6
|
+
// OpenTelemetry SDK: this file keeps the current trace of an async call chain
|
|
7
|
+
// (AsyncLocalStorage), makes spans, and counts metrics. src/telemetry/export.ts
|
|
8
|
+
// decides where a finished trace goes (the instance's collector, a workspace's
|
|
9
|
+
// own) and sends it as OTLP/HTTP JSON; src/telemetry/http.ts starts a trace per
|
|
10
|
+
// request.
|
|
11
|
+
//
|
|
12
|
+
// A trace's spans are kept in memory until its root span ends, so the decision
|
|
13
|
+
// what to send is made once per trace, with everything known: the application
|
|
14
|
+
// (its workspace's setting), whether something failed (failed traces are always
|
|
15
|
+
// sent, whatever the sample ratio). Values (SQL statements with their literals,
|
|
16
|
+
// bind values, query strings, item values) are kept apart from the other
|
|
17
|
+
// attributes and only encoded for a destination that asked for them.
|
|
18
|
+
//
|
|
19
|
+
// Nothing here runs when no destination is configured: without a current trace
|
|
20
|
+
// a span helper just calls its function.
|
|
21
|
+
|
|
22
|
+
export type Attr = string | number | boolean;
|
|
23
|
+
export type Attrs = Record<string, Attr | null | undefined>;
|
|
24
|
+
|
|
25
|
+
export const SPAN_KIND = { internal: 1, server: 2, client: 3 } as const;
|
|
26
|
+
|
|
27
|
+
export interface Span {
|
|
28
|
+
traceId: string;
|
|
29
|
+
spanId: string;
|
|
30
|
+
parentId?: string;
|
|
31
|
+
name: string;
|
|
32
|
+
kind: number;
|
|
33
|
+
/** epoch milliseconds (fractions kept) */
|
|
34
|
+
start: number;
|
|
35
|
+
end?: number;
|
|
36
|
+
attrs: Attrs;
|
|
37
|
+
/** attributes that carry values: only sent where values are included */
|
|
38
|
+
vals?: Attrs;
|
|
39
|
+
/** a SQL statement as run: sent as is with values, with its literals replaced by ? otherwise */
|
|
40
|
+
sql?: string;
|
|
41
|
+
error?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface Trace {
|
|
45
|
+
id: string;
|
|
46
|
+
/** the head sampling decision (a trace with an error is sent anyway) */
|
|
47
|
+
sampled: boolean;
|
|
48
|
+
spans: Span[];
|
|
49
|
+
dropped: number;
|
|
50
|
+
error: boolean;
|
|
51
|
+
/** the application the trace is about (the URL's alias, then loadContext's id) */
|
|
52
|
+
appId?: number;
|
|
53
|
+
alias?: string;
|
|
54
|
+
user?: string;
|
|
55
|
+
ended: boolean;
|
|
56
|
+
/** (src/telemetry/export.ts) decided when the application is known: a browser span may follow */
|
|
57
|
+
browser?: boolean;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface Ctx {
|
|
61
|
+
trace: Trace;
|
|
62
|
+
span: Span;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface LogRecord {
|
|
66
|
+
time: number;
|
|
67
|
+
severity: 'INFO' | 'WARN' | 'ERROR';
|
|
68
|
+
body: string;
|
|
69
|
+
attrs: Attrs;
|
|
70
|
+
/** the detail (an error message): values in it are hidden unless the destination includes values */
|
|
71
|
+
detail?: string;
|
|
72
|
+
appId?: number | null;
|
|
73
|
+
user?: string | null;
|
|
74
|
+
traceId?: string;
|
|
75
|
+
spanId?: string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** What src/telemetry/export.ts plugs in. */
|
|
79
|
+
export interface Sink {
|
|
80
|
+
/** a destination for traces exists (cached, synchronous) */
|
|
81
|
+
tracing(): boolean;
|
|
82
|
+
/** metrics are kept (OTLP metrics or /metrics) */
|
|
83
|
+
metering(): boolean;
|
|
84
|
+
/** a destination for logs exists */
|
|
85
|
+
logging(): boolean;
|
|
86
|
+
/** a root span ended */
|
|
87
|
+
finish(t: Trace): void;
|
|
88
|
+
/** a span that ended after its trace was sent (a late child) */
|
|
89
|
+
late(t: Trace, s: Span): void;
|
|
90
|
+
log(r: LogRecord): void;
|
|
91
|
+
/** the trace's application became known (browser spans) */
|
|
92
|
+
app(t: Trace): void | Promise<void>;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const NONE: Sink = {
|
|
96
|
+
tracing: () => false,
|
|
97
|
+
metering: () => false,
|
|
98
|
+
logging: () => false,
|
|
99
|
+
finish() {},
|
|
100
|
+
late() {},
|
|
101
|
+
log() {},
|
|
102
|
+
app() {},
|
|
103
|
+
};
|
|
104
|
+
let sink: Sink = NONE;
|
|
105
|
+
export const setSink = (s: Sink | null) => (sink = s ?? NONE);
|
|
106
|
+
export const tracingOn = () => sink.tracing();
|
|
107
|
+
export const meteringOn = () => sink.metering();
|
|
108
|
+
|
|
109
|
+
/** At most this many spans per trace (a loop of SQL statements): the rest are counted, not kept. */
|
|
110
|
+
export const MAX_SPANS = 1000;
|
|
111
|
+
const MAX_TEXT = 4000;
|
|
112
|
+
|
|
113
|
+
const als = new AsyncLocalStorage<Ctx>();
|
|
114
|
+
export const currentCtx = () => als.getStore();
|
|
115
|
+
/** Run fn with ctx as the current trace (Fastify hooks: als.run with their done callback). */
|
|
116
|
+
export const runIn = <T>(ctx: Ctx, fn: () => T) => als.run(ctx, fn);
|
|
117
|
+
/** Run fn outside any trace (the exporter's own work must not trace itself). */
|
|
118
|
+
export const outside = <T>(fn: () => T): T => als.exit(fn);
|
|
119
|
+
|
|
120
|
+
export const now = () => performance.timeOrigin + performance.now();
|
|
121
|
+
const hex = (bytes: number) => randomBytes(bytes).toString('hex');
|
|
122
|
+
|
|
123
|
+
const TRACEPARENT = /^00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/;
|
|
124
|
+
|
|
125
|
+
/** An incoming W3C traceparent header: the caller's trace id, its span and whether it sampled. */
|
|
126
|
+
export function parseTraceparent(v: unknown): { traceId: string; spanId: string; sampled: boolean } | null {
|
|
127
|
+
const m = typeof v === 'string' ? TRACEPARENT.exec(v.trim().toLowerCase()) : null;
|
|
128
|
+
if (!m || /^0+$/.test(m[1]) || /^0+$/.test(m[2])) return null;
|
|
129
|
+
return { traceId: m[1], spanId: m[2], sampled: (parseInt(m[3], 16) & 1) === 1 };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
let sampleRatio = 1;
|
|
133
|
+
/** The head sampling ratio (0 … 1) for traces that start here. */
|
|
134
|
+
export const setSampleRatio = (r: number) => (sampleRatio = Number.isFinite(r) ? Math.min(Math.max(r, 0), 1) : 1);
|
|
135
|
+
|
|
136
|
+
/** Start a trace (a request, a background job). Null when no destination wants traces. */
|
|
137
|
+
export function startTrace(name: string, kind: number, attrs: Attrs = {}, parent?: string | null): Ctx | null {
|
|
138
|
+
if (!sink.tracing()) return null;
|
|
139
|
+
const remote = parseTraceparent(parent);
|
|
140
|
+
const trace: Trace = {
|
|
141
|
+
id: remote?.traceId ?? hex(16),
|
|
142
|
+
sampled: remote ? remote.sampled : Math.random() < sampleRatio,
|
|
143
|
+
spans: [],
|
|
144
|
+
dropped: 0,
|
|
145
|
+
error: false,
|
|
146
|
+
ended: false,
|
|
147
|
+
};
|
|
148
|
+
const span: Span = { traceId: trace.id, spanId: hex(8), parentId: remote?.spanId, name, kind, start: now(), attrs: { ...attrs } };
|
|
149
|
+
trace.spans.push(span);
|
|
150
|
+
return { trace, span };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** End a trace's root span and hand the trace over. */
|
|
154
|
+
export function endTrace(ctx: Ctx, attrs: Attrs = {}, error?: string) {
|
|
155
|
+
if (ctx.trace.ended) return;
|
|
156
|
+
Object.assign(ctx.span.attrs, attrs);
|
|
157
|
+
if (error) fail(ctx, error);
|
|
158
|
+
ctx.span.end = now();
|
|
159
|
+
ctx.trace.ended = true;
|
|
160
|
+
sink.finish(ctx.trace);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function fail(ctx: Ctx, message: string) {
|
|
164
|
+
ctx.span.error = message.slice(0, 500);
|
|
165
|
+
ctx.trace.error = true;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function child(ctx: Ctx, name: string, kind: number, attrs: Attrs, start = now()): Ctx {
|
|
169
|
+
const span: Span = { traceId: ctx.trace.id, spanId: hex(8), parentId: ctx.span.spanId, name, kind, start, attrs: { ...attrs } };
|
|
170
|
+
if (ctx.trace.spans.length < MAX_SPANS) ctx.trace.spans.push(span);
|
|
171
|
+
else ctx.trace.dropped++;
|
|
172
|
+
return { trace: ctx.trace, span };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function close(ctx: Ctx, error?: unknown) {
|
|
176
|
+
ctx.span.end ??= now();
|
|
177
|
+
if (error !== undefined) fail(ctx, error instanceof Error ? error.message : String(error));
|
|
178
|
+
// a child that ends after its trace was handed over (fire-and-forget work) goes on its own
|
|
179
|
+
if (ctx.trace.ended && ctx.trace.spans.includes(ctx.span)) sink.late(ctx.trace, ctx.span);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Run fn in a child span of the current trace (just fn when there is none). An exception marks the span. */
|
|
183
|
+
export async function withSpan<T>(name: string, attrs: Attrs, fn: (span: Span | null) => Promise<T>, kind: number = SPAN_KIND.internal): Promise<T> {
|
|
184
|
+
const ctx = als.getStore();
|
|
185
|
+
if (!ctx) return fn(null);
|
|
186
|
+
const c = child(ctx, name, kind, attrs);
|
|
187
|
+
try {
|
|
188
|
+
const result = await als.run(c, () => fn(c.span));
|
|
189
|
+
close(c);
|
|
190
|
+
return result;
|
|
191
|
+
} catch (e) {
|
|
192
|
+
close(c, e);
|
|
193
|
+
throw e;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** A span of the current trace, or (background work) the root of a trace of its own. */
|
|
198
|
+
export async function rootSpan<T>(name: string, attrs: Attrs, fn: (span: Span | null) => Promise<T>): Promise<T> {
|
|
199
|
+
if (als.getStore()) return withSpan(name, attrs, fn);
|
|
200
|
+
const ctx = startTrace(name, SPAN_KIND.internal, attrs);
|
|
201
|
+
if (!ctx) return fn(null);
|
|
202
|
+
try {
|
|
203
|
+
const result = await als.run(ctx, () => fn(ctx.span));
|
|
204
|
+
endTrace(ctx);
|
|
205
|
+
return result;
|
|
206
|
+
} catch (e) {
|
|
207
|
+
endTrace(ctx, {}, e instanceof Error ? e.message : String(e));
|
|
208
|
+
throw e;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** A span that already happened (an AI call timed by its caller), as a child of the current span. */
|
|
213
|
+
export function recordSpan(name: string, o: { ms: number; attrs?: Attrs; vals?: Attrs; error?: string | null; kind?: number }) {
|
|
214
|
+
const ctx = als.getStore();
|
|
215
|
+
if (!ctx) return;
|
|
216
|
+
const end = now();
|
|
217
|
+
const c = child(ctx, name, o.kind ?? SPAN_KIND.client, o.attrs ?? {}, end - Math.max(o.ms, 0));
|
|
218
|
+
if (o.vals) c.span.vals = { ...o.vals };
|
|
219
|
+
c.span.end = end;
|
|
220
|
+
close(c, o.error ?? undefined);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Add attributes (and value attributes) to the current span. */
|
|
224
|
+
export function setAttrs(attrs: Attrs, vals?: Attrs) {
|
|
225
|
+
const ctx = als.getStore();
|
|
226
|
+
if (!ctx) return;
|
|
227
|
+
Object.assign(ctx.span.attrs, attrs);
|
|
228
|
+
if (vals) ctx.span.vals = { ...ctx.span.vals, ...vals };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Mark the current span as failed (an error handled without an exception: an HTTP 5xx). */
|
|
232
|
+
export function markError(message: string) {
|
|
233
|
+
const ctx = als.getStore();
|
|
234
|
+
if (ctx) fail(ctx, message);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** The application of the current trace (its workspace decides where the trace goes) and the page's user. */
|
|
238
|
+
export function traceApp(a: { appId: number; alias?: string; page?: number | string; user?: string }) {
|
|
239
|
+
const ctx = als.getStore();
|
|
240
|
+
if (!ctx) return;
|
|
241
|
+
const t = ctx.trace;
|
|
242
|
+
t.appId = a.appId;
|
|
243
|
+
if (a.alias) t.alias = a.alias;
|
|
244
|
+
if (a.user) t.user = a.user;
|
|
245
|
+
const root = t.spans[0];
|
|
246
|
+
if (root) {
|
|
247
|
+
root.attrs['pgkiln.app.id'] = a.appId;
|
|
248
|
+
if (a.alias) root.attrs['pgkiln.app.alias'] = a.alias;
|
|
249
|
+
if (a.page !== undefined) root.attrs['pgkiln.page'] = String(a.page);
|
|
250
|
+
}
|
|
251
|
+
return sink.app(t);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** The traceparent header for an outgoing request made in the current span. */
|
|
255
|
+
export function traceparent(): string | null {
|
|
256
|
+
const ctx = als.getStore();
|
|
257
|
+
return ctx ? `00-${ctx.trace.id}-${ctx.span.spanId}-${ctx.trace.sampled || ctx.trace.error ? '01' : '00'}` : null;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** A log record, tied to the current span when there is one. */
|
|
261
|
+
export function emitLog(r: Omit<LogRecord, 'time' | 'traceId' | 'spanId'>) {
|
|
262
|
+
if (!sink.logging()) return;
|
|
263
|
+
const ctx = als.getStore();
|
|
264
|
+
sink.log({ ...r, time: now(), traceId: ctx?.trace.id, spanId: ctx?.span.spanId });
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ---------------------------------------------------------------- values kept out
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* A SQL statement without its values: string literals (also E'' and U&''),
|
|
271
|
+
* dollar-quoted strings that aren't function bodies, and numbers become ?.
|
|
272
|
+
* Comments go. Identifiers in double quotes stay.
|
|
273
|
+
*/
|
|
274
|
+
export function sanitizeSql(sql: string): string {
|
|
275
|
+
let out = '';
|
|
276
|
+
let i = 0;
|
|
277
|
+
const n = Math.min(sql.length, 20_000);
|
|
278
|
+
while (i < n) {
|
|
279
|
+
const ch = sql[i];
|
|
280
|
+
const next = sql[i + 1];
|
|
281
|
+
if (ch === '-' && next === '-') {
|
|
282
|
+
while (i < n && sql[i] !== '\n') i++;
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
if (ch === '/' && next === '*') {
|
|
286
|
+
const end = sql.indexOf('*/', i + 2);
|
|
287
|
+
i = end < 0 ? n : end + 2;
|
|
288
|
+
out += ' ';
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
if (ch === '"') {
|
|
292
|
+
const end = sql.indexOf('"', i + 1);
|
|
293
|
+
const stop = end < 0 ? n : end + 1;
|
|
294
|
+
out += sql.slice(i, stop);
|
|
295
|
+
i = stop;
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
if (ch === "'" || ((ch === 'E' || ch === 'e') && next === "'" && !/[\w$]/.test(sql[i - 1] ?? ''))) {
|
|
299
|
+
i += ch === "'" ? 1 : 2;
|
|
300
|
+
const escapes = ch !== "'";
|
|
301
|
+
while (i < n) {
|
|
302
|
+
if (escapes && sql[i] === '\\') i += 2;
|
|
303
|
+
else if (sql[i] === "'" && sql[i + 1] === "'") i += 2;
|
|
304
|
+
else if (sql[i] === "'") break;
|
|
305
|
+
else i++;
|
|
306
|
+
}
|
|
307
|
+
i++;
|
|
308
|
+
out += '?';
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
if (ch === '$') {
|
|
312
|
+
const m = /^\$([A-Za-z_][A-Za-z0-9_]*)?\$/.exec(sql.slice(i, i + 70));
|
|
313
|
+
if (m) {
|
|
314
|
+
const end = sql.indexOf(m[0], i + m[0].length);
|
|
315
|
+
const stop = end < 0 ? n : end + m[0].length;
|
|
316
|
+
// a function body (create function … as $$ … $$, do $$ … $$) is code, not a value
|
|
317
|
+
const before = out.slice(-40).toLowerCase();
|
|
318
|
+
out += /\b(as|do)\s*$/.test(before) ? sql.slice(i, stop) : '?';
|
|
319
|
+
i = stop;
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
if (/\d/.test(next ?? '')) {
|
|
323
|
+
// a parameter ($1)
|
|
324
|
+
let j = i + 1;
|
|
325
|
+
while (j < n && /\d/.test(sql[j])) j++;
|
|
326
|
+
out += sql.slice(i, j);
|
|
327
|
+
i = j;
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
if (/\d/.test(ch) && !/[\w$.]/.test(sql[i - 1] ?? '')) {
|
|
332
|
+
let j = i;
|
|
333
|
+
while (j < n && /[\d.eE_]/.test(sql[j])) j++;
|
|
334
|
+
out += '?';
|
|
335
|
+
i = j;
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
out += ch;
|
|
339
|
+
i++;
|
|
340
|
+
}
|
|
341
|
+
return out.replace(/\s+/g, ' ').trim().slice(0, MAX_TEXT);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** A message without the values it may quote: 'strings', Key (col)=(value), e-mail addresses, long numbers. */
|
|
345
|
+
export function redactText(s: string): string {
|
|
346
|
+
return s
|
|
347
|
+
.replace(/'(?:[^']|'')*'/g, "'?'")
|
|
348
|
+
.replace(/=\((?:[^()]|\([^()]*\))*\)/g, '=(?)')
|
|
349
|
+
.replace(/[\w.+-]+@[\w-]+(\.[\w-]+)+/g, '?@?')
|
|
350
|
+
.replace(/\b\d{4,}\b/g, '?')
|
|
351
|
+
.slice(0, MAX_TEXT);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ---------------------------------------------------------------- SQL statements as spans
|
|
355
|
+
|
|
356
|
+
const TX_CONTROL = /^\s*(begin|commit|rollback|savepoint|release|start\s+transaction|end)\b/i;
|
|
357
|
+
|
|
358
|
+
function operation(sql: string) {
|
|
359
|
+
const m = /^\s*(?:\/\*[\s\S]*?\*\/\s*|--[^\n]*\n\s*)*([a-z]+)/i.exec(sql);
|
|
360
|
+
return (m?.[1] ?? 'SQL').toUpperCase();
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Every statement on any pg client becomes a span of the current trace (pools
|
|
365
|
+
* call Client#query too). Transaction control is left out; cursors and COPY
|
|
366
|
+
* (submittables) are left alone. Without a current trace nothing changes.
|
|
367
|
+
*/
|
|
368
|
+
function patchPg() {
|
|
369
|
+
const proto = pg.Client.prototype as unknown as { query: (...a: unknown[]) => unknown; __pgkilnTraced?: boolean };
|
|
370
|
+
if (proto.__pgkilnTraced) return;
|
|
371
|
+
proto.__pgkilnTraced = true;
|
|
372
|
+
const original = proto.query;
|
|
373
|
+
proto.query = function (this: pg.Client & { connectionParameters?: { host?: string; port?: number; database?: string } }, ...args: unknown[]) {
|
|
374
|
+
const ctx = als.getStore();
|
|
375
|
+
const [config, values, cb] = args as [unknown, unknown, unknown];
|
|
376
|
+
const text = typeof config === 'string' ? config : (config as { text?: unknown } | null)?.text;
|
|
377
|
+
if (!ctx || typeof text !== 'string' || TX_CONTROL.test(text) || typeof (config as { submit?: unknown })?.submit === 'function' || typeof (config as { callback?: unknown })?.callback === 'function')
|
|
378
|
+
return original.apply(this, args);
|
|
379
|
+
const op = operation(text);
|
|
380
|
+
const p = this.connectionParameters;
|
|
381
|
+
const c = child(ctx, op, SPAN_KIND.client, {
|
|
382
|
+
'db.system.name': 'postgresql',
|
|
383
|
+
'db.operation.name': op,
|
|
384
|
+
'db.namespace': p?.database ?? null,
|
|
385
|
+
'server.address': p?.host ?? null,
|
|
386
|
+
'server.port': p?.port ?? null,
|
|
387
|
+
});
|
|
388
|
+
c.span.sql = text.slice(0, 20_000);
|
|
389
|
+
const params = Array.isArray(values) ? values : (config as { values?: unknown })?.values;
|
|
390
|
+
if (Array.isArray(params) && params.length)
|
|
391
|
+
c.span.vals = Object.fromEntries(params.slice(0, 50).map((v, i) => [`db.query.parameter.${i + 1}`, v === null || v === undefined ? 'null' : String(typeof v === 'object' && !Buffer.isBuffer(v) ? JSON.stringify(v) : v).slice(0, 500)]));
|
|
392
|
+
const finish = (err: unknown, res?: { rowCount?: number | null }) => {
|
|
393
|
+
if (res && typeof res.rowCount === 'number') c.span.attrs['db.response.returned_rows'] = res.rowCount;
|
|
394
|
+
if (err) {
|
|
395
|
+
const code = (err as { code?: string }).code;
|
|
396
|
+
if (code) c.span.attrs['db.response.status_code'] = code;
|
|
397
|
+
c.span.attrs['error.type'] = code ?? 'error';
|
|
398
|
+
}
|
|
399
|
+
close(c, err ? err : undefined);
|
|
400
|
+
};
|
|
401
|
+
const callback = typeof values === 'function' ? values : typeof cb === 'function' ? cb : null;
|
|
402
|
+
if (callback) {
|
|
403
|
+
const wrapped = (err: unknown, res: unknown) => {
|
|
404
|
+
finish(err, res as { rowCount?: number });
|
|
405
|
+
(callback as (e: unknown, r: unknown) => void)(err, res);
|
|
406
|
+
};
|
|
407
|
+
return typeof values === 'function' ? original.call(this, config, wrapped) : original.call(this, config, values, wrapped);
|
|
408
|
+
}
|
|
409
|
+
const result = original.apply(this, args) as Promise<{ rowCount?: number }> | undefined;
|
|
410
|
+
result?.then?.((r) => finish(null, r), (e) => finish(e));
|
|
411
|
+
return result;
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
patchPg();
|
|
415
|
+
|
|
416
|
+
// ---------------------------------------------------------------- metrics
|
|
417
|
+
|
|
418
|
+
export type Labels = Record<string, string>;
|
|
419
|
+
interface Counter { name: string; labels: Labels; value: number }
|
|
420
|
+
interface Histogram { name: string; labels: Labels; count: number; sum: number; buckets: number[] }
|
|
421
|
+
|
|
422
|
+
/** Request durations (ms): the histogram's upper bounds. */
|
|
423
|
+
export const BUCKETS = [5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000];
|
|
424
|
+
const MAX_SERIES = 5000;
|
|
425
|
+
|
|
426
|
+
export const counters = new Map<string, Counter>();
|
|
427
|
+
export const histograms = new Map<string, Histogram>();
|
|
428
|
+
/** When the counters started (OTLP cumulative sums need it). */
|
|
429
|
+
export const meteringSince = now();
|
|
430
|
+
|
|
431
|
+
const key = (name: string, labels: Labels) => `${name}${JSON.stringify(Object.entries(labels).sort())}`;
|
|
432
|
+
|
|
433
|
+
/** Add to a counter (when metrics are kept). */
|
|
434
|
+
export function count(name: string, labels: Labels, n = 1) {
|
|
435
|
+
if (!sink.metering() || !(n > 0)) return;
|
|
436
|
+
const k = key(name, labels);
|
|
437
|
+
const c = counters.get(k);
|
|
438
|
+
if (c) c.value += n;
|
|
439
|
+
else if (counters.size < MAX_SERIES) counters.set(k, { name, labels, value: n });
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** Add a duration (ms) to a histogram (when metrics are kept). */
|
|
443
|
+
export function observe(name: string, labels: Labels, ms: number) {
|
|
444
|
+
if (!sink.metering() || !Number.isFinite(ms)) return;
|
|
445
|
+
const k = key(name, labels);
|
|
446
|
+
let h = histograms.get(k);
|
|
447
|
+
if (!h) {
|
|
448
|
+
if (histograms.size >= MAX_SERIES) return;
|
|
449
|
+
h = { name, labels, count: 0, sum: 0, buckets: BUCKETS.map(() => 0) };
|
|
450
|
+
histograms.set(k, h);
|
|
451
|
+
}
|
|
452
|
+
h.count++;
|
|
453
|
+
h.sum += ms;
|
|
454
|
+
const i = BUCKETS.findIndex((b) => ms <= b);
|
|
455
|
+
if (i >= 0) h.buckets[i]++;
|
|
456
|
+
}
|
package/src/webclient.ts
CHANGED
|
@@ -3,6 +3,7 @@ import http from 'node:http';
|
|
|
3
3
|
import https from 'node:https';
|
|
4
4
|
import net from 'node:net';
|
|
5
5
|
import zlib from 'node:zlib';
|
|
6
|
+
import { SPAN_KIND, traceparent, withSpan } from './telemetry/trace.ts';
|
|
6
7
|
|
|
7
8
|
// Outgoing HTTP requests to web services (REST data sources, web credential
|
|
8
9
|
// token endpoints, the Invoke API process), with the server-side request
|
|
@@ -258,6 +259,30 @@ function once(url: URL, req: WebRequest, deadline: number, maxBytes: number): Pr
|
|
|
258
259
|
|
|
259
260
|
/** Call a web service with the server's protections (see the top of this file). */
|
|
260
261
|
export async function webRequest(raw: string, req: WebRequest = {}): Promise<WebResponse> {
|
|
262
|
+
// (sprint 139, AP-6) a client span of the current trace, and its W3C traceparent sent along
|
|
263
|
+
const first = new URL(raw);
|
|
264
|
+
return withSpan(`HTTP ${(req.method ?? 'GET').toUpperCase()}`, {
|
|
265
|
+
'http.request.method': (req.method ?? 'GET').toUpperCase(),
|
|
266
|
+
'server.address': first.hostname,
|
|
267
|
+
'server.port': Number(first.port) || (first.protocol === 'https:' ? 443 : 80),
|
|
268
|
+
'url.scheme': first.protocol.replace(':', ''),
|
|
269
|
+
'url.path': first.pathname.slice(0, 500),
|
|
270
|
+
}, async (span) => {
|
|
271
|
+
if (span) {
|
|
272
|
+
span.vals = { 'url.full': raw.slice(0, 2000) };
|
|
273
|
+
const tp = traceparent();
|
|
274
|
+
if (tp) req = { ...req, headers: { ...req.headers, traceparent: tp } };
|
|
275
|
+
}
|
|
276
|
+
const res = await webRequestUntraced(raw, req);
|
|
277
|
+
if (span) {
|
|
278
|
+
span.attrs['http.response.status_code'] = res.status;
|
|
279
|
+
if (res.status >= 400) span.error = `HTTP ${res.status}`;
|
|
280
|
+
}
|
|
281
|
+
return res;
|
|
282
|
+
}, SPAN_KIND.client);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async function webRequestUntraced(raw: string, req: WebRequest): Promise<WebResponse> {
|
|
261
286
|
const maxBytes = req.allowLarge && req.maxBytes ? req.maxBytes : Math.min(req.maxBytes ?? maxResponseBytes(), maxResponseBytes());
|
|
262
287
|
const deadline = Date.now() + Math.min(Math.max(req.timeoutMs ?? 10_000, 100), 60_000);
|
|
263
288
|
let url = new URL(raw);
|
package/src/workflow.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { appIsolated, runtimeForApp } from './workspace-db.ts';
|
|
|
5
5
|
import { html, raw, type Raw } from './html.ts';
|
|
6
6
|
import type { WebResponse } from './webclient.ts';
|
|
7
7
|
import { invoke, invokeCallProblems, invokeJson, isStringMap, toRows, valueAt, type InvokeConfig, type RestSource } from './websources.ts';
|
|
8
|
+
import { rootSpan, setAttrs, traceApp } from './telemetry/trace.ts';
|
|
8
9
|
|
|
9
10
|
// Workflows (APEX 23.2: Workflow). A definition is a list of named steps;
|
|
10
11
|
// an instance runs them one after the other. Each step runs in its own
|
|
@@ -453,13 +454,17 @@ export async function runWorkflow(id: string | number): Promise<number> {
|
|
|
453
454
|
let more: boolean | PendingCall;
|
|
454
455
|
at.branch = null;
|
|
455
456
|
try {
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
457
|
+
// (sprint 139, AP-6) each step a trace of its own (the invoke_api call inside it)
|
|
458
|
+
more = await rootSpan('workflow step', { 'pgkiln.workflow.id': String(id) }, async () => {
|
|
459
|
+
let r = await stepTx((c, side) => step(c, side, String(id), at));
|
|
460
|
+
if (typeof r === 'object') {
|
|
461
|
+
// an invoke_api step: the call runs between two transactions
|
|
462
|
+
const done = await makeCall(r);
|
|
463
|
+
at.branch = done.branch;
|
|
464
|
+
r = await stepTx((c, side) => step(c, side, String(id), at, done));
|
|
465
|
+
}
|
|
466
|
+
return r;
|
|
467
|
+
});
|
|
463
468
|
} catch (e) {
|
|
464
469
|
await fault(String(id), at.branch, (e as Error).message.slice(0, 2000));
|
|
465
470
|
// the other branches may go on
|
|
@@ -555,6 +560,8 @@ async function step(c: pg.PoolClient, side: AppSide, id: string, at: { branch: s
|
|
|
555
560
|
)
|
|
556
561
|
).rows[0];
|
|
557
562
|
if (!w) return false;
|
|
563
|
+
traceApp({ appId: w.app_id });
|
|
564
|
+
setAttrs({ 'pgkiln.workflow.name': w.name, 'pgkiln.workflow.step': w.current_step ?? null });
|
|
558
565
|
// back from a call: the path that waits for it, if it still does (not terminated, retried or expired)
|
|
559
566
|
const k: Cursor | undefined = done
|
|
560
567
|
? done.branch === null
|