pgkiln 0.70.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.
Files changed (65) hide show
  1. package/db/migrations/137_create_page_types.sql +904 -0
  2. package/db/migrations/138_app_utilities.sql +153 -0
  3. package/db/migrations/139_opentelemetry.sql +36 -0
  4. package/docs/apex-feature-parity.md +9 -7
  5. package/docs/guide/01-installation.md +108 -0
  6. package/docs/guide/03-builder.md +116 -4
  7. package/docs/guide/04-pages-and-regions.md +3 -1
  8. package/docs/guide/05-items.md +1 -1
  9. package/docs/guide/06-processing.md +18 -0
  10. package/docs/guide/09-reference.md +18 -1
  11. package/docs/guide/12-development.md +35 -1
  12. package/docs/guide/16-files.md +1 -1
  13. package/examples/hr/hr_73_configuration_options.sql +17 -0
  14. package/examples/hr/hr_74_app_utilities.sql +22 -0
  15. package/package.json +1 -1
  16. package/public/app.css +9 -0
  17. package/public/app.js +50 -0
  18. package/public/builder.css +48 -0
  19. package/src/ai/service.ts +14 -0
  20. package/src/app.ts +20 -0
  21. package/src/builder/advisor.ts +2 -2
  22. package/src/builder/app-updates.ts +3 -0
  23. package/src/builder/components.ts +3 -1
  24. package/src/builder/create-page.ts +259 -0
  25. package/src/builder/dependencies.ts +311 -0
  26. package/src/builder/features.ts +313 -0
  27. package/src/builder/home.ts +3 -0
  28. package/src/builder/i18n/de.ts +6 -0
  29. package/src/builder/i18n/en.ts +6 -0
  30. package/src/builder/i18n/es.ts +6 -0
  31. package/src/builder/i18n/fr.ts +6 -0
  32. package/src/builder/i18n/it.ts +6 -0
  33. package/src/builder/i18n/ja.ts +6 -0
  34. package/src/builder/i18n/ko.ts +6 -0
  35. package/src/builder/i18n/nl.ts +6 -0
  36. package/src/builder/i18n/pt.ts +6 -0
  37. package/src/builder/i18n/zh-cn.ts +6 -0
  38. package/src/builder/i18n/zh-tw.ts +6 -0
  39. package/src/builder/login-page.ts +17 -14
  40. package/src/builder/newapp.ts +41 -23
  41. package/src/builder/routes.ts +13 -2
  42. package/src/builder/telemetry.ts +206 -0
  43. package/src/builder/ui-defaults.ts +161 -0
  44. package/src/builder/ui.ts +2 -1
  45. package/src/builder/upgrade.ts +154 -0
  46. package/src/builder/utilities.ts +181 -0
  47. package/src/builder/wizards.ts +211 -26
  48. package/src/builder/workspaces.ts +2 -1
  49. package/src/cli/replace.ts +9 -1
  50. package/src/history.ts +155 -0
  51. package/src/instance-checks.ts +11 -4
  52. package/src/layout.ts +2 -1
  53. package/src/mail/mail.ts +14 -0
  54. package/src/runtime/ai-agents.ts +9 -0
  55. package/src/runtime/builtin-components.ts +23 -2
  56. package/src/runtime/items.ts +4 -2
  57. package/src/runtime/routes.ts +3 -0
  58. package/src/session.ts +26 -0
  59. package/src/telemetry/browser.ts +91 -0
  60. package/src/telemetry/config.ts +342 -0
  61. package/src/telemetry/export.ts +484 -0
  62. package/src/telemetry/http.ts +126 -0
  63. package/src/telemetry/trace.ts +456 -0
  64. package/src/webclient.ts +25 -0
  65. package/src/workflow.ts +14 -7
package/src/history.ts ADDED
@@ -0,0 +1,155 @@
1
+ // (sprint 138, AP-5) Change history per component (APEX: Application Utilities ›
2
+ // Change History) and application checksums (APEX: Application Checksum).
3
+ //
4
+ // meta.app_snapshot keeps an application's export after its last recorded
5
+ // change. After a builder change (app-updates.ts, onSend) and inside an import
6
+ // --replace (cli/replace.ts: restores, merges, the command line and the MCP
7
+ // server), recordHistory() exports the application again, splits both exports
8
+ // into components the way working copies compare them (workingcopy.ts
9
+ // components(): a region with its SQL, a page's settings, a list of values, …)
10
+ // and stores each added, changed or deleted component with its files before and
11
+ // after in meta.component_change (migration 138). The first change of an
12
+ // application without a snapshot has nothing to compare with: ensureSnapshot()
13
+ // takes one before a builder POST runs (a preHandler hook).
14
+ //
15
+ // appChecksum(): a SHA-256 over the export's files (the text layout, without the
16
+ // format marker and with the alias blanked), so two installations, or an
17
+ // application and its working copy, compare at a glance; pageChecksums() per page.
18
+ import { createHash } from 'node:crypto';
19
+ import type pg from 'pg';
20
+ import { docToFiles, MARKER, type Doc, type FileMap } from './appfiles.ts';
21
+ import { owner } from './db.ts';
22
+ import { components, describe } from './workingcopy.ts';
23
+
24
+ type Db = { query: (sql: string, params?: unknown[]) => Promise<pg.QueryResult<any>> };
25
+
26
+ /** Days a change stays in the history (CHANGE_HISTORY_DAYS, default 365; 0 keeps everything). */
27
+ export const historyDays = () => {
28
+ const n = Number(process.env.CHANGE_HISTORY_DAYS ?? 365);
29
+ return Number.isFinite(n) && n >= 0 ? Math.floor(n) : 365;
30
+ };
31
+
32
+ /** The export as files of the text layout (SQL inline in YAML: readable diffs), the alias blanked. */
33
+ export function appFiles(doc: Doc): FileMap {
34
+ const d = { ...doc, app: { ...(doc.app ?? {}), alias: '' } };
35
+ const files = docToFiles(d, 'text');
36
+ files.delete(MARKER);
37
+ return files;
38
+ }
39
+
40
+ const isText = (b: Buffer) => !b.subarray(0, 8000).includes(0);
41
+ const asText = (files: Map<string, Buffer> | undefined) =>
42
+ files ? Object.fromEntries([...files].map(([p, b]) => [p, isText(b) ? b.toString('utf8') : `(binary, ${b.length} bytes, sha256 ${createHash('sha256').update(b).digest('hex').slice(0, 16)})`])) : null;
43
+
44
+ const sameFiles = (a?: Map<string, Buffer>, b?: Map<string, Buffer>) => {
45
+ if (!a || !b) return !a && !b;
46
+ if (a.size !== b.size) return false;
47
+ for (const [p, buf] of a) if (!b.get(p)?.equals(buf)) return false;
48
+ return true;
49
+ };
50
+
51
+ export interface ComponentDiff {
52
+ component: string;
53
+ label: string;
54
+ page: number | null;
55
+ op: 'added' | 'changed' | 'deleted';
56
+ before: Record<string, string> | null;
57
+ after: Record<string, string> | null;
58
+ }
59
+
60
+ /** The components that differ between two exports. */
61
+ export function diffDocs(before: Doc, after: Doc): ComponentDiff[] {
62
+ const a = components(appFiles(before)), b = components(appFiles(after));
63
+ const out: ComponentDiff[] = [];
64
+ for (const id of [...new Set([...a.keys(), ...b.keys()])].sort()) {
65
+ const x = a.get(id), y = b.get(id);
66
+ if (sameFiles(x, y)) continue;
67
+ const { label, page } = describe(id);
68
+ out.push({ component: id, label, page, op: !x ? 'added' : !y ? 'deleted' : 'changed', before: asText(x), after: asText(y) });
69
+ }
70
+ return out;
71
+ }
72
+
73
+ const exportDoc = async (db: Db, appId: number): Promise<Doc | null> =>
74
+ (await db.query('select meta.export_app(alias) as d from meta.app where id = $1', [appId])).rows[0]?.d ?? null;
75
+
76
+ /** Take a first snapshot of an application that has none (before its first recorded change); with `db`, inside its transaction. */
77
+ export async function ensureSnapshot(appId: number, db?: pg.ClientBase) {
78
+ const sql = `insert into meta.app_snapshot (app_id, doc) select id, meta.export_app(alias) from meta.app
79
+ where id = $1 and not exists (select 1 from meta.app_snapshot where app_id = $1)
80
+ on conflict (app_id) do nothing`;
81
+ if (!db) return void (await owner.query(sql, [appId]));
82
+ await db.query('savepoint history_snapshot');
83
+ try {
84
+ await db.query(sql, [appId]);
85
+ await db.query('release savepoint history_snapshot');
86
+ } catch {
87
+ await db.query('rollback to savepoint history_snapshot');
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Compare the application with its snapshot, record what changed and keep the new snapshot.
93
+ * With `db` a client in the caller's transaction (an import --replace), else a transaction of its own.
94
+ */
95
+ export async function recordHistory(appId: number, changedBy: string | null, source: 'builder' | 'import' | 'upgrade' = 'builder', db?: pg.ClientBase): Promise<number> {
96
+ const run = async (c: Db) => {
97
+ // one change at a time per application: the snapshot row is the lock
98
+ const snap = (await c.query('select doc from meta.app_snapshot where app_id = $1 for update', [appId])).rows[0];
99
+ const now = await exportDoc(c, appId);
100
+ if (!now) return 0;
101
+ if (!snap) {
102
+ await c.query('insert into meta.app_snapshot (app_id, doc) values ($1, $2) on conflict (app_id) do update set doc = excluded.doc, taken_at = now()', [appId, now]);
103
+ return 0;
104
+ }
105
+ const changes = diffDocs(snap.doc, now);
106
+ if (!changes.length) return 0;
107
+ await c.query(
108
+ `insert into meta.component_change (app_id, component, label, page_no, op, before, after, changed_by, source)
109
+ select $1, x.component, x.label, x.page, x.op, x.before, x.after, $2, $3
110
+ from jsonb_to_recordset($4::jsonb) as x(component text, label text, page int, op text, before jsonb, after jsonb)`,
111
+ [appId, changedBy?.slice(0, 200) ?? null, source, JSON.stringify(changes)],
112
+ );
113
+ await c.query('update meta.app_snapshot set doc = $2, taken_at = now() where app_id = $1', [appId, now]);
114
+ const days = historyDays();
115
+ if (days) await c.query(`delete from meta.component_change where app_id = $1 and changed_at < now() - make_interval(days => $2)`, [appId, days]);
116
+ return changes.length;
117
+ };
118
+ if (db) {
119
+ await db.query('savepoint history');
120
+ try {
121
+ const n = await run(db);
122
+ await db.query('release savepoint history');
123
+ return n;
124
+ } catch (e) {
125
+ await db.query('rollback to savepoint history');
126
+ throw e;
127
+ }
128
+ }
129
+ return owner.tx(run);
130
+ }
131
+
132
+ /** SHA-256 of a set of files (paths and contents, in path order). */
133
+ function hashFiles(files: [string, Buffer][]) {
134
+ const h = createHash('sha256');
135
+ for (const [p, b] of files.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) h.update(p).update('\0').update(b).update('\0');
136
+ return h.digest('hex');
137
+ }
138
+
139
+ /** The application checksum of an export (the same definition gives the same checksum on any installation). */
140
+ export const appChecksum = (doc: Doc) => hashFiles([...appFiles(doc)]);
141
+
142
+ /** A checksum per page: page number → the hash of the page's files. */
143
+ export function pageChecksums(doc: Doc): Map<number, string> {
144
+ const byPage = new Map<number, [string, Buffer][]>();
145
+ for (const [id, files] of components(appFiles(doc))) {
146
+ const page = id.startsWith('pages/') ? describe(id).page : null;
147
+ if (page === null) continue;
148
+ if (!byPage.has(page)) byPage.set(page, []);
149
+ byPage.get(page)!.push(...files);
150
+ }
151
+ return new Map([...byPage].sort(([a], [b]) => a - b).map(([p, f]) => [p, hashFiles(f)]));
152
+ }
153
+
154
+ /** A short form for display (the first 16 hex digits). */
155
+ export const shortSum = (sum: string) => sum.slice(0, 16);
@@ -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)}` : ''}`);
@@ -1,8 +1,8 @@
1
1
  import type { TemplateComponent } from './template-components.ts';
2
2
 
3
3
  // Built-in template components (APEX: the Universal Theme's template
4
- // components: Avatar, Badge, Comments, Media List, Timeline, and 26.1's
5
- // Metric Card). Every application can use them in a template_component
4
+ // components: Avatar, Badge, Comments, Content Row (137), Media List,
5
+ // Timeline, and 26.1's Metric Card). Every application can use them in a template_component
6
6
  // region or as a report column template without importing anything; a
7
7
  // component of the application with the same static id takes its place
8
8
  // (Shared Components → Template components → Copy into this application).
@@ -53,6 +53,27 @@ export const BUILTIN_COMPONENTS: readonly TemplateComponent[] = [
53
53
  css_classes: [],
54
54
  attributes: [text('USER', 'User'), text('INITIALS', 'Initials'), text('DATE', 'Date'), text('COMMENT', 'Comment'), text('ACTIONS', 'Extra text', 'Optional, e.g. "edited".')],
55
55
  },
56
+ {
57
+ // (137) APEX: Content Row: an avatar, a title and description, and on the right a secondary text and a badge
58
+ static_id: 'ut_content_row',
59
+ name: 'Content row',
60
+ description: 'Rows with initials or a picture, a title (linked when the region has a link) and a description, and on the right a secondary text and a badge. Show the region as "multiple".',
61
+ template:
62
+ '<li class="tc-content-row">{if ?IMAGE/}<img class="tc-avatar tc-avatar-md" src="#IMAGE#" alt="" loading="lazy">{else/}<span class="tc-avatar tc-avatar-md" aria-hidden="true">#INITIALS#</span>{endif/}' +
63
+ '<div class="tc-stack"><p class="tc-title">{if ?LINK/}<a href="#LINK#">#TITLE#</a>{else/}#TITLE#{endif/}</p>{if ?DESCRIPTION/}<p class="tc-meta">#DESCRIPTION#</p>{endif/}</div>' +
64
+ '<div class="tc-content-row-end">{if ?MISC/}<span class="tc-meta">#MISC#</span>{endif/}{if ?BADGE/}<span class="tc-badge tc-badge-' + STATE + '">#BADGE#</span>{endif/}</div></li>',
65
+ wrapper: '<ul class="tc-content-rows">#APEX$ROWS#</ul>',
66
+ css_classes: [],
67
+ attributes: [
68
+ text('TITLE', 'Title'),
69
+ text('DESCRIPTION', 'Description'),
70
+ text('MISC', 'Secondary text', 'Shown on the right, e.g. a date or an amount.'),
71
+ text('INITIALS', 'Initials', 'Shown without a picture.'),
72
+ text('IMAGE', 'Picture URL', 'Optional: a relative or https URL.'),
73
+ text('BADGE', 'Badge'),
74
+ text('STATE', 'Badge state', 'success, warning, danger, info or neutral.'),
75
+ ],
76
+ },
56
77
  {
57
78
  static_id: 'ut_media_list',
58
79
  name: 'Media list',
@@ -309,9 +309,11 @@ export async function renderItem(ctx: PageContext, item: Item, hiddenByDa = fals
309
309
  case 'checkbox_group': {
310
310
  useLegend = true;
311
311
  const selected = new Set(splitValues(value));
312
- control = html`<div class="radio-group">${options.map(
312
+ // (137) {"switches": true}: each value as a switch (the Configuration page's build options)
313
+ const switches = item.config?.switches === true;
314
+ control = html`<div class="radio-group${switches ? ' switch-group' : ''}">${options.map(
313
315
  (o, i) =>
314
- html`<label class="check"><input type="checkbox" id="${i === 0 ? id : `${id}_${i}`}" name="${id}" value="${o.value}"${selected.has(o.value) ? raw(' checked') : ''}${aria}> ${o.display}</label>`,
316
+ html`<label class="check${switches ? ' switch' : ''}"><input type="checkbox" id="${i === 0 ? id : `${id}_${i}`}" name="${id}" value="${o.value}"${switches ? raw(' role="switch"') : ''}${selected.has(o.value) ? raw(' checked') : ''}${aria}> ${o.display}</label>`,
315
317
  )}</div>`;
316
318
  break;
317
319
  }
@@ -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
+