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.
- package/db/migrations/137_create_page_types.sql +904 -0
- package/db/migrations/138_app_utilities.sql +153 -0
- package/db/migrations/139_opentelemetry.sql +36 -0
- package/docs/apex-feature-parity.md +9 -7
- package/docs/guide/01-installation.md +108 -0
- package/docs/guide/03-builder.md +116 -4
- package/docs/guide/04-pages-and-regions.md +3 -1
- package/docs/guide/05-items.md +1 -1
- package/docs/guide/06-processing.md +18 -0
- package/docs/guide/09-reference.md +18 -1
- package/docs/guide/12-development.md +35 -1
- package/docs/guide/16-files.md +1 -1
- package/examples/hr/hr_73_configuration_options.sql +17 -0
- package/examples/hr/hr_74_app_utilities.sql +22 -0
- package/package.json +1 -1
- package/public/app.css +9 -0
- package/public/app.js +50 -0
- package/public/builder.css +48 -0
- package/src/ai/service.ts +14 -0
- package/src/app.ts +20 -0
- package/src/builder/advisor.ts +2 -2
- package/src/builder/app-updates.ts +3 -0
- package/src/builder/components.ts +3 -1
- package/src/builder/create-page.ts +259 -0
- package/src/builder/dependencies.ts +311 -0
- package/src/builder/features.ts +313 -0
- package/src/builder/home.ts +3 -0
- package/src/builder/i18n/de.ts +6 -0
- package/src/builder/i18n/en.ts +6 -0
- package/src/builder/i18n/es.ts +6 -0
- package/src/builder/i18n/fr.ts +6 -0
- package/src/builder/i18n/it.ts +6 -0
- package/src/builder/i18n/ja.ts +6 -0
- package/src/builder/i18n/ko.ts +6 -0
- package/src/builder/i18n/nl.ts +6 -0
- package/src/builder/i18n/pt.ts +6 -0
- package/src/builder/i18n/zh-cn.ts +6 -0
- package/src/builder/i18n/zh-tw.ts +6 -0
- package/src/builder/login-page.ts +17 -14
- package/src/builder/newapp.ts +41 -23
- package/src/builder/routes.ts +13 -2
- package/src/builder/telemetry.ts +206 -0
- package/src/builder/ui-defaults.ts +161 -0
- package/src/builder/ui.ts +2 -1
- package/src/builder/upgrade.ts +154 -0
- package/src/builder/utilities.ts +181 -0
- package/src/builder/wizards.ts +211 -26
- package/src/builder/workspaces.ts +2 -1
- package/src/cli/replace.ts +9 -1
- package/src/history.ts +155 -0
- 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/builtin-components.ts +23 -2
- package/src/runtime/items.ts +4 -2
- 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,342 @@
|
|
|
1
|
+
import { owner } from '../db.ts';
|
|
2
|
+
import { decryptSecret, encryptSecret } from '../secrets.ts';
|
|
3
|
+
import { hashToken } from '../security.ts';
|
|
4
|
+
import { outside, setSampleRatio } from './trace.ts';
|
|
5
|
+
|
|
6
|
+
// (sprint 139, AP-6) Where telemetry goes. The instance's collector is set on
|
|
7
|
+
// Workspace utilities → OpenTelemetry (meta.setting otel_*), else by the
|
|
8
|
+
// standard OTEL_* environment variables; each workspace may switch its
|
|
9
|
+
// applications off or add a collector of its own (meta.workspace.telemetry*,
|
|
10
|
+
// migration 139). Read every 30 seconds like the other instance settings.
|
|
11
|
+
|
|
12
|
+
export type UserMode = 'hashed' | 'name' | 'none';
|
|
13
|
+
export type Origin = 'builder' | 'environment' | 'default';
|
|
14
|
+
|
|
15
|
+
export interface InstanceTelemetry {
|
|
16
|
+
enabled: boolean;
|
|
17
|
+
/** the OTLP/HTTP base URL (…:4318); per-signal URLs below */
|
|
18
|
+
endpoint: string;
|
|
19
|
+
urls: { traces: string; logs: string; metrics: string };
|
|
20
|
+
headers: Record<string, string>;
|
|
21
|
+
/** header names only (the page never shows the values) */
|
|
22
|
+
headerNames: string[];
|
|
23
|
+
serviceName: string;
|
|
24
|
+
resource: Record<string, string>;
|
|
25
|
+
sample: number;
|
|
26
|
+
user: UserMode;
|
|
27
|
+
values: boolean;
|
|
28
|
+
traces: boolean;
|
|
29
|
+
logs: boolean;
|
|
30
|
+
metrics: boolean;
|
|
31
|
+
/** builder requests are traced too (applications only by default) */
|
|
32
|
+
builder: boolean;
|
|
33
|
+
/** pages send a span per page load and Ajax call (relayed by pgkiln) */
|
|
34
|
+
browser: boolean;
|
|
35
|
+
/** GET /metrics in the Prometheus format */
|
|
36
|
+
prometheus: boolean;
|
|
37
|
+
prometheusTokenHash: string | null;
|
|
38
|
+
intervalMs: number;
|
|
39
|
+
/** OTEL_EXPORTER_OTLP_PROTOCOL asks for something other than http/json */
|
|
40
|
+
protocolWarning: string | null;
|
|
41
|
+
origin: Record<string, Origin>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type WorkspaceMode = 'instance' | 'off' | 'own';
|
|
45
|
+
|
|
46
|
+
export interface WorkspaceTelemetry {
|
|
47
|
+
id: number;
|
|
48
|
+
mode: WorkspaceMode;
|
|
49
|
+
endpoint: string | null;
|
|
50
|
+
headers: Record<string, string>;
|
|
51
|
+
values: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The builder's keys in meta.setting. */
|
|
55
|
+
export const KEYS = ['otel_enabled', 'otel_endpoint', 'otel_headers_enc', 'otel_service_name', 'otel_sample', 'otel_user', 'otel_values',
|
|
56
|
+
'otel_traces', 'otel_logs', 'otel_metrics', 'otel_builder', 'otel_browser', 'metrics_endpoint', 'metrics_token_hash'] as const;
|
|
57
|
+
type Key = (typeof KEYS)[number];
|
|
58
|
+
/** Kept in meta.instance_setting (only the owner reads it; the runtime role reads meta.setting). */
|
|
59
|
+
const PRIVATE = new Set<string>(['otel_headers_enc', 'metrics_token_hash']);
|
|
60
|
+
const tableFor = (k: string) => (PRIVATE.has(k) ? 'meta.instance_setting' : 'meta.setting');
|
|
61
|
+
|
|
62
|
+
const REFRESH_MS = 30_000;
|
|
63
|
+
let stored = new Map<string, string>();
|
|
64
|
+
let workspaces = new Map<number, WorkspaceTelemetry>();
|
|
65
|
+
/** application id → [alias, workspace id] */
|
|
66
|
+
let apps = new Map<number, [string, number | null]>();
|
|
67
|
+
let aliases = new Map<string, number>();
|
|
68
|
+
let loadedAt = 0;
|
|
69
|
+
let loading: Promise<void> | null = null;
|
|
70
|
+
let current: InstanceTelemetry | null = null;
|
|
71
|
+
let listeners: (() => void)[] = [];
|
|
72
|
+
/** Called after every reload (src/telemetry/export.ts: timers). */
|
|
73
|
+
export const onTelemetryConfig = (fn: () => void) => listeners.push(fn);
|
|
74
|
+
|
|
75
|
+
const env = (name: string) => {
|
|
76
|
+
const v = process.env[name];
|
|
77
|
+
return v === undefined || v.trim() === '' ? undefined : v.trim();
|
|
78
|
+
};
|
|
79
|
+
const bool = (v: string | undefined) => (v === undefined ? undefined : /^(true|on|1|yes)$/i.test(v) ? true : /^(false|off|0|no)$/i.test(v) ? false : undefined);
|
|
80
|
+
|
|
81
|
+
/** "k=v,k2=v2" (OTEL_EXPORTER_OTLP_HEADERS, OTEL_RESOURCE_ATTRIBUTES): values URL-decoded. */
|
|
82
|
+
export function parsePairs(s: string | undefined): Record<string, string> {
|
|
83
|
+
const out: Record<string, string> = {};
|
|
84
|
+
for (const part of (s ?? '').split(/[,\n]/)) {
|
|
85
|
+
const i = part.indexOf('=');
|
|
86
|
+
if (i <= 0) continue;
|
|
87
|
+
const k = part.slice(0, i).trim();
|
|
88
|
+
let v = part.slice(i + 1).trim();
|
|
89
|
+
try {
|
|
90
|
+
v = decodeURIComponent(v);
|
|
91
|
+
} catch {
|
|
92
|
+
// kept as written
|
|
93
|
+
}
|
|
94
|
+
if (/^[A-Za-z0-9._-]{1,100}$/.test(k) && v.length <= 4000 && !/[\r\n]/.test(v)) out[k] = v;
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** An OTLP/HTTP URL: http or https, no fragment. Null when fine, else the problem. */
|
|
100
|
+
export function endpointProblem(raw: string): string | null {
|
|
101
|
+
let u: URL;
|
|
102
|
+
try {
|
|
103
|
+
u = new URL(raw);
|
|
104
|
+
} catch {
|
|
105
|
+
return 'not a URL';
|
|
106
|
+
}
|
|
107
|
+
if (u.protocol !== 'http:' && u.protocol !== 'https:') return 'only http and https';
|
|
108
|
+
if (u.hash) return 'no fragment (#) in the URL';
|
|
109
|
+
if (u.username || u.password) return 'no user name or password in the URL (use a header)';
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The signal's URL: the base with /v1/<signal> appended (OTLP/HTTP's rule for OTEL_EXPORTER_OTLP_ENDPOINT). */
|
|
114
|
+
export const signalUrl = (base: string, signal: 'traces' | 'logs' | 'metrics') => `${base.replace(/\/+$/, '')}/v1/${signal}`;
|
|
115
|
+
|
|
116
|
+
function sampleFromEnv(): number | undefined {
|
|
117
|
+
const sampler = env('OTEL_TRACES_SAMPLER')?.toLowerCase();
|
|
118
|
+
if (sampler === 'always_off' || sampler === 'parentbased_always_off') return 0;
|
|
119
|
+
if (sampler === 'always_on' || sampler === 'parentbased_always_on') return 1;
|
|
120
|
+
const arg = Number(env('OTEL_TRACES_SAMPLER_ARG'));
|
|
121
|
+
return sampler?.includes('traceidratio') && arg >= 0 && arg <= 1 ? arg : undefined;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function build(): InstanceTelemetry {
|
|
125
|
+
const origin: Record<string, Origin> = {};
|
|
126
|
+
const pick = <T>(key: Key, fromStore: (v: string) => T | undefined, fromEnv: T | undefined, dflt: T): T => {
|
|
127
|
+
const s = stored.get(key);
|
|
128
|
+
const v = s === undefined ? undefined : fromStore(s);
|
|
129
|
+
if (v !== undefined) return (origin[key] = 'builder'), v;
|
|
130
|
+
if (fromEnv !== undefined) return (origin[key] = 'environment'), fromEnv;
|
|
131
|
+
origin[key] = 'default';
|
|
132
|
+
return dflt;
|
|
133
|
+
};
|
|
134
|
+
const envEndpoint = env('OTEL_EXPORTER_OTLP_ENDPOINT');
|
|
135
|
+
const endpoint = pick('otel_endpoint', (v) => (endpointProblem(v) ? undefined : v), envEndpoint && !endpointProblem(envEndpoint) ? envEndpoint : undefined, '');
|
|
136
|
+
const disabled = bool(env('OTEL_SDK_DISABLED')) === true;
|
|
137
|
+
const enabled = !disabled && !!endpoint && pick('otel_enabled', bool, envEndpoint ? true : undefined, false);
|
|
138
|
+
let headers: Record<string, string> = {};
|
|
139
|
+
const enc = stored.get('otel_headers_enc');
|
|
140
|
+
if (enc) {
|
|
141
|
+
try {
|
|
142
|
+
headers = parsePairs(decryptSecret(enc));
|
|
143
|
+
origin.otel_headers_enc = 'builder';
|
|
144
|
+
} catch {
|
|
145
|
+
origin.otel_headers_enc = 'builder';
|
|
146
|
+
}
|
|
147
|
+
} else if (env('OTEL_EXPORTER_OTLP_HEADERS')) {
|
|
148
|
+
headers = parsePairs(env('OTEL_EXPORTER_OTLP_HEADERS'));
|
|
149
|
+
origin.otel_headers_enc = 'environment';
|
|
150
|
+
} else origin.otel_headers_enc = 'default';
|
|
151
|
+
const notNone = (name: string) => (env(name)?.toLowerCase() === 'none' ? false : undefined);
|
|
152
|
+
const protocol = env('OTEL_EXPORTER_OTLP_PROTOCOL');
|
|
153
|
+
const t: InstanceTelemetry = {
|
|
154
|
+
enabled,
|
|
155
|
+
endpoint,
|
|
156
|
+
urls: {
|
|
157
|
+
traces: env('OTEL_EXPORTER_OTLP_TRACES_ENDPOINT') && origin.otel_endpoint !== 'builder' ? env('OTEL_EXPORTER_OTLP_TRACES_ENDPOINT')! : signalUrl(endpoint, 'traces'),
|
|
158
|
+
logs: env('OTEL_EXPORTER_OTLP_LOGS_ENDPOINT') && origin.otel_endpoint !== 'builder' ? env('OTEL_EXPORTER_OTLP_LOGS_ENDPOINT')! : signalUrl(endpoint, 'logs'),
|
|
159
|
+
metrics: env('OTEL_EXPORTER_OTLP_METRICS_ENDPOINT') && origin.otel_endpoint !== 'builder' ? env('OTEL_EXPORTER_OTLP_METRICS_ENDPOINT')! : signalUrl(endpoint, 'metrics'),
|
|
160
|
+
},
|
|
161
|
+
headers,
|
|
162
|
+
headerNames: Object.keys(headers),
|
|
163
|
+
serviceName: pick('otel_service_name', (v) => (/^[\w .:/-]{1,100}$/.test(v) ? v : undefined), env('OTEL_SERVICE_NAME'), 'pgkiln'),
|
|
164
|
+
resource: parsePairs(env('OTEL_RESOURCE_ATTRIBUTES')),
|
|
165
|
+
sample: pick('otel_sample', (v) => (Number(v) >= 0 && Number(v) <= 1 ? Number(v) : undefined), sampleFromEnv(), 1),
|
|
166
|
+
user: pick('otel_user', (v) => (['hashed', 'name', 'none'].includes(v) ? (v as UserMode) : undefined), undefined, 'hashed'),
|
|
167
|
+
values: pick('otel_values', bool, undefined, false),
|
|
168
|
+
traces: pick('otel_traces', bool, notNone('OTEL_TRACES_EXPORTER'), true),
|
|
169
|
+
logs: pick('otel_logs', bool, notNone('OTEL_LOGS_EXPORTER'), true),
|
|
170
|
+
metrics: pick('otel_metrics', bool, notNone('OTEL_METRICS_EXPORTER'), true),
|
|
171
|
+
builder: pick('otel_builder', bool, undefined, false),
|
|
172
|
+
browser: pick('otel_browser', bool, undefined, false),
|
|
173
|
+
prometheus: pick('metrics_endpoint', bool, bool(env('PGKILN_METRICS')), false),
|
|
174
|
+
prometheusTokenHash: stored.get('metrics_token_hash') ?? (env('PGKILN_METRICS_TOKEN') ? hashToken(env('PGKILN_METRICS_TOKEN')!) : null),
|
|
175
|
+
intervalMs: Math.min(Math.max(Number(env('OTEL_METRIC_EXPORT_INTERVAL') ?? 60_000) || 60_000, 5_000), 3_600_000),
|
|
176
|
+
protocolWarning: protocol && protocol !== 'http/json'
|
|
177
|
+
? `OTEL_EXPORTER_OTLP_PROTOCOL is ${protocol}: pgkiln sends OTLP over HTTP as JSON (http/json), which every OpenTelemetry Collector accepts on port 4318.`
|
|
178
|
+
: null,
|
|
179
|
+
origin,
|
|
180
|
+
};
|
|
181
|
+
return t;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Read the settings, workspaces and applications again when older than 30 s (or at once). */
|
|
185
|
+
export async function refreshTelemetry(force = false) {
|
|
186
|
+
if (!force && Date.now() - loadedAt < REFRESH_MS) return;
|
|
187
|
+
loading ??= outside(async () => {
|
|
188
|
+
await Promise.resolve();
|
|
189
|
+
try {
|
|
190
|
+
const [s, w, a] = await Promise.all([
|
|
191
|
+
owner.query<{ name: string; value: string }>(
|
|
192
|
+
`select name, value from meta.setting where name = any($1) and not (name = any($2))
|
|
193
|
+
union all select name, value from meta.instance_setting where name = any($2)`, [KEYS, [...PRIVATE]]),
|
|
194
|
+
owner.query<{ id: number; telemetry: WorkspaceMode; telemetry_endpoint: string | null; telemetry_headers_enc: string | null; telemetry_values: boolean }>(
|
|
195
|
+
`select id, telemetry, telemetry_endpoint, telemetry_headers_enc, telemetry_values from meta.workspace where telemetry <> 'instance'`),
|
|
196
|
+
owner.query<{ id: number; alias: string; ws: number | null }>('select a.id, a.alias, meta.app_workspace(a.id) as ws from meta.app a'),
|
|
197
|
+
]);
|
|
198
|
+
stored = new Map(s.rows.map((x) => [x.name, x.value]));
|
|
199
|
+
workspaces = new Map(w.rows.map((x) => {
|
|
200
|
+
let headers: Record<string, string> = {};
|
|
201
|
+
try {
|
|
202
|
+
headers = x.telemetry_headers_enc ? parsePairs(decryptSecret(x.telemetry_headers_enc)) : {};
|
|
203
|
+
} catch {
|
|
204
|
+
// an unreadable secret: sent without it (the page says so)
|
|
205
|
+
}
|
|
206
|
+
return [x.id, { id: x.id, mode: x.telemetry, endpoint: x.telemetry_endpoint, headers, values: x.telemetry_values }];
|
|
207
|
+
}));
|
|
208
|
+
apps = new Map(a.rows.map((x) => [x.id, [x.alias, x.ws]]));
|
|
209
|
+
aliases = new Map(a.rows.map((x) => [x.alias.toLowerCase(), x.id]));
|
|
210
|
+
} catch {
|
|
211
|
+
// an older database (no columns yet) or none at all: keep what we had
|
|
212
|
+
} finally {
|
|
213
|
+
loadedAt = Date.now();
|
|
214
|
+
current = build();
|
|
215
|
+
setSampleRatio(current.sample);
|
|
216
|
+
for (const fn of listeners) fn();
|
|
217
|
+
loading = null;
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
await loading;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** The instance's settings in effect (synchronous; refreshed in the background). */
|
|
224
|
+
export function instanceTelemetry(): InstanceTelemetry {
|
|
225
|
+
if (Date.now() - loadedAt >= REFRESH_MS) void refreshTelemetry();
|
|
226
|
+
return (current ??= build());
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** A workspace's own setting (instance mode when it has none). */
|
|
230
|
+
export const workspaceTelemetry = (id: number | null | undefined): WorkspaceTelemetry | null => (id == null ? null : workspaces.get(id) ?? null);
|
|
231
|
+
/** Some workspace sends to a collector of its own. */
|
|
232
|
+
export const anyOwnCollector = () => [...workspaces.values()].some((w) => w.mode === 'own' && !!w.endpoint);
|
|
233
|
+
export const appOf = (id: number | null | undefined) => (id == null ? undefined : apps.get(id));
|
|
234
|
+
export const appIdOf = (alias: string | undefined) => (alias ? aliases.get(alias.toLowerCase()) : undefined);
|
|
235
|
+
|
|
236
|
+
// ---------------------------------------------------------------- saving (the builder)
|
|
237
|
+
|
|
238
|
+
const NO_KEY = 'Headers are stored encrypted: set PGKILN_SECRET_KEY in the server\'s environment first (or leave the headers empty).';
|
|
239
|
+
function tryEncrypt(plain: string): string | null {
|
|
240
|
+
try {
|
|
241
|
+
return encryptSecret(plain);
|
|
242
|
+
} catch {
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
export interface InstanceForm {
|
|
248
|
+
enabled?: string;
|
|
249
|
+
endpoint?: string;
|
|
250
|
+
headers?: string;
|
|
251
|
+
remove_headers?: string;
|
|
252
|
+
service_name?: string;
|
|
253
|
+
sample?: string;
|
|
254
|
+
user?: string;
|
|
255
|
+
values?: string;
|
|
256
|
+
traces?: string;
|
|
257
|
+
logs?: string;
|
|
258
|
+
metrics?: string;
|
|
259
|
+
builder?: string;
|
|
260
|
+
browser?: string;
|
|
261
|
+
prometheus?: string;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Save the instance's settings. Returns a problem, or null. Empty text fields go back to the environment or default. */
|
|
265
|
+
export async function saveInstanceTelemetry(f: InstanceForm): Promise<string | null> {
|
|
266
|
+
const endpoint = (f.endpoint ?? '').trim();
|
|
267
|
+
if (endpoint && endpointProblem(endpoint)) return `Collector URL: ${endpointProblem(endpoint)}.`;
|
|
268
|
+
if (f.enabled === 'true' && !endpoint && !instanceTelemetry().endpoint) return 'Give the collector\'s URL to switch exporting on.';
|
|
269
|
+
const serviceName = (f.service_name ?? '').trim();
|
|
270
|
+
if (serviceName && !/^[\w .:/-]{1,100}$/.test(serviceName)) return 'Service name: letters, digits, spaces and . _ : / - (at most 100).';
|
|
271
|
+
const sample = (f.sample ?? '').trim();
|
|
272
|
+
if (sample && !(Number(sample) >= 0 && Number(sample) <= 1)) return 'Sample ratio: a number from 0 to 1.';
|
|
273
|
+
const user = f.user ?? 'hashed';
|
|
274
|
+
if (!['hashed', 'name', 'none'].includes(user)) return 'Unknown choice for the user.';
|
|
275
|
+
const headers = (f.headers ?? '').trim();
|
|
276
|
+
if (headers && !Object.keys(parsePairs(headers)).length) return 'Headers: name=value, one per line or comma separated.';
|
|
277
|
+
const yes = (v: string | undefined) => (v === 'true' ? 'true' : 'false');
|
|
278
|
+
const writes: [Key, string | null][] = [
|
|
279
|
+
['otel_enabled', yes(f.enabled)],
|
|
280
|
+
['otel_endpoint', endpoint || null],
|
|
281
|
+
['otel_service_name', serviceName || null],
|
|
282
|
+
['otel_sample', sample ? String(Number(sample)) : null],
|
|
283
|
+
['otel_user', user],
|
|
284
|
+
['otel_values', yes(f.values)],
|
|
285
|
+
['otel_traces', yes(f.traces)],
|
|
286
|
+
['otel_logs', yes(f.logs)],
|
|
287
|
+
['otel_metrics', yes(f.metrics)],
|
|
288
|
+
['otel_builder', yes(f.builder)],
|
|
289
|
+
['otel_browser', yes(f.browser)],
|
|
290
|
+
['metrics_endpoint', yes(f.prometheus)],
|
|
291
|
+
];
|
|
292
|
+
if (headers) {
|
|
293
|
+
const enc = tryEncrypt(headers);
|
|
294
|
+
if (!enc) return NO_KEY;
|
|
295
|
+
writes.push(['otel_headers_enc', enc]);
|
|
296
|
+
}
|
|
297
|
+
else if (f.remove_headers === 'true') writes.push(['otel_headers_enc', null]);
|
|
298
|
+
await owner.tx(async (c) => {
|
|
299
|
+
for (const [k, v] of writes) {
|
|
300
|
+
if (v === null) await c.query(`delete from ${tableFor(k)} where name = $1`, [k]);
|
|
301
|
+
else await c.query(`insert into ${tableFor(k)} (name, value) values ($1, $2) on conflict (name) do update set value = excluded.value`, [k, v]);
|
|
302
|
+
}
|
|
303
|
+
});
|
|
304
|
+
await refreshTelemetry(true);
|
|
305
|
+
return null;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** A new token for /metrics (shown once; only its hash is kept), or null to remove it. */
|
|
309
|
+
export async function setMetricsToken(token: string | null) {
|
|
310
|
+
if (token) await owner.query(`insert into meta.instance_setting (name, value) values ('metrics_token_hash', $1) on conflict (name) do update set value = excluded.value`, [hashToken(token)]);
|
|
311
|
+
else await owner.query(`delete from meta.instance_setting where name = 'metrics_token_hash'`);
|
|
312
|
+
await refreshTelemetry(true);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export interface WorkspaceForm {
|
|
316
|
+
mode?: string;
|
|
317
|
+
endpoint?: string;
|
|
318
|
+
headers?: string;
|
|
319
|
+
remove_headers?: string;
|
|
320
|
+
values?: string;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** Save a workspace's setting. Returns a problem, or null. */
|
|
324
|
+
export async function saveWorkspaceTelemetry(wsId: number, f: WorkspaceForm): Promise<string | null> {
|
|
325
|
+
const mode = f.mode ?? 'instance';
|
|
326
|
+
if (!['instance', 'off', 'own'].includes(mode)) return 'Unknown choice.';
|
|
327
|
+
const endpoint = (f.endpoint ?? '').trim();
|
|
328
|
+
if (endpoint && endpointProblem(endpoint)) return `Collector URL: ${endpointProblem(endpoint)}.`;
|
|
329
|
+
if (mode === 'own' && !endpoint) return 'Give the URL of the workspace\'s own collector.';
|
|
330
|
+
const headers = (f.headers ?? '').trim();
|
|
331
|
+
if (headers && !Object.keys(parsePairs(headers)).length) return 'Headers: name=value, one per line or comma separated.';
|
|
332
|
+
const enc = headers ? tryEncrypt(headers) : null;
|
|
333
|
+
if (headers && !enc) return NO_KEY;
|
|
334
|
+
await owner.query(
|
|
335
|
+
`update meta.workspace set telemetry = $2, telemetry_endpoint = $3, telemetry_values = $4,
|
|
336
|
+
telemetry_headers_enc = case when $5::text is not null then $5 when $6 then null else telemetry_headers_enc end
|
|
337
|
+
where id = $1`,
|
|
338
|
+
[wsId, mode, endpoint || null, f.values === 'true', enc, f.remove_headers === 'true'],
|
|
339
|
+
);
|
|
340
|
+
await refreshTelemetry(true);
|
|
341
|
+
return null;
|
|
342
|
+
}
|