@vaultgradient/pipequery-cli 0.6.1 → 0.10.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 (40) hide show
  1. package/README.md +98 -13
  2. package/__tests__/clickhouse-pushdown-compile.test.ts +121 -0
  3. package/__tests__/event-log.test.ts +267 -0
  4. package/__tests__/mongo-pushdown-compile.test.ts +160 -0
  5. package/__tests__/mysql-pushdown-compile.test.ts +122 -0
  6. package/__tests__/mysql-pushdown.smoke.test.ts +77 -0
  7. package/__tests__/postgres-pushdown-compile.test.ts +240 -0
  8. package/__tests__/postgres-pushdown.smoke.test.ts +158 -0
  9. package/__tests__/pushdown-routing.test.ts +144 -0
  10. package/__tests__/rest-source.test.ts +191 -0
  11. package/__tests__/snowflake-pushdown-compile.test.ts +74 -0
  12. package/__tests__/websocket-source.test.ts +187 -0
  13. package/dist/index.js +3570 -2044
  14. package/package.json +11 -2
  15. package/src/commands/telegram.ts +10 -0
  16. package/src/config/schema.ts +90 -1
  17. package/src/index.ts +2 -1
  18. package/src/mcp/provider.ts +4 -7
  19. package/src/server/routes/control.ts +1 -3
  20. package/src/server/routes/dynamic.ts +2 -4
  21. package/src/server/sources/clickhouse.ts +153 -0
  22. package/src/server/sources/kafka.ts +4 -16
  23. package/src/server/sources/manager.ts +71 -1
  24. package/src/server/sources/mongodb.ts +165 -0
  25. package/src/server/sources/mysql.ts +46 -14
  26. package/src/server/sources/postgres.ts +52 -20
  27. package/src/server/sources/pushdown/clickhouse.ts +17 -0
  28. package/src/server/sources/pushdown/compile.ts +328 -0
  29. package/src/server/sources/pushdown/dialect.ts +88 -0
  30. package/src/server/sources/pushdown/mongodb.ts +450 -0
  31. package/src/server/sources/pushdown/mysql.ts +17 -0
  32. package/src/server/sources/pushdown/postgres.ts +19 -0
  33. package/src/server/sources/pushdown/snowflake.ts +17 -0
  34. package/src/server/sources/rest.ts +33 -5
  35. package/src/server/sources/snowflake.ts +158 -0
  36. package/src/server/sources/types.ts +27 -0
  37. package/src/server/sources/websocket.ts +73 -9
  38. package/src/telegram/bot.ts +198 -12
  39. package/src/telegram/event-log.ts +234 -0
  40. package/src/utils/expandEnv.ts +23 -0
@@ -0,0 +1,158 @@
1
+ import snowflake, { type Connection } from 'snowflake-sdk';
2
+ import type { SnowflakeSourceConfig } from '../../config/schema.js';
3
+ import { parseDuration } from '../../utils/parseDuration.js';
4
+ import { expandEnv } from '../../utils/expandEnv.js';
5
+ import { parseQuery } from '../../engine.js';
6
+ import { compileSnowflakePushdown } from './pushdown/snowflake.js';
7
+ import type { SourceAdapter, SourceStatus } from './types.js';
8
+
9
+ const DEFAULT_INTERVAL_MS = 60_000;
10
+ const DEFAULT_MAX_ROWS = 10_000;
11
+
12
+ export class SnowflakeSourceAdapter implements SourceAdapter {
13
+ private conn: Connection | null = null;
14
+ private data: unknown[] = [];
15
+ private timer: ReturnType<typeof setInterval> | null = null;
16
+ private lastFetch: Date | null = null;
17
+ private error?: string;
18
+ private listeners = new Set<(data: unknown[]) => void>();
19
+ private readonly intervalMs: number;
20
+ private readonly maxRows: number;
21
+
22
+ constructor(private config: SnowflakeSourceConfig) {
23
+ this.intervalMs = config.interval ? parseDuration(config.interval) : DEFAULT_INTERVAL_MS;
24
+ this.maxRows = config.maxRows ?? DEFAULT_MAX_ROWS;
25
+ }
26
+
27
+ async start(): Promise<void> {
28
+ this.conn = snowflake.createConnection({
29
+ account: expandEnv(this.config.account, 'snowflake source account'),
30
+ username: expandEnv(this.config.username, 'snowflake source username'),
31
+ password: expandEnv(this.config.password, 'snowflake source password'),
32
+ ...(this.config.database ? { database: expandEnv(this.config.database, 'snowflake source database') } : {}),
33
+ ...(this.config.schema ? { schema: expandEnv(this.config.schema, 'snowflake source schema') } : {}),
34
+ ...(this.config.warehouse ? { warehouse: expandEnv(this.config.warehouse, 'snowflake source warehouse') } : {}),
35
+ ...(this.config.role ? { role: expandEnv(this.config.role, 'snowflake source role') } : {}),
36
+ });
37
+
38
+ await new Promise<void>((resolve, reject) => {
39
+ this.conn!.connect((err) => (err ? reject(err) : resolve()));
40
+ });
41
+
42
+ await this.fetch();
43
+ this.timer = setInterval(() => {
44
+ void this.fetch();
45
+ }, this.intervalMs);
46
+ }
47
+
48
+ stop(): void {
49
+ if (this.timer) {
50
+ clearInterval(this.timer);
51
+ this.timer = null;
52
+ }
53
+ if (this.conn) {
54
+ this.conn.destroy(() => undefined);
55
+ this.conn = null;
56
+ }
57
+ }
58
+
59
+ getData(): unknown[] {
60
+ return this.data;
61
+ }
62
+
63
+ getStatus(): SourceStatus {
64
+ return {
65
+ healthy: !this.error,
66
+ rowCount: this.data.length,
67
+ lastFetch: this.lastFetch,
68
+ error: this.error,
69
+ };
70
+ }
71
+
72
+ onUpdate(callback: (data: unknown[]) => void): () => void {
73
+ this.listeners.add(callback);
74
+ return () => this.listeners.delete(callback);
75
+ }
76
+
77
+ async refresh(): Promise<void> {
78
+ await this.fetch();
79
+ }
80
+
81
+ /**
82
+ * Adapter-native push-down. SourceManager.runQuery dispatches here when a
83
+ * pipeline targets a Snowflake source; declines fall back to in-process.
84
+ */
85
+ async runPushdown(expression: string): Promise<
86
+ | { ok: true; rows: unknown[]; sql: string; params: unknown[] }
87
+ | { ok: false; reason: string }
88
+ > {
89
+ if (!this.conn) {
90
+ return { ok: false, reason: 'Snowflake connection not initialized — call start() first' };
91
+ }
92
+ let pipeline;
93
+ try {
94
+ pipeline = parseQuery(expression);
95
+ } catch (err) {
96
+ return { ok: false, reason: `parse error: ${err instanceof Error ? err.message : String(err)}` };
97
+ }
98
+ const compiled = compileSnowflakePushdown(pipeline, this.config.query);
99
+ if (!compiled.ok) {
100
+ return { ok: false, reason: compiled.reason };
101
+ }
102
+ try {
103
+ const rows = await this.executeSql(compiled.compiled.sql, compiled.compiled.params);
104
+ return {
105
+ ok: true,
106
+ rows,
107
+ sql: compiled.compiled.sql,
108
+ params: compiled.compiled.params,
109
+ };
110
+ } catch (err) {
111
+ return { ok: false, reason: `snowflake error: ${err instanceof Error ? err.message : String(err)}` };
112
+ }
113
+ }
114
+
115
+ private async fetch(): Promise<void> {
116
+ if (!this.conn) {
117
+ this.error = 'Snowflake connection not initialized';
118
+ return;
119
+ }
120
+ try {
121
+ const capQuery = `SELECT * FROM (${stripTrailingSemicolon(this.config.query)}) AS pq_src LIMIT ${this.maxRows + 1}`;
122
+ const rows = await this.executeSql(capQuery, []);
123
+ if (rows.length > this.maxRows) {
124
+ throw new Error(
125
+ `Query returned more than maxRows=${this.maxRows}. ` +
126
+ `Add LIMIT/WHERE to bound the result set, or raise maxRows in the source config.`,
127
+ );
128
+ }
129
+ this.data = rows;
130
+ this.lastFetch = new Date();
131
+ this.error = undefined;
132
+ this.notify();
133
+ } catch (err) {
134
+ this.error = err instanceof Error ? err.message : String(err);
135
+ }
136
+ }
137
+
138
+ private executeSql(sqlText: string, params: unknown[]): Promise<unknown[]> {
139
+ return new Promise((resolve, reject) => {
140
+ this.conn!.execute({
141
+ sqlText,
142
+ binds: params as snowflake.Binds,
143
+ complete: (err, _stmt, rows) => {
144
+ if (err) reject(err);
145
+ else resolve(rows ?? []);
146
+ },
147
+ });
148
+ });
149
+ }
150
+
151
+ private notify(): void {
152
+ for (const cb of this.listeners) cb(this.data);
153
+ }
154
+ }
155
+
156
+ function stripTrailingSemicolon(sql: string): string {
157
+ return sql.trim().replace(/;+\s*$/, '');
158
+ }
@@ -5,6 +5,26 @@ export interface SourceStatus {
5
5
  error?: string;
6
6
  }
7
7
 
8
+ /**
9
+ * Result of an adapter-native push-down attempt.
10
+ *
11
+ * `ok: true` means the adapter executed the pipe expression natively (e.g.
12
+ * Postgres compiled it to SQL and ran it on the database) and returned rows
13
+ * without materializing the source query in memory. `sql` / `params` are
14
+ * optional telemetry — useful for audit logs and debugging, not required
15
+ * for callers to act on.
16
+ *
17
+ * `ok: false` is the explicit decline channel: the adapter recognised the
18
+ * shape but declined (unsupported op, multi-source pipeline, etc.). The
19
+ * caller should fall back to in-process execution against `getContext()`.
20
+ *
21
+ * Any thrown error means *neither* path succeeded — let it propagate; the
22
+ * caller has no useful fallback.
23
+ */
24
+ export type PushdownResult =
25
+ | { ok: true; rows: unknown[]; sql?: string; params?: unknown[] }
26
+ | { ok: false; reason: string };
27
+
8
28
  export interface SourceAdapter {
9
29
  start(): Promise<void>;
10
30
  stop(): void;
@@ -12,4 +32,11 @@ export interface SourceAdapter {
12
32
  getStatus(): SourceStatus;
13
33
  onUpdate(callback: (data: unknown[]) => void): () => void;
14
34
  refresh?(): Promise<void>;
35
+ /**
36
+ * Optional: compile + execute the pipe expression natively against the
37
+ * underlying engine instead of materializing into memory. When absent
38
+ * (or when the adapter declines), SourceManager falls back to in-process
39
+ * execution. Today only PostgresSourceAdapter implements this.
40
+ */
41
+ runPushdown?(expression: string): Promise<PushdownResult>;
15
42
  }
@@ -1,6 +1,7 @@
1
1
  import WebSocket from 'ws';
2
2
  import type { WebSocketSourceConfig } from '../../config/schema.js';
3
3
  import type { SourceAdapter, SourceStatus } from './types.js';
4
+ import { parseDuration } from '../../utils/parseDuration.js';
4
5
 
5
6
  export class WebSocketSourceAdapter implements SourceAdapter {
6
7
  private data: unknown[] = [];
@@ -9,21 +10,44 @@ export class WebSocketSourceAdapter implements SourceAdapter {
9
10
  private error?: string;
10
11
  private listeners = new Set<(data: unknown[]) => void>();
11
12
  private maxBuffer: number;
13
+ private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
14
+ /** Tracks whether stop() was called so reconnect respects intent. */
15
+ private stopped = false;
12
16
 
13
17
  constructor(private config: WebSocketSourceConfig) {
14
18
  this.maxBuffer = config.maxBuffer ?? 1000;
15
19
  }
16
20
 
17
21
  async start(): Promise<void> {
22
+ this.stopped = false;
18
23
  return new Promise((resolve, reject) => {
19
- this.ws = new WebSocket(this.config.url);
24
+ const ws = new WebSocket(this.config.url);
25
+ this.ws = ws;
26
+ let settled = false;
20
27
 
21
- this.ws.on('open', () => {
28
+ ws.on('open', () => {
22
29
  this.error = undefined;
23
- resolve();
30
+ try {
31
+ this.sendSubscribePayloads(ws);
32
+ this.startHeartbeat(ws);
33
+ } catch (err) {
34
+ // A failure to send the subscribe frame leaves the socket open
35
+ // but useless; record it and reject the start() promise.
36
+ this.error = err instanceof Error ? err.message : String(err);
37
+ ws.close();
38
+ if (!settled) {
39
+ settled = true;
40
+ reject(err instanceof Error ? err : new Error(this.error));
41
+ }
42
+ return;
43
+ }
44
+ if (!settled) {
45
+ settled = true;
46
+ resolve();
47
+ }
24
48
  });
25
49
 
26
- this.ws.on('message', (raw) => {
50
+ ws.on('message', (raw) => {
27
51
  try {
28
52
  const parsed = JSON.parse(raw.toString());
29
53
  if (Array.isArray(parsed)) {
@@ -42,21 +66,30 @@ export class WebSocketSourceAdapter implements SourceAdapter {
42
66
  }
43
67
  });
44
68
 
45
- this.ws.on('error', (err) => {
69
+ ws.on('error', (err) => {
46
70
  this.error = err.message;
47
- reject(err);
71
+ if (!settled) {
72
+ settled = true;
73
+ reject(err);
74
+ }
48
75
  });
49
76
 
50
- this.ws.on('close', () => {
51
- // Auto-reconnect after 5s
77
+ ws.on('close', () => {
78
+ this.stopHeartbeat();
79
+ // Auto-reconnect after 5s unless stop() was explicitly called.
80
+ // Re-running start() also re-sends the subscribe payloads, which
81
+ // is exactly what every exchange expects after a connection drop.
82
+ if (this.stopped) return;
52
83
  setTimeout(() => {
53
- if (this.ws) this.start().catch(() => {});
84
+ if (!this.stopped) this.start().catch(() => {});
54
85
  }, 5000);
55
86
  });
56
87
  });
57
88
  }
58
89
 
59
90
  stop(): void {
91
+ this.stopped = true;
92
+ this.stopHeartbeat();
60
93
  const ws = this.ws;
61
94
  this.ws = null;
62
95
  ws?.close();
@@ -83,4 +116,35 @@ export class WebSocketSourceAdapter implements SourceAdapter {
83
116
  private notify(): void {
84
117
  for (const cb of this.listeners) cb(this.data);
85
118
  }
119
+
120
+ private sendSubscribePayloads(ws: WebSocket): void {
121
+ const subscribe = this.config.subscribe;
122
+ if (subscribe === undefined || subscribe === null) return;
123
+ const list = Array.isArray(subscribe) ? subscribe : [subscribe];
124
+ for (const payload of list) {
125
+ ws.send(JSON.stringify(payload));
126
+ }
127
+ }
128
+
129
+ private startHeartbeat(ws: WebSocket): void {
130
+ this.stopHeartbeat();
131
+ const hb = this.config.heartbeat;
132
+ if (!hb) return;
133
+ const intervalMs = parseDuration(hb.interval);
134
+ this.heartbeatTimer = setInterval(() => {
135
+ if (ws.readyState !== WebSocket.OPEN) return;
136
+ try {
137
+ ws.send(JSON.stringify(hb.payload));
138
+ } catch {
139
+ // Ignore — next 'close' / 'error' will surface anything fatal.
140
+ }
141
+ }, intervalMs);
142
+ }
143
+
144
+ private stopHeartbeat(): void {
145
+ if (this.heartbeatTimer) {
146
+ clearInterval(this.heartbeatTimer);
147
+ this.heartbeatTimer = null;
148
+ }
149
+ }
86
150
  }
@@ -10,6 +10,7 @@ import { Bot, type Context, GrammyError, HttpError } from 'grammy';
10
10
  import type { Provider } from '../mcp/provider.js';
11
11
  import { formatError, formatResult, escapeHtml } from './format.js';
12
12
  import type { NLTranslator } from './nl.js';
13
+ import type { EventLogger, UserRef } from './event-log.js';
13
14
 
14
15
  export interface BotOptions {
15
16
  /** Whitelist of Telegram user IDs (numeric) or @usernames allowed to query.
@@ -18,12 +19,16 @@ export interface BotOptions {
18
19
  /** Optional natural-language translator. If present, plain-text messages
19
20
  * (no leading slash) are routed through it. */
20
21
  nl?: NLTranslator;
22
+ /** Optional structured event logger. When present, every command,
23
+ * NL query, and unauthorized attempt is recorded. */
24
+ logger?: EventLogger;
21
25
  }
22
26
 
23
27
  export function buildBot(token: string, provider: Provider, opts: BotOptions = {}): Bot {
24
28
  const bot = new Bot(token);
25
29
  const allowList = normalizeAllowList(opts.allowUsers ?? []);
26
30
  const nl = opts.nl;
31
+ const logger = opts.logger;
27
32
 
28
33
  // Auth middleware — runs before any handler.
29
34
  bot.use(async (ctx, next) => {
@@ -36,6 +41,7 @@ export function buildBot(token: string, provider: Provider, opts: BotOptions = {
36
41
  return;
37
42
  }
38
43
  if (!isAllowed(ctx, allowList)) {
44
+ logger?.log({ kind: 'unauthorized', user: userOf(ctx) });
39
45
  await ctx.reply('🔒 You are not authorized to use this bot.');
40
46
  return;
41
47
  }
@@ -43,6 +49,7 @@ export function buildBot(token: string, provider: Provider, opts: BotOptions = {
43
49
  });
44
50
 
45
51
  bot.command('start', async (ctx) => {
52
+ const start = Date.now();
46
53
  await ctx.reply(
47
54
  [
48
55
  '👋 <b>pipequery bot</b>',
@@ -59,9 +66,11 @@ export function buildBot(token: string, provider: Provider, opts: BotOptions = {
59
66
  ].join('\n'),
60
67
  { parse_mode: 'HTML' },
61
68
  );
69
+ logger?.log({ kind: 'command', user: userOf(ctx), cmd: '/start', outcome: 'success', latencyMs: Date.now() - start });
62
70
  });
63
71
 
64
72
  bot.command('help', async (ctx) => {
73
+ const start = Date.now();
65
74
  const lines = [
66
75
  '<b>Examples</b>',
67
76
  '<code>/sources</code>',
@@ -86,37 +95,73 @@ export function buildBot(token: string, provider: Provider, opts: BotOptions = {
86
95
  '• Results &gt; 30 rows are truncated with a footer; tighten your query to see all of them.',
87
96
  );
88
97
  await ctx.reply(lines.join('\n'), { parse_mode: 'HTML' });
98
+ logger?.log({ kind: 'command', user: userOf(ctx), cmd: '/help', outcome: 'success', latencyMs: Date.now() - start });
89
99
  });
90
100
 
91
101
  bot.command('sources', async (ctx) => {
102
+ const start = Date.now();
92
103
  try {
93
104
  const sources = await provider.listSources();
94
105
  if (sources.length === 0) {
95
106
  await ctx.reply('<i>No sources configured.</i>', { parse_mode: 'HTML' });
96
- return;
107
+ } else {
108
+ const lines = sources.map((s) => {
109
+ const dot = s.status.healthy ? '🟢' : '🔴';
110
+ const rowCount = s.status.rowCount.toLocaleString();
111
+ const err = s.status.error ? ` <i>(${escapeHtml(s.status.error)})</i>` : '';
112
+ return `${dot} <b>${escapeHtml(s.name)}</b> — ${rowCount} rows${err}`;
113
+ });
114
+ await ctx.reply(lines.join('\n'), { parse_mode: 'HTML' });
97
115
  }
98
- const lines = sources.map((s) => {
99
- const dot = s.status.healthy ? '🟢' : '🔴';
100
- const rowCount = s.status.rowCount.toLocaleString();
101
- const err = s.status.error ? ` <i>(${escapeHtml(s.status.error)})</i>` : '';
102
- return `${dot} <b>${escapeHtml(s.name)}</b> — ${rowCount} rows${err}`;
116
+ logger?.log({
117
+ kind: 'command',
118
+ user: userOf(ctx),
119
+ cmd: '/sources',
120
+ outcome: 'success',
121
+ rowCount: sources.length,
122
+ latencyMs: Date.now() - start,
103
123
  });
104
- await ctx.reply(lines.join('\n'), { parse_mode: 'HTML' });
105
124
  } catch (err) {
106
125
  await ctx.reply(formatError(err), { parse_mode: 'HTML' });
126
+ logger?.log({
127
+ kind: 'command',
128
+ user: userOf(ctx),
129
+ cmd: '/sources',
130
+ outcome: 'error',
131
+ latencyMs: Date.now() - start,
132
+ error: errMessage(err),
133
+ });
107
134
  }
108
135
  });
109
136
 
110
137
  bot.command('describe', async (ctx) => {
138
+ const start = Date.now();
111
139
  const name = ctx.match.trim();
112
140
  if (!name) {
113
141
  await ctx.reply('Usage: <code>/describe &lt;source-name&gt;</code>', { parse_mode: 'HTML' });
142
+ logger?.log({
143
+ kind: 'command',
144
+ user: userOf(ctx),
145
+ cmd: '/describe',
146
+ outcome: 'error',
147
+ latencyMs: Date.now() - start,
148
+ error: 'missing source name',
149
+ });
114
150
  return;
115
151
  }
116
152
  try {
117
153
  const desc = await provider.describeSource(name, 5);
118
154
  if (!desc) {
119
155
  await ctx.reply(`Source <code>${escapeHtml(name)}</code> not found.`, { parse_mode: 'HTML' });
156
+ logger?.log({
157
+ kind: 'command',
158
+ user: userOf(ctx),
159
+ cmd: '/describe',
160
+ args: name,
161
+ outcome: 'error',
162
+ latencyMs: Date.now() - start,
163
+ error: 'source not found',
164
+ });
120
165
  return;
121
166
  }
122
167
  const fields = desc.fields.length ? desc.fields.map((f) => `<code>${escapeHtml(f)}</code>`).join(', ') : '<i>(none inferred)</i>';
@@ -125,52 +170,141 @@ export function buildBot(token: string, provider: Provider, opts: BotOptions = {
125
170
  `<b>${escapeHtml(name)}</b> — ${desc.status.rowCount.toLocaleString()} rows\n<b>Fields:</b> ${fields}\n\n<b>Sample:</b>\n${sample}`,
126
171
  { parse_mode: 'HTML' },
127
172
  );
173
+ logger?.log({
174
+ kind: 'command',
175
+ user: userOf(ctx),
176
+ cmd: '/describe',
177
+ args: name,
178
+ outcome: 'success',
179
+ rowCount: desc.sample.length,
180
+ latencyMs: Date.now() - start,
181
+ });
128
182
  } catch (err) {
129
183
  await ctx.reply(formatError(err), { parse_mode: 'HTML' });
184
+ logger?.log({
185
+ kind: 'command',
186
+ user: userOf(ctx),
187
+ cmd: '/describe',
188
+ args: name,
189
+ outcome: 'error',
190
+ latencyMs: Date.now() - start,
191
+ error: errMessage(err),
192
+ });
130
193
  }
131
194
  });
132
195
 
133
196
  bot.command('endpoints', async (ctx) => {
197
+ const start = Date.now();
134
198
  try {
135
199
  const endpoints = await provider.listEndpoints();
136
200
  if (endpoints.length === 0) {
137
201
  await ctx.reply('<i>No endpoints configured.</i>', { parse_mode: 'HTML' });
138
- return;
202
+ } else {
203
+ const lines = endpoints.map(
204
+ (e) => `<b>${escapeHtml(e.path)}</b>\n<code>${escapeHtml(e.config.query)}</code>`,
205
+ );
206
+ await ctx.reply(lines.join('\n\n'), { parse_mode: 'HTML' });
139
207
  }
140
- const lines = endpoints.map(
141
- (e) => `<b>${escapeHtml(e.path)}</b>\n<code>${escapeHtml(e.config.query)}</code>`,
142
- );
143
- await ctx.reply(lines.join('\n\n'), { parse_mode: 'HTML' });
208
+ logger?.log({
209
+ kind: 'command',
210
+ user: userOf(ctx),
211
+ cmd: '/endpoints',
212
+ outcome: 'success',
213
+ rowCount: endpoints.length,
214
+ latencyMs: Date.now() - start,
215
+ });
144
216
  } catch (err) {
145
217
  await ctx.reply(formatError(err), { parse_mode: 'HTML' });
218
+ logger?.log({
219
+ kind: 'command',
220
+ user: userOf(ctx),
221
+ cmd: '/endpoints',
222
+ outcome: 'error',
223
+ latencyMs: Date.now() - start,
224
+ error: errMessage(err),
225
+ });
146
226
  }
147
227
  });
148
228
 
149
229
  bot.command('call', async (ctx) => {
230
+ const start = Date.now();
150
231
  const path = ctx.match.trim();
151
232
  if (!path) {
152
233
  await ctx.reply('Usage: <code>/call &lt;endpoint-path&gt;</code>', { parse_mode: 'HTML' });
234
+ logger?.log({
235
+ kind: 'command',
236
+ user: userOf(ctx),
237
+ cmd: '/call',
238
+ outcome: 'error',
239
+ latencyMs: Date.now() - start,
240
+ error: 'missing path',
241
+ });
153
242
  return;
154
243
  }
155
244
  try {
156
245
  const result = await provider.callEndpoint(path);
157
246
  await ctx.reply(formatResult(result), { parse_mode: 'HTML' });
247
+ logger?.log({
248
+ kind: 'command',
249
+ user: userOf(ctx),
250
+ cmd: '/call',
251
+ args: path,
252
+ outcome: 'success',
253
+ rowCount: rowCountOf(result),
254
+ latencyMs: Date.now() - start,
255
+ });
158
256
  } catch (err) {
159
257
  await ctx.reply(formatError(err), { parse_mode: 'HTML' });
258
+ logger?.log({
259
+ kind: 'command',
260
+ user: userOf(ctx),
261
+ cmd: '/call',
262
+ args: path,
263
+ outcome: 'error',
264
+ latencyMs: Date.now() - start,
265
+ error: errMessage(err),
266
+ });
160
267
  }
161
268
  });
162
269
 
163
270
  bot.command('query', async (ctx) => {
271
+ const start = Date.now();
164
272
  const expr = ctx.match.trim();
165
273
  if (!expr) {
166
274
  await ctx.reply('Usage: <code>/query &lt;pipequery expression&gt;</code>', { parse_mode: 'HTML' });
275
+ logger?.log({
276
+ kind: 'command',
277
+ user: userOf(ctx),
278
+ cmd: '/query',
279
+ outcome: 'error',
280
+ latencyMs: Date.now() - start,
281
+ error: 'missing expression',
282
+ });
167
283
  return;
168
284
  }
169
285
  try {
170
286
  const result = await provider.runQuery(expr);
171
287
  await ctx.reply(formatResult(result), { parse_mode: 'HTML' });
288
+ logger?.log({
289
+ kind: 'command',
290
+ user: userOf(ctx),
291
+ cmd: '/query',
292
+ args: expr,
293
+ outcome: 'success',
294
+ rowCount: rowCountOf(result),
295
+ latencyMs: Date.now() - start,
296
+ });
172
297
  } catch (err) {
173
298
  await ctx.reply(formatError(err), { parse_mode: 'HTML' });
299
+ logger?.log({
300
+ kind: 'command',
301
+ user: userOf(ctx),
302
+ cmd: '/query',
303
+ args: expr,
304
+ outcome: 'error',
305
+ latencyMs: Date.now() - start,
306
+ error: errMessage(err),
307
+ });
174
308
  }
175
309
  });
176
310
 
@@ -180,12 +314,21 @@ export function buildBot(token: string, provider: Provider, opts: BotOptions = {
180
314
  bot.on('message:text', async (ctx) => {
181
315
  const text = ctx.message.text.trim();
182
316
  if (!text || text.startsWith('/')) return;
317
+ const start = Date.now();
183
318
  if (!nl) {
184
319
  await ctx.reply(
185
320
  'I don\'t recognise that. Use <code>/help</code> to see the available commands. ' +
186
321
  'Natural-language queries are disabled — start the bot with <code>--anthropic-key</code> to enable them.',
187
322
  { parse_mode: 'HTML' },
188
323
  );
324
+ logger?.log({
325
+ kind: 'nl',
326
+ user: userOf(ctx),
327
+ text,
328
+ outcome: 'error',
329
+ latencyMs: Date.now() - start,
330
+ error: 'NL translation disabled',
331
+ });
189
332
  return;
190
333
  }
191
334
  try {
@@ -194,13 +337,39 @@ export function buildBot(token: string, provider: Provider, opts: BotOptions = {
194
337
  if (!expression) {
195
338
  const reason = explanation || 'I couldn\'t translate that into a pipequery expression.';
196
339
  await ctx.reply(`🤔 ${escapeHtml(reason)}`, { parse_mode: 'HTML' });
340
+ logger?.log({
341
+ kind: 'nl',
342
+ user: userOf(ctx),
343
+ text,
344
+ explanation,
345
+ outcome: 'not_answerable',
346
+ latencyMs: Date.now() - start,
347
+ });
197
348
  return;
198
349
  }
199
350
  const result = await provider.runQuery(expression);
200
351
  const header = `<i>${escapeHtml(explanation || 'Translated query')}</i>\n<code>${escapeHtml(expression)}</code>\n\n`;
201
352
  await ctx.reply(header + formatResult(result), { parse_mode: 'HTML' });
353
+ logger?.log({
354
+ kind: 'nl',
355
+ user: userOf(ctx),
356
+ text,
357
+ expression,
358
+ explanation,
359
+ outcome: 'success',
360
+ rowCount: rowCountOf(result),
361
+ latencyMs: Date.now() - start,
362
+ });
202
363
  } catch (err) {
203
364
  await ctx.reply(formatError(err), { parse_mode: 'HTML' });
365
+ logger?.log({
366
+ kind: 'nl',
367
+ user: userOf(ctx),
368
+ text,
369
+ outcome: 'error',
370
+ latencyMs: Date.now() - start,
371
+ error: errMessage(err),
372
+ });
204
373
  }
205
374
  });
206
375
 
@@ -245,3 +414,20 @@ function trackUnauthSeen(id: number, username?: string): void {
245
414
  `Restart with --allow-user @yourname to lock it down.\n`,
246
415
  );
247
416
  }
417
+
418
+ // ─── Logging helpers ────────────────────────────────────────────────────────
419
+
420
+ function userOf(ctx: Context): UserRef {
421
+ return {
422
+ id: ctx.from?.id,
423
+ username: ctx.from?.username,
424
+ };
425
+ }
426
+
427
+ function rowCountOf(value: unknown): number | undefined {
428
+ return Array.isArray(value) ? value.length : undefined;
429
+ }
430
+
431
+ function errMessage(err: unknown): string {
432
+ return err instanceof Error ? err.message : String(err);
433
+ }