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,206 @@
1
+ import type { FastifyInstance, FastifyReply } from 'fastify';
2
+ import { owner } from '../db.ts';
3
+ import { html, raw, type Raw } from '../html.ts';
4
+ import { newToken } from '../security.ts';
5
+ import { clientIp, logActivity, type Session } from '../session.ts';
6
+ import {
7
+ instanceTelemetry, refreshTelemetry, saveInstanceTelemetry, saveWorkspaceTelemetry, setMetricsToken, workspaceTelemetry,
8
+ type InstanceForm, type Origin, type WorkspaceForm,
9
+ } from '../telemetry/config.ts';
10
+ import { destinationStatus, sendTest } from '../telemetry/export.ts';
11
+ import { isAdmin } from './locks.ts';
12
+ import { back, BASE, csrf, developer, flash, input, region, select, send, shell, type Req } from './ui.ts';
13
+ import { workspaceAdmin } from './workspaces.ts';
14
+
15
+ // (sprint 139, AP-6; APEX: Workspace Utilities → OpenTelemetry) Workspace
16
+ // utilities → OpenTelemetry (administrators): the instance's collector, what
17
+ // it receives, GET /metrics, and each workspace's choice; a workspace's
18
+ // administrators switch its applications off or add a collector of their own
19
+ // on /builder/workspaces/:id/telemetry. Headers (an API key) are write-only:
20
+ // stored encrypted, only their names are shown.
21
+
22
+ const crumbs: [string, string?][] = [['App Builder', BASE], ['Workspace utilities', `${BASE}/utilities`], ['OpenTelemetry']];
23
+ const ORIGIN: Record<Origin, string> = { builder: 'set here', environment: 'environment variable', default: 'default' };
24
+
25
+ const check = (name: string, label: string, on: boolean, help?: string) =>
26
+ html`<div class="field"><label class="check"><input type="checkbox" name="${name}" value="true"${on ? raw(' checked') : ''}> ${label}</label>${help ? html`<small class="help">${help}</small>` : ''}</div>`;
27
+
28
+ const headersField = (names: string[], help: string) => html`<div class="field"><label class="label" for="f_headers">Headers</label>
29
+ <textarea id="f_headers" name="headers" rows="2" maxlength="4000" spellcheck="false" autocomplete="off" placeholder="${names.length ? `${names.join(', ')} set: type new headers to replace them` : 'e.g. x-honeycomb-team=…, Authorization=Bearer …'}"></textarea>
30
+ <small class="help">${help}${names.length ? html` Set now: <b>${names.join(', ')}</b> (values hidden).` : ''}</small>
31
+ ${names.length ? html`<label class="check"><input type="checkbox" name="remove_headers" value="true"> Remove the headers</label>` : ''}</div>`;
32
+
33
+ const when = (ms: number | null) => (ms ? new Date(ms).toISOString().replace('T', ' ').slice(0, 19) + ' UTC' : '—');
34
+
35
+ function statusCell(key: string): Raw {
36
+ const st = destinationStatus(key);
37
+ return html`${st.sent} sent${st.dropped ? html`, <span class="otel-problem">${st.dropped} dropped</span>` : ''} · last success ${when(st.lastOkAt)}
38
+ ${st.lastError ? html`<br><span class="otel-problem">Last problem (${when(st.lastErrorAt)}): ${st.lastError}</span>` : ''}`;
39
+ }
40
+
41
+ async function adminOnly(s: Session, reply: FastifyReply) {
42
+ if (await isAdmin(s.username)) return true;
43
+ await send(reply.code(403), s, shell(s, 'OpenTelemetry', crumbs, html`<h1 class="u-mb1">OpenTelemetry</h1><div class="alert alert-error" role="alert">Only administrators set up OpenTelemetry for the installation.</div>`));
44
+ return false;
45
+ }
46
+
47
+ export async function telemetryBuilderRoutes(app: FastifyInstance) {
48
+ app.get(`${BASE}/telemetry`, async (req: Req, reply) => {
49
+ const s = await developer(req, reply);
50
+ if (!s || !(await adminOnly(s, reply))) return;
51
+ await refreshTelemetry(true);
52
+ const t = instanceTelemetry();
53
+ const token = typeof s.state.__METRICS_TOKEN === 'string' ? s.state.__METRICS_TOKEN : null;
54
+ if (token) {
55
+ delete s.state.__METRICS_TOKEN;
56
+ const { saveState } = await import('../session.ts');
57
+ await saveState(s);
58
+ }
59
+ const workspaces = (await owner.query<{ id: number; name: string; telemetry: string; telemetry_endpoint: string | null; apps: number }>(
60
+ `select w.id, w.name, w.telemetry, w.telemetry_endpoint, (select count(*) from meta.app a where meta.app_workspace(a.id) = w.id)::int as apps
61
+ from meta.workspace w order by w.name`)).rows;
62
+ const from = (k: string) => html`<span class="muted">(${ORIGIN[t.origin[k] ?? 'default']})</span>`;
63
+ const pub = (process.env.PUBLIC_URL ?? '').replace(/\/+$/, '');
64
+ const main = html`<h1 class="u-mb1">OpenTelemetry</h1>
65
+ <p class="muted u-mt0">Send traces, logs and metrics of the applications to your own monitoring (Grafana, Jaeger, Datadog, Honeycomb, OCI APM,
66
+ or anything that speaks OTLP) through an OpenTelemetry Collector. A trace per page request, with a span for each SQL statement, REST data source call,
67
+ AI request and tool call; mail and workflow steps as traces of their own. pgkiln sends OTLP over HTTP as JSON. The standard <code>OTEL_*</code>
68
+ environment variables work too; a value set here wins. Chapter 1 of the guide (Monitoring with OpenTelemetry) explains every choice.</p>
69
+ ${t.protocolWarning ? html`<div class="alert alert-warning" role="status">${t.protocolWarning}</div>` : ''}
70
+ <div class="columns wide-left">
71
+ ${region('Collector', html`<form method="post" action="${BASE}/telemetry">${csrf(s)}
72
+ ${check('enabled', 'Send to this collector', t.enabled, 'Off: nothing leaves the server (workspaces with a collector of their own still send to it).')}
73
+ <div class="form-grid">
74
+ ${input('endpoint', 'Collector URL', t.origin.otel_endpoint === 'builder' ? t.endpoint : '', { placeholder: t.endpoint || 'http://otel-collector:4318', help: html`The OTLP/HTTP base address; /v1/traces, /v1/logs and /v1/metrics are added. In effect: <b>${t.endpoint || 'none'}</b> ${from('otel_endpoint')} (<code>OTEL_EXPORTER_OTLP_ENDPOINT</code>).` as unknown as string })}
75
+ ${input('service_name', 'Service name', t.origin.otel_service_name === 'builder' ? t.serviceName : '', { placeholder: t.serviceName, help: `service.name of everything sent (OTEL_SERVICE_NAME). In effect: ${t.serviceName}.` })}
76
+ ${input('sample', 'Sample ratio', t.origin.otel_sample === 'builder' ? String(t.sample) : '', { placeholder: String(t.sample), help: 'Share of traces sent, 0 to 1 (OTEL_TRACES_SAMPLER_ARG). A trace with an error is always sent. A caller\'s traceparent decides for its trace.' })}
77
+ ${select('user', 'User', t.user, [['hashed', 'Hashed (the same user gives the same value, the name is not sent)'], ['name', 'User name'], ['none', 'Not sent']], 'enduser.id on the page span and on logs.')}
78
+ </div>
79
+ ${headersField(t.headerNames, 'Sent with every request to the collector, name=value, one per line or comma separated (OTEL_EXPORTER_OTLP_HEADERS). Stored encrypted.')}
80
+ <fieldset class="field"><legend class="label">Send</legend><div class="radio-group">
81
+ ${check('traces', 'Traces', t.traces)}${check('logs', 'Logs: errors, refused access and failed sign-ins', t.logs)}${check('metrics', `Metrics, every ${Math.round(t.intervalMs / 1000)} seconds`, t.metrics)}
82
+ </div></fieldset>
83
+ ${check('values', 'Include values', t.values, 'SQL statements with their literals, bind values, query strings and AI tool arguments. Off: literals become ?, values are left out and error messages have quoted values removed.')}
84
+ ${check('builder', 'Trace builder requests too', t.builder, 'Applications only by default.')}
85
+ ${check('browser', 'Browser spans', t.browser, 'Pages report their load time and Ajax calls to pgkiln, which adds them to the page\'s trace. The browser never sees the collector or its headers.')}
86
+ ${check('prometheus', 'Prometheus endpoint /metrics', t.prometheus, 'For administrators signed in to the builder, or a scraper with the metrics token below (PGKILN_METRICS, PGKILN_METRICS_TOKEN).')}
87
+ <div class="buttons"><button class="btn btn-hot">Save</button></div></form>
88
+ <form method="post" action="${BASE}/telemetry/test" class="u-mt1">${csrf(s)}<button class="btn">Send a test span</button></form>`)}
89
+ ${region('Status', html`<p class="muted u-mt0">Since this server started. Each server of the installation sends its own.</p>
90
+ <div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Collector</th><th>Sending</th></tr></thead><tbody>
91
+ <tr><td data-label="Collector">This instance's${t.enabled ? '' : html` <span class="muted">(off)</span>`}</td><td data-label="Sending">${statusCell('instance')}</td></tr>
92
+ ${workspaces.filter((w) => w.telemetry === 'own').map((w) => html`<tr><td data-label="Collector">Workspace ${w.name}</td><td data-label="Sending">${statusCell(`ws:${w.id}`)}</td></tr>`)}
93
+ </tbody></table></div>`)}
94
+ ${region('Prometheus', html`<p class="muted u-mt0">${t.prometheus ? html`<code>GET ${pub || ''}/metrics</code> answers with the counters, durations and queue lengths in the Prometheus text format.` : 'Off: switch it on above.'}
95
+ A scraper sends <code>Authorization: Bearer &lt;token&gt;</code>. ${t.prometheusTokenHash ? (t.origin.metrics_token_hash === 'builder' || !process.env.PGKILN_METRICS_TOKEN ? 'A token is set.' : 'The token is PGKILN_METRICS_TOKEN.') : 'No token is set.'}</p>
96
+ ${token ? html`<div class="alert alert-success" role="status">The new token (shown once): <code class="cell-break">${token}</code></div>` : ''}
97
+ <form method="post" action="${BASE}/telemetry/token" class="buttons">${csrf(s)}<button class="btn" name="action" value="new">${t.prometheusTokenHash ? 'Replace the token' : 'Make a token'}</button>
98
+ ${t.prometheusTokenHash ? html`<button class="btn btn-danger" name="action" value="remove" data-confirm="Remove the metrics token? Scrapers that use it get 401.">Remove the token</button>` : ''}</form>`)}
99
+ ${region('Workspaces', html`<p class="muted u-mt0">A workspace's administrators may switch its applications off, or send them to a collector of their own as well.</p>
100
+ <div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Workspace</th><th>Applications</th><th>Sends</th></tr></thead><tbody>
101
+ ${workspaces.length ? workspaces.map((w) => html`<tr><td data-label="Workspace"><a href="${BASE}/workspaces/${w.id}/telemetry">${w.name}</a></td><td data-label="Applications" class="num">${w.apps}</td>
102
+ <td data-label="Sends" class="cell-break">${w.telemetry === 'off' ? 'nothing' : w.telemetry === 'own' ? html`to the instance's collector and ${w.telemetry_endpoint}` : 'to the instance\'s collector'}</td></tr>`)
103
+ : html`<tr><td colspan="3" class="empty">No workspaces.</td></tr>`}</tbody></table></div>`)}
104
+ </div>`;
105
+ return send(reply, s, shell(s, 'OpenTelemetry', crumbs, main));
106
+ });
107
+
108
+ app.post(`${BASE}/telemetry`, async (req: Req, reply) => {
109
+ const s = await developer(req, reply);
110
+ if (!s || !(await adminOnly(s, reply))) return;
111
+ const problem = await saveInstanceTelemetry((req.body ?? {}) as InstanceForm);
112
+ if (!problem) {
113
+ const t = instanceTelemetry();
114
+ await logActivity({ username: s.username, event: 'instance_settings', ip: clientIp(req),
115
+ detail: `OpenTelemetry: ${t.enabled ? `on, ${t.endpoint}` : 'off'}, sample ${t.sample}, user ${t.user}, values ${t.values}, browser ${t.browser}, /metrics ${t.prometheus}` });
116
+ }
117
+ flash(s, problem ?? 'OpenTelemetry settings saved.', problem ? 'error' : 'ok');
118
+ return back(reply, s, `${BASE}/telemetry`);
119
+ });
120
+
121
+ app.post(`${BASE}/telemetry/test`, async (req: Req, reply) => {
122
+ const s = await developer(req, reply);
123
+ if (!s || !(await adminOnly(s, reply))) return;
124
+ const problem = await sendTest(null);
125
+ flash(s, problem ? `The collector did not take the test span: ${problem}` : 'The collector took the test span ("pgkiln test span").', problem ? 'error' : 'ok');
126
+ return back(reply, s, `${BASE}/telemetry`);
127
+ });
128
+
129
+ app.post(`${BASE}/telemetry/token`, async (req: Req, reply) => {
130
+ const s = await developer(req, reply);
131
+ if (!s || !(await adminOnly(s, reply))) return;
132
+ const action = (req.body as { action?: string } | undefined)?.action;
133
+ if (action === 'new') {
134
+ const token = `pgkm_${newToken()}`;
135
+ await setMetricsToken(token);
136
+ s.state.__METRICS_TOKEN = token;
137
+ await logActivity({ username: s.username, event: 'instance_settings', ip: clientIp(req), detail: 'metrics token made' });
138
+ } else if (action === 'remove') {
139
+ await setMetricsToken(null);
140
+ await logActivity({ username: s.username, event: 'instance_settings', ip: clientIp(req), detail: 'metrics token removed' });
141
+ flash(s, 'The metrics token is removed.');
142
+ }
143
+ return back(reply, s, `${BASE}/telemetry`);
144
+ });
145
+
146
+ // ---------------------------------------------------------------- a workspace's own
147
+
148
+ const wsAllowed = async (s: Session, reply: FastifyReply, id: string) => {
149
+ if (await workspaceAdmin(s.username, id)) return true;
150
+ await send(reply.code(403), s, shell(s, 'OpenTelemetry', [['Administration'], ['OpenTelemetry']],
151
+ html`<h1 class="u-mb1">OpenTelemetry</h1><div class="alert alert-error" role="alert">Only the workspace's administrators change its OpenTelemetry setting.</div>`, 'users'));
152
+ return false;
153
+ };
154
+
155
+ app.get(`${BASE}/workspaces/:id(^\\d{1,9}$)/telemetry`, async (req: Req, reply) => {
156
+ const s = await developer(req, reply);
157
+ if (!s || !(await wsAllowed(s, reply, req.params.id))) return;
158
+ const w = await owner.one<{ id: number; name: string; telemetry: string; telemetry_endpoint: string | null; telemetry_values: boolean; has_headers: boolean }>(
159
+ 'select id, name, telemetry, telemetry_endpoint, telemetry_values, telemetry_headers_enc is not null as has_headers from meta.workspace where id = $1', [req.params.id]);
160
+ if (!w) return reply.code(404).send('Not found');
161
+ await refreshTelemetry(true);
162
+ const t = instanceTelemetry();
163
+ const names = Object.keys(workspaceTelemetry(w.id)?.headers ?? {});
164
+ const radio = (v: string, label: string, help: string) => html`<label class="check"><input type="radio" name="mode" value="${v}"${w.telemetry === v ? raw(' checked') : ''}> ${label} <span class="muted small">${help}</span></label>`;
165
+ const main = html`<div class="title-row"><h1>OpenTelemetry · ${w.name}</h1></div>
166
+ <p class="muted u-mt0">Traces and logs of this workspace's applications. ${t.enabled ? 'The instance sends them to its collector.' : 'The instance sends nothing at the moment.'}
167
+ A collector of the workspace's own is reached through the installation's allow-list and this workspace's <a href="${BASE}/network?ws=${w.id}">network access</a> list.</p>
168
+ <div class="columns wide-left">
169
+ ${region('Setting', html`<form method="post" action="${BASE}/workspaces/${w.id}/telemetry">${csrf(s)}
170
+ <fieldset class="field"><legend class="label">The applications send</legend><div class="radio-group">
171
+ ${radio('instance', 'As the instance does', '(default)')}
172
+ ${radio('off', 'Nothing', 'not even to the instance\'s collector')}
173
+ ${radio('own', 'Also to our own collector', '')}
174
+ </div></fieldset>
175
+ <div class="form-grid">${input('endpoint', 'Own collector URL', w.telemetry_endpoint ?? '', { placeholder: 'https://otlp.example.com', help: 'The OTLP/HTTP base address; /v1/traces and /v1/logs are added.' })}</div>
176
+ ${headersField(names.length ? names : w.has_headers ? ['(unreadable)'] : [], 'Sent with every request to the own collector (an API key). Stored encrypted.')}
177
+ ${check('values', 'Include values for the own collector', w.telemetry_values, 'SQL statements with their literals, bind values and query strings.')}
178
+ <div class="buttons"><button class="btn btn-hot">Save</button></div></form>
179
+ ${w.telemetry === 'own' ? html`<form method="post" action="${BASE}/workspaces/${w.id}/telemetry/test" class="u-mt1">${csrf(s)}<button class="btn">Send a test span</button></form>` : ''}`)}
180
+ ${w.telemetry === 'own' ? region('Status', html`<p class="u-mt0">${statusCell(`ws:${w.id}`)}</p>`) : ''}
181
+ </div>`;
182
+ return send(reply, s, shell(s, `OpenTelemetry · ${w.name}`, [['Administration'], [w.name, `${BASE}/workspaces/${w.id}`], ['OpenTelemetry']], main, 'users'));
183
+ });
184
+
185
+ app.post(`${BASE}/workspaces/:id(^\\d{1,9}$)/telemetry`, async (req: Req, reply) => {
186
+ const s = await developer(req, reply);
187
+ if (!s || !(await wsAllowed(s, reply, req.params.id))) return;
188
+ const id = Number(req.params.id);
189
+ if (!(await owner.one('select 1 from meta.workspace where id = $1', [id]))) return reply.code(404).send('Not found');
190
+ const f = (req.body ?? {}) as WorkspaceForm;
191
+ const problem = await saveWorkspaceTelemetry(id, f);
192
+ if (!problem)
193
+ await logActivity({ username: s.username, event: 'workspace', ip: clientIp(req), detail: `workspace ${id} OpenTelemetry: ${f.mode}${f.mode === 'own' ? ` ${f.endpoint}` : ''}` });
194
+ flash(s, problem ?? 'Saved.', problem ? 'error' : 'ok');
195
+ return back(reply, s, `${BASE}/workspaces/${id}/telemetry`);
196
+ });
197
+
198
+ app.post(`${BASE}/workspaces/:id(^\\d{1,9}$)/telemetry/test`, async (req: Req, reply) => {
199
+ const s = await developer(req, reply);
200
+ if (!s || !(await wsAllowed(s, reply, req.params.id))) return;
201
+ const id = Number(req.params.id);
202
+ const problem = workspaceTelemetry(id)?.mode === 'own' ? await sendTest(id) : 'The workspace has no collector of its own.';
203
+ flash(s, problem ? `The collector did not take the test span: ${problem}` : 'The collector took the test span ("pgkiln test span").', problem ? 'error' : 'ok');
204
+ return back(reply, s, `${BASE}/workspaces/${id}/telemetry`);
205
+ });
206
+ }
@@ -385,7 +385,8 @@ export async function workspaceRoutes(app: FastifyInstance) {
385
385
  <div class="buttons"><button class="btn">${tx('wsp.save_devs')}</button></div></form>`)}
386
386
  </div>
387
387
  ${serviceRegion(s, w)}
388
- ${region(tx('wsp.mail_net'), html`<p class="u-mt0">${tx.rich('wsp.mail_net_text', { mail: html`<a href="${BASE}/workspaces/${w.id}/mail">${tx('wsp.mail_service')}</a>`, network: html`<a href="${BASE}/network?ws=${w.id}">${tx('wsp.network')}</a>`, queue: html`<a href="${BASE}/mail-queue${full ? `?ws=${w.id}` : ''}">${tx('wsp.queue')}</a>` })}</p>`, '', 'mail-network')}
388
+ ${region(tx('wsp.mail_net'), html`<p class="u-mt0">${tx.rich('wsp.mail_net_text', { mail: html`<a href="${BASE}/workspaces/${w.id}/mail">${tx('wsp.mail_service')}</a>`, network: html`<a href="${BASE}/network?ws=${w.id}">${tx('wsp.network')}</a>`, queue: html`<a href="${BASE}/mail-queue${full ? `?ws=${w.id}` : ''}">${tx('wsp.queue')}</a>` })}</p>
389
+ <p>${tx.rich('wsp.telemetry_text', { link: html`<a href="${BASE}/workspaces/${w.id}/telemetry">${tx('wsp.telemetry')}</a>` })}</p>`, '', 'mail-network')}
389
390
  <div class="columns">${preferencesRegion(s, w, without, unlinked)}${linksRegion(s, w, links)}</div>
390
391
  ${full ? isolationRegion(s, w, roles.rows) : region(tx('wsp.isolation'), html`<p class="u-mt0">${w.isolated ? tx('wsp.iso_short_on') : tx('wsp.iso_short_off')} ${tx('wsp.admins_change')}</p>`)}
391
392
  ${!full ? '' : region(tx('wsp.applications'), html`<div class="table-wrap"><table class="report report-reflow">
@@ -36,16 +36,23 @@ export interface SecretColumn {
36
36
  schema: string;
37
37
  table: string;
38
38
  column: string;
39
+ /** (sprint 139) only the rows that match (meta.setting: the settings named *_enc) */
40
+ where?: string;
39
41
  }
40
42
 
43
+ /** (sprint 139) instance settings that hold a secret: meta.instance_setting rows named *_enc (otel_headers_enc) */
44
+ const SETTING_SECRETS: SecretColumn = { schema: 'meta', table: 'instance_setting', column: 'value', where: `name like '%\\_enc'` };
45
+
41
46
  export async function encryptedColumns(): Promise<SecretColumn[]> {
42
- return (await owner.query<SecretColumn>(
47
+ return [...(await owner.query<SecretColumn>(
43
48
  `select table_schema as schema, table_name as table, column_name as column from information_schema.columns
44
49
  where table_schema = 'meta' and data_type = 'text'
45
50
  and (column_name like '%\\_enc' or (table_name = 'push_key' and column_name = 'private_key'))
46
- order by 2, 3`)).rows;
51
+ order by 2, 3`)).rows, SETTING_SECRETS];
47
52
  }
48
53
 
54
+ const rowsOf = (c: SecretColumn) => `${quoteIdent(c.column)} is not null${c.where ? ` and (${c.where})` : ''}`;
55
+
49
56
  const quoteIdent = (s: string) => `"${s.replace(/"/g, '""')}"`;
50
57
  const tableOf = (c: SecretColumn) => `${quoteIdent(c.schema)}.${quoteIdent(c.table)}`;
51
58
 
@@ -54,7 +61,7 @@ export async function secretStatus(withKey?: string) {
54
61
  const out: (SecretColumn & { stored: number; unreadable: number })[] = [];
55
62
  const usable = (withKey ?? process.env.PGKILN_SECRET_KEY ?? '').length >= 32;
56
63
  for (const c of await encryptedColumns()) {
57
- const rows = (await owner.query<{ v: string }>(`select ${quoteIdent(c.column)} as v from ${tableOf(c)} where ${quoteIdent(c.column)} is not null`)).rows;
64
+ const rows = (await owner.query<{ v: string }>(`select ${quoteIdent(c.column)} as v from ${tableOf(c)} where ${rowsOf(c)}`)).rows;
58
65
  let unreadable = 0;
59
66
  for (const r of rows) {
60
67
  try {
@@ -228,7 +235,7 @@ export async function rotateKey(oldKey: string, newKey: string, { dryRun = false
228
235
  const failed: string[] = [];
229
236
  for (const col of cols) {
230
237
  const rows = (await c.query<{ ctid: string; v: string }>(
231
- `select ctid::text as ctid, ${quoteIdent(col.column)} as v from ${tableOf(col)} where ${quoteIdent(col.column)} is not null for update`)).rows;
238
+ `select ctid::text as ctid, ${quoteIdent(col.column)} as v from ${tableOf(col)} where ${rowsOf(col)} for update`)).rows;
232
239
  let rotated = 0;
233
240
  let already = 0;
234
241
  for (const r of rows) {
package/src/layout.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { html, type Raw } from './html.ts';
2
2
  import { staticUrl } from './static-version.ts';
3
+ import { browserTraceMeta } from './telemetry/browser.ts';
3
4
 
4
5
  /** Attributes of <html>: language, text direction and a fixed theme (light/dark; absent = follow the OS). */
5
6
  export interface RootAttrs {
@@ -20,7 +21,7 @@ export function documentShell(title: string, body: Raw, bodyClass = '', data: Re
20
21
  <title>${title}</title>
21
22
  <link rel="stylesheet" href="${staticUrl('app.css')}">
22
23
  <script src="${staticUrl('app.js')}" defer></script>
23
- ${head}
24
+ ${browserTraceMeta()}${head}
24
25
  </head>
25
26
  <body class="${bodyClass}"${attrs}>
26
27
  ${body}
package/src/mail/mail.ts CHANGED
@@ -14,6 +14,7 @@ import { owner } from '../db.ts';
14
14
  import { decryptSecret } from '../secrets.ts';
15
15
  import { mailHostAddress, scopeForWorkspace } from '../network-acl.ts';
16
16
  import { deliver, MailError, verifyConnection, type OutgoingMail, type Provider, type ServiceSettings } from './providers.ts';
17
+ import { count, markError, rootSpan, setAttrs, traceApp } from '../telemetry/trace.ts';
17
18
 
18
19
  export interface MailService {
19
20
  id: number;
@@ -95,6 +96,14 @@ interface Claimed {
95
96
  }
96
97
 
97
98
  async function sendOne(m: Claimed) {
99
+ // (sprint 139, AP-6) each attempt a trace of its own (or a span of the request that sends at once)
100
+ return rootSpan('mail send', { 'pgkiln.mail.id': String(m.id), 'pgkiln.mail.attempt': m.attempts }, async () => {
101
+ traceApp({ appId: m.app_id });
102
+ await sendOneTraced(m);
103
+ });
104
+ }
105
+
106
+ async function sendOneTraced(m: Claimed) {
98
107
  // the application's service, else (sprint 118) its workspace's own
99
108
  const service = await owner.one<MailService>(`select s.* from meta.mail_service s where s.id = meta.mail_service_for_app($1)`, [m.app_id]);
100
109
  const t0 = performance.now();
@@ -112,10 +121,15 @@ async function sendOne(m: Claimed) {
112
121
  await owner.query(`update meta.mail_queue set status = 'sent', sent_at = now(), provider_id = $2, service = $3, message = null,
113
122
  last_attempt_at = now(), duration_ms = $4, provider = $5 where id = $1`,
114
123
  [m.id, id ? String(id).slice(0, 500) : null, service.name, ms, provider]);
124
+ setAttrs({ 'pgkiln.mail.provider': provider, 'pgkiln.mail.service': service.name });
125
+ count('pgkiln.mail.sent', { status: 'sent' });
115
126
  } catch (e) {
116
127
  const retry = e instanceof MailError ? e.retry : true;
117
128
  const message = (e as Error).message.slice(0, 2000);
118
129
  const [ms, provider] = timing();
130
+ setAttrs({ 'pgkiln.mail.provider': provider, 'pgkiln.mail.service': service?.name ?? null, 'pgkiln.mail.retry': retry && m.attempts < ATTEMPTS });
131
+ markError(message);
132
+ count('pgkiln.mail.sent', { status: retry && m.attempts < ATTEMPTS ? 'retry' : 'error' });
119
133
  if (retry && m.attempts < ATTEMPTS)
120
134
  await owner.query(`update meta.mail_queue set status = 'queued', not_before = now() + make_interval(mins => $2), message = $3, service = $4,
121
135
  last_attempt_at = now(), duration_ms = $5, provider = $6 where id = $1`,
@@ -8,6 +8,7 @@ import { isAuthorized, pageAllowed } from './authz.ts';
8
8
  import { bindValues, dbg, publicError, stripSemicolon, type PageContext } from './context.ts';
9
9
  import { txContext } from './routes.ts';
10
10
  import { retrieve } from './search.ts';
11
+ import { recordSpan } from '../telemetry/trace.ts';
11
12
 
12
13
  // AI agents and AI tools (migration 121; APEX 26.1 / 26.2: Shared Components
13
14
  // → AI Agents). Both are lists in meta.app (ai_agents, ai_tools), exported
@@ -397,6 +398,14 @@ export async function logToolCall(
397
398
  ctx: PageContext, regionId: number, agent: string | null, tool: string, type: string, input: Record<string, unknown>, out: AgentOutcome, ms: number,
398
399
  ) {
399
400
  const status = out.refused ? 'refused' : out.isError ? 'error' : 'ok';
401
+ // (sprint 139, AP-6) the tool call as a span of the page's trace (its arguments only where values are included)
402
+ recordSpan(`AI tool ${tool}`.slice(0, 120), {
403
+ ms,
404
+ kind: 1,
405
+ attrs: { 'gen_ai.operation.name': 'execute_tool', 'gen_ai.tool.name': tool, 'gen_ai.tool.type': type, 'pgkiln.ai.agent': agent, 'pgkiln.ai.tool.status': status },
406
+ vals: { 'gen_ai.tool.call.arguments': JSON.stringify(input ?? {}).slice(0, 2000) },
407
+ error: status === 'ok' ? null : status,
408
+ });
400
409
  let args = JSON.stringify(input ?? {});
401
410
  if (args.length > MAX_ARG_CHARS) args = JSON.stringify({ cut_off: args.slice(0, MAX_ARG_CHARS) });
402
411
  dbg(ctx, 4, 'ai', () => `AI tool ${tool} (${type}${agent ? `, agent ${agent}` : ''}) ${args} → ${status} in ${Math.round(ms)} ms${out.isError ? `: ${out.content.slice(0, 300)}` : ''}`);
@@ -55,6 +55,7 @@ import { availabilityRefusal } from './availability.ts';
55
55
  import { feedbackRoutes } from './feedback.ts';
56
56
  import { dataItemRoutes, loadPreferences, savePreferences } from './data-items.ts';
57
57
  import { ajaxRoutes } from './ajax.ts';
58
+ import { traceApp } from '../telemetry/trace.ts';
58
59
 
59
60
  const XLSX_TYPE = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
60
61
 
@@ -266,6 +267,8 @@ export async function loadContext(req: Req, reply: FastifyReply, { json = false,
266
267
  if (!page) return simplePage(reply, 404, locale.t('error.not_found'), locale.t('error.page_not_found', { page: req.params.page, app: app.name }), base, locale), null;
267
268
  if (locale.lang !== app.language) translatePage(page, locale.tr);
268
269
  const user = app.authentication === 'none' ? 'nobody' : (session.username ?? 'nobody');
270
+ // (sprint 139, AP-6) the request's trace: which application (its workspace decides where it goes), page and user
271
+ await traceApp({ appId: app.id, alias: app.alias, page: page.page_no, user });
269
272
 
270
273
  if (app.authentication !== 'none' && (page.requires_auth || fixedPage) && !session.username) {
271
274
  if (json) reply.code(401).send({ error: locale.t('error.session_ended') });
package/src/session.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import type { FastifyReply, FastifyRequest } from 'fastify';
2
+ import { count, emitLog } from './telemetry/trace.ts';
3
+ import { appOf } from './telemetry/config.ts';
2
4
  import { runtime, sharedRuntime } from './db.ts';
3
5
  import { runtimeForApp } from './workspace-db.ts';
4
6
  import { hashToken, loginMaxFailuresPerIp, loginMaxFailuresPerUser, loginWindowMinutes, newToken } from './security.ts';
@@ -121,6 +123,7 @@ export async function logActivity(entry: {
121
123
  elapsedMs?: number;
122
124
  detail?: string;
123
125
  }) {
126
+ activityTelemetry(entry);
124
127
  try {
125
128
  // (090) on the connection of the entry's application (an isolated workspace's own); without one, the shared one
126
129
  const db = entry.appId != null ? await runtimeForApp(entry.appId) : sharedRuntime;
@@ -152,3 +155,26 @@ export async function loginThrottled(appId: number | null, username: string, ip:
152
155
  );
153
156
  return !!counts && (counts.user_fails >= loginMaxFailuresPerUser() || counts.ip_fails >= loginMaxFailuresPerIp());
154
157
  }
158
+
159
+ // (sprint 139, AP-6) errors, refusals and failed sign-ins as OpenTelemetry logs and metrics
160
+ const OTEL_EVENTS: Record<string, ['ERROR' | 'WARN', string]> = {
161
+ error: ['ERROR', 'pgkiln.errors'],
162
+ forbidden: ['WARN', 'pgkiln.access_denied'],
163
+ login_failed: ['WARN', 'pgkiln.signin_failures'],
164
+ login_locked: ['WARN', 'pgkiln.signin_failures'],
165
+ };
166
+
167
+ function activityTelemetry(e: { appId?: number | null; pageNo?: number | null; username?: string | null; event: string; detail?: string }) {
168
+ const known = OTEL_EVENTS[e.event];
169
+ if (!known) return;
170
+ const app = e.appId == null ? 'builder' : appOf(e.appId)?.[0] ?? 'unknown';
171
+ count(known[1], e.event === 'login_locked' ? { app, reason: 'locked' } : { app });
172
+ emitLog({
173
+ severity: known[0],
174
+ body: e.event,
175
+ detail: e.detail,
176
+ attrs: { 'pgkiln.event': e.event, 'pgkiln.app.id': e.appId ?? null, 'pgkiln.app.alias': e.appId == null ? null : app, 'pgkiln.page': e.pageNo == null ? null : String(e.pageNo) },
177
+ appId: e.appId ?? null,
178
+ user: e.username ?? null,
179
+ });
180
+ }
@@ -0,0 +1,91 @@
1
+ import { html, type Raw } from '../html.ts';
2
+ import { checksumValid, signText } from '../security.ts';
3
+ import { browserSpans } from './export.ts';
4
+ import { currentCtx, now, SPAN_KIND, type Span } from './trace.ts';
5
+
6
+ // (sprint 139, AP-6) Browser spans: a page whose trace is sent carries a signed
7
+ // <meta name="pgkiln-trace"> (layout.ts); app.js reports the page load and the
8
+ // page's Ajax calls to POST /a/:alias/telemetry (src/telemetry/http.ts), and
9
+ // relay() turns them into spans of the page's trace. The token is bound to the
10
+ // trace, the page's span, the time and the application, and is good for 30
11
+ // minutes and 200 spans. No collector address or token reaches the browser.
12
+
13
+ const TOKEN_MINUTES = 30;
14
+ const MAX_PER_TOKEN = 200;
15
+ const used = new Map<string, number>();
16
+ let usedSince = Date.now();
17
+
18
+ /** The page's <meta> for browser spans: its trace, span, time and a signature (empty when not wanted). */
19
+ export function browserTraceMeta(): Raw | '' {
20
+ const ctx = currentCtx();
21
+ if (!ctx?.trace.browser || !ctx.trace.sampled || !ctx.trace.alias) return '';
22
+ const root = ctx.trace.spans[0];
23
+ const ts = Math.floor(Date.now() / 1000);
24
+ const payload = `${root.traceId}.${root.spanId}.${ts}`;
25
+ return html`<meta name="pgkiln-trace" content="${payload}.${signText('otel-browser', `${payload}.${ctx.trace.alias.toLowerCase()}`)}">`;
26
+ }
27
+
28
+ interface BrowserSpan {
29
+ k?: unknown;
30
+ path?: unknown;
31
+ method?: unknown;
32
+ status?: unknown;
33
+ start?: unknown;
34
+ dur?: unknown;
35
+ t?: Record<string, unknown>;
36
+ }
37
+
38
+ const num = (v: unknown, max: number) => (typeof v === 'number' && Number.isFinite(v) ? Math.min(Math.max(v, 0), max) : null);
39
+
40
+ /** Check a page's token and turn its reports into spans. Returns the number kept, or null for a bad token. */
41
+ export function relay(alias: string, token: unknown, spans: unknown): number | null {
42
+ const m = typeof token === 'string' ? /^([0-9a-f]{32})\.([0-9a-f]{16})\.(\d{1,12})\.([\w-]{22})$/.exec(token) : null;
43
+ if (!m) return null;
44
+ const [, traceId, spanId, ts, sig] = m;
45
+ if (!checksumValid(signText('otel-browser', `${traceId}.${spanId}.${ts}.${alias.toLowerCase()}`), sig)) return null;
46
+ if (Date.now() / 1000 - Number(ts) > TOKEN_MINUTES * 60) return null;
47
+ if (Date.now() - usedSince > 3_600_000) {
48
+ used.clear();
49
+ usedSince = Date.now();
50
+ }
51
+ const list = Array.isArray(spans) ? (spans as BrowserSpan[]).slice(0, 50) : [];
52
+ const room = MAX_PER_TOKEN - (used.get(`${traceId}.${spanId}`) ?? 0);
53
+ if (room <= 0 || !list.length) return 0;
54
+ const prefix = `/a/${alias}/`.toLowerCase();
55
+ const out: Span[] = [];
56
+ const t = now();
57
+ for (const b of list.slice(0, room)) {
58
+ const dur = num(b.dur, 600_000);
59
+ const start = num(b.start, 8.64e15);
60
+ if (dur === null || start === null || Math.abs(start - t) > 3_600_000) continue;
61
+ const path = typeof b.path === 'string' ? b.path.split('#')[0] : '';
62
+ const [p, query] = path.split('?');
63
+ const s: Span = {
64
+ traceId, spanId: '', parentId: spanId, kind: SPAN_KIND.client, start, end: start + dur, name: '', attrs: { 'pgkiln.browser': true },
65
+ };
66
+ if (b.k === 'load') {
67
+ s.kind = SPAN_KIND.internal;
68
+ s.name = 'browser page load';
69
+ for (const k of ['ttfb', 'dom_interactive', 'dom_complete', 'load']) {
70
+ const v = num(b.t?.[k], 600_000);
71
+ if (v !== null) s.attrs[`pgkiln.browser.${k}_ms`] = Math.round(v);
72
+ }
73
+ } else if (b.k === 'ajax' && p.toLowerCase().startsWith(prefix) && p.length <= 500) {
74
+ const method = typeof b.method === 'string' && /^(GET|POST|PUT|PATCH|DELETE)$/.test(b.method) ? b.method : 'GET';
75
+ s.name = `browser ${method}`;
76
+ s.attrs['http.request.method'] = method;
77
+ s.attrs['url.path'] = p;
78
+ const st = num(b.status, 599);
79
+ if (st !== null) {
80
+ s.attrs['http.response.status_code'] = Math.round(st);
81
+ if (st >= 500 || st === 0) s.error = st ? `HTTP ${Math.round(st)}` : 'network error';
82
+ }
83
+ if (query) s.vals = { 'url.query': query.slice(0, 2000) };
84
+ } else continue;
85
+ out.push(s);
86
+ }
87
+ used.set(`${traceId}.${spanId}`, MAX_PER_TOKEN - room + out.length);
88
+ if (out.length) browserSpans(traceId, alias, out);
89
+ return out.length;
90
+ }
91
+