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.
@@ -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
- more = await stepTx((c, side) => step(c, side, String(id), at));
457
- if (typeof more === 'object') {
458
- // an invoke_api step: the call runs between two transactions
459
- const done = await makeCall(more);
460
- at.branch = done.branch;
461
- more = await stepTx((c, side) => step(c, side, String(id), at, done));
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