wingbot 3.76.17 → 3.77.0-alpha.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wingbot",
3
- "version": "3.76.17",
3
+ "version": "3.77.0-alpha.0",
4
4
  "description": "Enterprise Messaging Bot Conversation Engine",
5
5
  "main": "index.js",
6
6
  "type": "commonjs",
@@ -75,7 +75,7 @@
75
75
  },
76
76
  "optionalDependencies": {
77
77
  "axios": "^1.6.4",
78
- "handlebars": "^4.0.0"
78
+ "handlebars": "4.7.10"
79
79
  },
80
80
  "allowScripts": {
81
81
  "fsevents@2.3.2": true,
@@ -157,12 +157,7 @@ const DUMMY_ROUTE = { id: 0, path: null, resolvers: [] };
157
157
  * @returns {string}
158
158
  */
159
159
 
160
- /**
161
- * @typedef {object} ILogger
162
- * @prop {Function} log
163
- * @prop {Function} warn
164
- * @prop {Function} error
165
- */
160
+ /** @typedef {import('./log/types').LevelLogger} ILogger */
166
161
 
167
162
  /**
168
163
  * @template {BaseConfiguration} [C=object]
@@ -206,11 +201,7 @@ const DUMMY_ROUTE = { id: 0, path: null, resolvers: [] };
206
201
  * @prop {string|number} [resolverId] - only for text messages with random characters
207
202
  */
208
203
 
209
- /**
210
- * @typedef {object} Logger
211
- * @prop {Function} log
212
- * @prop {Function} error
213
- */
204
+ /** @typedef {import('./log/types').LevelLogger} Logger */
214
205
 
215
206
  /**
216
207
  * @typedef {object} RunningReq
@@ -400,6 +391,30 @@ class BuildRouter extends Router {
400
391
  return this._botLoaded;
401
392
  }
402
393
 
394
+ /**
395
+ * What an expired request was about, without the whole request and
396
+ * responder (they hold the state and the transcript).
397
+ *
398
+ * @param {any[]} args - the reduce() arguments: (req, res, ...)
399
+ * @param {number} timeoutMs
400
+ * @returns {object}
401
+ */
402
+ static _expireInfo (args, timeoutMs) {
403
+ const [req] = Array.isArray(args) ? args : [];
404
+ let action = null;
405
+ try {
406
+ action = req && typeof req.action === 'function' ? req.action() : null;
407
+ } catch (e) {
408
+ // the action is only a hint
409
+ }
410
+ return {
411
+ senderId: (req && req.senderId) || null,
412
+ pageId: (req && req.pageId) || null,
413
+ action,
414
+ timeoutMs
415
+ };
416
+ }
417
+
403
418
  async reduce (...args) {
404
419
  await this.preload();
405
420
 
@@ -411,7 +426,7 @@ class BuildRouter extends Router {
411
426
  const timeoutPromise = new Promise((resolve) => {
412
427
  timeout = setTimeout(() => {
413
428
  timeout = null;
414
- this._logger.error('BuildRouter EXPIRE', { args });
429
+ this._logger.error('BuildRouter EXPIRE', BuildRouter._expireInfo(args, this.runningReqTimeout));
415
430
  this._runningReqs = this._runningReqs
416
431
  .filter((rr) => rr !== runningRequest);
417
432
  resolve();
@@ -527,7 +542,10 @@ class BuildRouter extends Router {
527
542
  this._runningReqs
528
543
  .filter((req) => {
529
544
  if (req.started < threshold) {
530
- this._logger.error('BuildRouter EXPIRE filter', req);
545
+ this._logger.error('BuildRouter EXPIRE filter', {
546
+ ...BuildRouter._expireInfo(req.args, this.runningReqTimeout),
547
+ startedAt: new Date(req.started).toISOString()
548
+ });
531
549
  return false;
532
550
  }
533
551
  return true;
@@ -35,11 +35,7 @@ const TYPE_INFO = 'Info';
35
35
  * @prop {string} [ro] - referrer || origin
36
36
  */
37
37
 
38
- /**
39
- * @typedef {object} Logger
40
- * @prop {Function} log
41
- * @prop {Function} error
42
- */
38
+ /** @typedef {import('./log/types').LevelLogger} Logger */
43
39
 
44
40
  /**
45
41
  * @typedef {object} AuditLogEntry
package/src/ChatGpt.js CHANGED
@@ -4,7 +4,6 @@
4
4
  'use strict';
5
5
 
6
6
  const nodeFetch = require('node-fetch').default;
7
- const util = require('util');
8
7
  const { PHONE_REGEX, EMAIL_REGEX } = require('./systemEntities/regexps');
9
8
  const LLM = require('./LLM');
10
9
 
@@ -101,11 +100,7 @@ const LLM = require('./LLM');
101
100
  * @prop {ChatGPTUsage} usage
102
101
  */
103
102
 
104
- /**
105
- * @typedef {object} Logger
106
- * @prop {Function} log
107
- * @prop {Function} error
108
- */
103
+ /** @typedef {import('./log/types').LevelLogger} Logger */
109
104
 
110
105
  /**
111
106
  * @typedef {object} GptToolCall
@@ -178,6 +173,9 @@ const LLM = require('./LLM');
178
173
  /**
179
174
  * @class ChatGpt
180
175
  */
176
+ // how much of the input and the output the info row of an LLM call keeps
177
+ const SUMMARY_TEXT_LENGTH = 2000;
178
+
181
179
  class ChatGpt {
182
180
 
183
181
  /**
@@ -242,15 +240,41 @@ class ChatGpt {
242
240
  return this._options.model;
243
241
  }
244
242
 
245
- _log (msg, ...args) {
246
- if (this._logger === console) {
247
-
248
- this._logger.log(msg, ...args.map((arg) => util.inspect(arg, {
249
- showHidden: false, depth: null, colors: true
250
- })));
251
- } else {
252
- this._logger.log(msg, ...args);
253
- }
243
+ /**
244
+ * The info row of one LLM call: the new input (the last message, not the
245
+ * whole transcript), the output and the usage. The whole request and
246
+ * response are in the debug rows.
247
+ *
248
+ * @param {object} body - the request body
249
+ * @param {ChatGPTChoice} choice
250
+ * @param {LLMUsage} usage
251
+ * @param {number} durationMs
252
+ * @returns {object}
253
+ */
254
+ static _summary (body, choice, usage, durationMs) {
255
+ const { messages = [], tools = [] } = body;
256
+ const last = messages[messages.length - 1] || {};
257
+ const { message = {}, finish_reason: finishReason } = choice || {};
258
+ const cut = (text) => (typeof text === 'string' && text.length > SUMMARY_TEXT_LENGTH
259
+ ? `${text.slice(0, SUMMARY_TEXT_LENGTH)}… (${text.length - SUMMARY_TEXT_LENGTH} more chars)`
260
+ : text);
261
+ return {
262
+ ...usage,
263
+ durationMs,
264
+ finishReason,
265
+ messageCount: messages.length,
266
+ toolCount: tools.length,
267
+ input: { role: last.role, content: cut(last.content) },
268
+ output: cut(message.content),
269
+ ...(message.tool_calls && message.tool_calls.length
270
+ ? {
271
+ toolCalls: message.tool_calls.map((tc) => ({
272
+ name: tc.function && tc.function.name,
273
+ args: cut(tc.function && tc.function.arguments)
274
+ }))
275
+ }
276
+ : {})
277
+ };
254
278
  }
255
279
 
256
280
  /**
@@ -457,7 +481,8 @@ class ChatGpt {
457
481
 
458
482
  const apiUrl = `${this._openAiEndpoint}/chat/completions${this._apiKey ? `?api-version=${encodeURIComponent(this._apiVersion)}` : ''}`;
459
483
 
460
- this._log('#GPT request', body);
484
+ // the whole prompt: verbose only (or replayed with an error)
485
+ this._logger.debug('#GPT request', body);
461
486
 
462
487
  const startTime = Date.now();
463
488
  const response = await this._fetch(apiUrl, {
@@ -508,11 +533,14 @@ class ChatGpt {
508
533
 
509
534
  const durationMs = Date.now() - startTime;
510
535
 
511
- this._log('#GPT response', { choice, responseData, durationMs });
536
+ this._logger.debug('#GPT response body', { responseData, durationMs });
537
+ this._logger.info('#GPT response', ChatGpt._summary(body, choice, llmUsage, durationMs));
512
538
 
513
539
  return { choice, usage: llmUsage };
514
540
  } catch (e) {
515
- this._logger.log('#GPT failed', {
541
+ // the caller logs the error once (LLMSession: the fallback message);
542
+ // the detail goes with it (replayed with the error)
543
+ this._logger.debug('#GPT failed', {
516
544
  message: e.message, body, responseText, responseData, status, statusText
517
545
  });
518
546
  throw e;
@@ -95,11 +95,7 @@ const DEFAULT_TEXT_THRESHOLD = 0.8;
95
95
  * @prop {number} stepCount
96
96
  */
97
97
 
98
- /**
99
- * @typedef {object} Logger
100
- * @prop {Function} log
101
- * @prop {Function} error
102
- */
98
+ /** @typedef {import('./log/types').LevelLogger} Logger */
103
99
 
104
100
  /**
105
101
  * Automated Conversation tests runner
package/src/LLM.js CHANGED
@@ -60,6 +60,7 @@ const LLMSession = require('./LLMSession');
60
60
  * @typedef {object} PrepocessedRuleData
61
61
  * @prop {Function} condition
62
62
  * @prop {PreprocessorOutput} rule
63
+ * @prop {string[]} [aiTags] - original rule definition (from the designer), for logs
63
64
  */
64
65
 
65
66
  /**
@@ -75,12 +76,24 @@ const LLMSession = require('./LLMSession');
75
76
  * @typedef {RuleScore & PreprocessedRule} RuleWithScore
76
77
  */
77
78
 
79
+ /**
80
+ * @typedef {object} EvaluatedRule
81
+ * @prop {string|number} [id]
82
+ * @prop {EvaluationRuleAction} [action]
83
+ * @prop {string[]} [aiTags]
84
+ * @prop {boolean} conditionPassed - false, when the rule was skipped by its condition
85
+ * @prop {number} score - 0, when the rule did not match at all
86
+ * @prop {boolean} matched - score reached the threshold
87
+ */
88
+
78
89
  /**
79
90
  * @typedef {object} EvaluationResult
80
91
  * @prop {string} action
81
92
  * @prop {boolean} discard
82
93
  * @prop {RuleWithScore[]} results
83
94
  * @prop {object} setState
95
+ * @prop {number} [threshold] - minimal score of a matching rule
96
+ * @prop {EvaluatedRule[]} [evaluated] - every rule and the decision made about it
84
97
  */
85
98
 
86
99
  /**
@@ -190,11 +203,7 @@ const LLMSession = require('./LLMSession');
190
203
  * @prop {LogPrompt} logPrompt
191
204
  */
192
205
 
193
- /**
194
- * @typedef {object} Logger
195
- * @prop {Function} log
196
- * @prop {Function} error
197
- */
206
+ /** @typedef {import('./log/types').LevelLogger} Logger */
198
207
 
199
208
  /**
200
209
  * @typedef {object} VectorSearchDocument
@@ -527,6 +536,8 @@ class LLM {
527
536
 
528
537
  return {
529
538
  ...rest,
539
+ // kept, so logs can show the rule as it was defined in the designer
540
+ aiTags,
530
541
  condition,
531
542
  rule
532
543
  };
@@ -559,14 +570,27 @@ class LLM {
559
570
  const setState = {};
560
571
 
561
572
  const sAct = Object.values(LLM.EVALUATION_ACTIONS);
573
+ const { threshold } = this._ai;
574
+
575
+ /** @type {EvaluatedRule[]} */
576
+ const evaluated = [];
562
577
 
563
578
  const results = rules
564
- .filter((rule) => rule.condition(req, res))
579
+ .filter((rule) => {
580
+ const conditionPassed = !!rule.condition(req, res);
581
+ if (!conditionPassed) {
582
+ evaluated.push(LLM._describeRule(rule, false, 0, threshold));
583
+ }
584
+ return conditionPassed;
585
+ })
565
586
  .map((rule) => {
566
587
  const matched = this._ai.matcher
567
588
  .matchText(text, rule.rule, nlpResult, state);
568
589
 
569
- if (!matched || matched.score < this._ai.threshold) {
590
+ const score = matched ? matched.score : 0;
591
+ evaluated.push(LLM._describeRule(rule, true, score, threshold));
592
+
593
+ if (!matched || matched.score < threshold) {
570
594
  return null;
571
595
  }
572
596
 
@@ -594,10 +618,51 @@ class LLM {
594
618
  setState,
595
619
  results,
596
620
  discard,
597
- action: discard ? null : topRankedAction
621
+ action: discard ? null : topRankedAction,
622
+ threshold,
623
+ evaluated
624
+ };
625
+ }
626
+
627
+ /**
628
+ * Rule as a plain object for logs (RegExp objects serialize as {})
629
+ *
630
+ * @param {PreprocessedRule} rule
631
+ * @param {boolean} conditionPassed
632
+ * @param {number} score
633
+ * @param {number} threshold
634
+ * @returns {EvaluatedRule}
635
+ */
636
+ static _describeRule (rule, conditionPassed, score, threshold) {
637
+ // @ts-ignore
638
+ const { id, action = null, aiTags } = rule;
639
+ return {
640
+ id,
641
+ action,
642
+ aiTags: aiTags || LLM._ruleTags(rule.rule),
643
+ conditionPassed,
644
+ score,
645
+ matched: conditionPassed && score >= threshold
598
646
  };
599
647
  }
600
648
 
649
+ /**
650
+ * Fallback for rules added without their definition
651
+ *
652
+ * @param {PreprocessorOutput} rule
653
+ * @returns {string[]}
654
+ */
655
+ static _ruleTags (rule) {
656
+ if (!rule) return [];
657
+ /** @type {any} */
658
+ const r = rule;
659
+ return [
660
+ ...(r.regexps || []).map((x) => `regexp:${x.r instanceof RegExp ? x.r.source : x.r}`),
661
+ ...(r.intents || []).map((i) => `intent:${i}`),
662
+ ...(r.entities || []).map((e) => `@${e.entity}${e.optional ? '?' : ''}`)
663
+ ];
664
+ }
665
+
601
666
  }
602
667
 
603
668
  module.exports = LLM;
@@ -39,11 +39,7 @@ const BASE64_REGEX = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{
39
39
  * @prop {RouterResolver} resolve
40
40
  */
41
41
 
42
- /**
43
- * @typedef {object} Logger
44
- * @prop {Function} log
45
- * @prop {Function} error
46
- */
42
+ /** @typedef {import('./log/types').LevelLogger} Logger */
47
43
 
48
44
  /**
49
45
  * @typedef {object} DispatcherOptions
package/src/LLMSession.js CHANGED
@@ -429,7 +429,7 @@ class LLMSession {
429
429
  } else if (errors.length) {
430
430
  // @ts-ignore
431
431
  const errs = errors.map((e) => e.error);
432
- this._llm.log.log('LLMSession failures', errs);
432
+ this._llm.log.debug('LLMSession failures', { errors: errs.map((e) => e.message) });
433
433
  throw new Error(errs.map((e) => e.message).join(', '));
434
434
  }
435
435
  }
package/src/Processor.js CHANGED
@@ -14,6 +14,7 @@ const { prepareState, mergeState, isUserInteraction } = require('./utils/stateVa
14
14
  const LLM = require('./LLM');
15
15
  const LLMMockProvider = require('./LLMMockProvider');
16
16
  const LLMDispatcher = require('./LLMDispatcher');
17
+ const logSpan = require('./utils/logSpan');
17
18
 
18
19
  /** @typedef {import('./wingbot/CustomEntityDetectionModel').Intent} Intent */
19
20
  /** @typedef {import('./ReducerWrapper')} ReducerWrapper */
@@ -85,12 +86,7 @@ const LLMDispatcher = require('./LLMDispatcher');
85
86
  * @type {InteractionEvent}
86
87
  */
87
88
 
88
- /**
89
- * @typedef {object} ILogger
90
- * @prop {Function} log
91
- * @prop {Function} warn
92
- * @prop {Function} error
93
- */
89
+ /** @typedef {import('./log/types').LevelLogger} ILogger */
94
90
 
95
91
  /**
96
92
  *
@@ -314,7 +310,7 @@ class Processor extends EventEmitter {
314
310
 
315
311
  async _reportError (pageId, err, event, senderId = null) {
316
312
  if (err.code === 204) {
317
- this.options.log.log(`nothing sent: ${err.message}`, event);
313
+ this.options.log.debug('nothing sent', { message: err.message, senderId, pageId });
318
314
  return;
319
315
  }
320
316
  if (err.code !== 403) {
@@ -335,7 +331,9 @@ class Processor extends EventEmitter {
335
331
 
336
332
  await this.stateStorage.saveState(state);
337
333
  } catch (e) {
338
- this.options.log.error(`failed to log error "${err.message}" because: ${e.message}`, e, err);
334
+ this.options.log.error('Failed to store the send error to the state', e, {
335
+ senderId, pageId, sendError: err.message
336
+ });
339
337
  }
340
338
  }
341
339
 
@@ -364,6 +362,20 @@ class Processor extends EventEmitter {
364
362
  ),
365
363
  responderData = {}
366
364
  ) {
365
+ const senderId = message && ((message.sender && message.sender.id)
366
+ || (message.optin && message.optin.user_ref));
367
+
368
+ // one span per processed event: its logs, LLM and storage calls share
369
+ // the operation, and the span carries the conversation (senderId)
370
+ return logSpan(
371
+ this.options.log,
372
+ 'wingbot processMessage',
373
+ () => this._handleMessage(message, pageId, messageSender, responderData),
374
+ { senderId, pageId }
375
+ );
376
+ }
377
+
378
+ async _handleMessage (message, pageId, messageSender, responderData) {
367
379
  const preloadPromise = this._preload();
368
380
 
369
381
  try {
@@ -1052,7 +1064,9 @@ class Processor extends EventEmitter {
1052
1064
  this.stateStorage
1053
1065
  .getState(senderId, pageId)
1054
1066
  .then((state) => {
1055
- this.options.log.warn(`Locked state: ${senderId}, lock: ${lock}, at ${Date.now()}`, state);
1067
+ this.options.log.warn('Locked state', {
1068
+ senderId, pageId, lock, state
1069
+ });
1056
1070
  })
1057
1071
  .catch(() => {});
1058
1072
 
@@ -1085,7 +1099,9 @@ class Processor extends EventEmitter {
1085
1099
  const duration = Date.now() - now;
1086
1100
  if (duration >= (this.options.waitForLockedState / 2)) {
1087
1101
  try {
1088
- this.options.log.warn(`Loading state (${senderId}) for timeout ${timeout} took too long (${duration}ms).`);
1102
+ this.options.log.warn('Loading state took too long', {
1103
+ senderId, pageId, timeout, durationMs: duration
1104
+ });
1089
1105
  } catch (e) {
1090
1106
  // noop
1091
1107
  }
package/src/Request.js CHANGED
@@ -173,6 +173,11 @@ class Request {
173
173
  */
174
174
  this.params = {};
175
175
 
176
+ /**
177
+ * @type {S} current state of the conversation
178
+ */
179
+ this.state = state;
180
+
176
181
  this.message = event.message || null;
177
182
 
178
183
  let usePostBack;
@@ -222,11 +227,6 @@ class Request {
222
227
  */
223
228
  this.pageId = pageId;
224
229
 
225
- /**
226
- * @type {S} current state of the conversation
227
- */
228
- this.state = state;
229
-
230
230
  /**
231
231
  * @type {string[]} features supported messaging features
232
232
  */
@@ -1353,10 +1353,12 @@ It looks like the bot isn't connected to class BotApp or the Processor is used w
1353
1353
  features = [FEATURE_TEXT]
1354
1354
  ) {
1355
1355
  const postback = {
1356
- payload: {
1357
- action,
1358
- data
1359
- }
1356
+ payload: data === null
1357
+ ? action
1358
+ : {
1359
+ action,
1360
+ data
1361
+ }
1360
1362
  };
1361
1363
  if (refAction) {
1362
1364
  Object.assign(postback, {
@@ -126,7 +126,7 @@ class GA4 {
126
126
  };
127
127
 
128
128
  if (this._options.debug) {
129
- this._logger.log('GA4: starting session', event);
129
+ this._logger.debug('GA4: starting session', event);
130
130
  }
131
131
 
132
132
  const query = Object.entries(event)
@@ -255,15 +255,15 @@ class GA4 {
255
255
 
256
256
  message = (validationMessages[0] || { description: message }).description;
257
257
  } catch (e) {
258
- this._logger.log('GA4 debug failed', e);
258
+ this._logger.warn('GA4 debug failed', e);
259
259
  message += ` +(${e.message})`;
260
260
  }
261
261
 
262
262
  if (validationMessages.length || message) {
263
- this._logger.log('GA4: validationMessages', validationMessages);
263
+ this._logger.debug('GA4: validationMessages', { validationMessages });
264
264
  this._logger.error(`GA4: fail: ${message} [${res ? res.status : 0}]`, params.body);
265
265
  } else {
266
- this._logger.log(`GA4: debug [${res ? res.status : 0}]`, params.body);
266
+ this._logger.debug('GA4: debug', { status: res ? res.status : 0, body: params.body });
267
267
  }
268
268
  }
269
269
 
@@ -28,11 +28,7 @@ const DEFAULT_CACHE = 86400000; // 24 hours
28
28
  /** @typedef {import('../CallbackAuditLog')} AuditLog */
29
29
  /** @typedef {import('graphql')} GqlLib */
30
30
 
31
- /**
32
- * @typedef {object} Logger
33
- * @prop {Function} log
34
- * @prop {Function} error
35
- */
31
+ /** @typedef {import('../log/types').LevelLogger} Logger */
36
32
 
37
33
  class GraphApi {
38
34
 
@@ -10,11 +10,7 @@ const {
10
10
 
11
11
  /** @typedef {import('graphql').ValidationRule} ValidationRule */
12
12
 
13
- /**
14
- * @typedef {object} Logger
15
- * @prop {Function} log
16
- * @prop {Function} error
17
- */
13
+ /** @typedef {import('../log/types').LevelLogger} Logger */
18
14
 
19
15
  /**
20
16
  *
@@ -58,7 +54,7 @@ function gqlRules (variables, isProduction, hideVerboseErrors, log = console) {
58
54
  // This can be used for logging or to implement rate limiting
59
55
  onComplete: (complexity) => {
60
56
  if (!isProduction) {
61
- log.log('Determined query complexity: ', complexity);
57
+ log.debug('Determined query complexity', { complexity });
62
58
  }
63
59
  },
64
60
 
@@ -70,7 +66,7 @@ function gqlRules (variables, isProduction, hideVerboseErrors, log = console) {
70
66
  log.error(msg);
71
67
  return new GraphQLError('');
72
68
  }
73
- log.log(msg);
69
+ log.warn('GQL Query too complex', { actual, max });
74
70
  return new GraphQLError(`Query is too complex: ${actual}. Maximum allowed complexity: ${max}`);
75
71
  },
76
72
 
@@ -0,0 +1,98 @@
1
+ // GENERATED COPY of wingbot-super/packages/log/src/types.js - do not edit here.
2
+ // Edit packages/log, then run: node packages/log/scripts/copy.mjs packages/wingbot-llm
3
+ /**
4
+ * @author wingbot.ai
5
+ *
6
+ * The types of a log call, shared by the logger and the libraries that get
7
+ * it (wingbot, the orchestrator). Types only, no code: `scripts/copy.mjs`
8
+ * copies this file into the apps (next to the logger) and into the
9
+ * libraries (targets.json, `files: ["types"]`), so they import
10
+ * `LevelLogger` instead of declaring their own `Logger`.
11
+ */
12
+ 'use strict';
13
+
14
+ /**
15
+ * A log call: `(message, [Error], [data...])`, the arguments in any order.
16
+ * The first Error is the exception, plain objects are merged into the data,
17
+ * other values go to `data.args`.
18
+ *
19
+ * Rules for every log call:
20
+ * 1. **The message is a constant**, the values go to the data:
21
+ * `log.warn('SAP getList failed', { status, taskType })`, not
22
+ * `` `SAP getList failed ${status}` ``. Never put `e.stack` or
23
+ * `JSON.stringify(...)` into the message: pass the Error, pass the object.
24
+ * (Only the first line, max 200 chars, becomes the message / App Insights
25
+ * event name; the rest goes to `fullMessage`.)
26
+ * 2. **Log once, where the error is handled.** An adapter (SapApi,
27
+ * MicrosoftApi) does not log a failure it throws: it calls
28
+ * `log.annotate({...})` and throws an Error with the context
29
+ * (`new Error(msg, { cause })`). The code that decides what happens (retry
30
+ * later, show an error, return 500) logs it once. Logging the same Error
31
+ * object again is marked as a duplicate and not reported again.
32
+ * 3. **Never log** a raw request / event, headers, cookies, tokens or config:
33
+ * use `requestLogInfo(event)`. Secret-looking keys, the `queryKeys` and the
34
+ * configured secret values are redacted anyway, but do not rely on it.
35
+ * Personal data (emails, transcripts) is allowed. How much data:
36
+ * - `debug`: the detail, whole objects too (the task, the slot, the
37
+ * transcript). It is written with `verbose`, or with the error of the
38
+ * trace (`replayOnError`), and cut by `redact` (`maxString`, `maxDepth`,
39
+ * `maxArray`).
40
+ * - `info`: the ids and the small values that explain the event (statuses,
41
+ * reason, emails), not whole objects: it is always written.
42
+ * - `warn` / `error`: what is needed to fix it, whole objects too.
43
+ * 4. **Wrap non-HTTP work** (worker runs, sync runs, queues) in `log.span()`,
44
+ * and call `await log.sendAndClose()` at the end of a lambda or worker run
45
+ * (it flushes only on Lambda; `{ force: true }` before the process exits).
46
+ * 5. **One row per business event.** A storage read or write that must be
47
+ * audited is `log.audit()`, other detail is `debug`. The decision about a
48
+ * task or slot (created, rejected and why, status changed) is one `info`
49
+ * row with the ids.
50
+ *
51
+ * @callback LogFn
52
+ * @param {any} message - a CONSTANT text: values go to the data, never `e.stack`
53
+ * or `JSON.stringify(...)` in it (only its first line, max 200 chars, is kept as
54
+ * the message). An Error is accepted too.
55
+ * @param {...any} args - the Error (pass it, do not stringify it) and data objects
56
+ * (`slotId`, `taskId`, ...; whole objects in `debug`, `warn` and `error`).
57
+ * Never a raw request, event, headers, tokens or config: use
58
+ * `requestLogInfo(event)`.
59
+ * @returns {void}
60
+ */
61
+
62
+ /** @typedef {<T>(name: string, fn: () => T|Promise<T>, attrs?: object) => Promise<T>} SpanFn */
63
+
64
+ /**
65
+ * What a library needs from a logger: the five levels. `console`, the shared
66
+ * logger (lib/log of an app) and the orchestrator's moduleLoggerFactory
67
+ * logger have them. `span` is optional: the shared logger has it, a library
68
+ * checks it before the call (`console` has none).
69
+ *
70
+ * Use it in a library as the typedef
71
+ * `{import('./log/types').LevelLogger} Logger`.
72
+ *
73
+ * @typedef {object} LevelLogger
74
+ * @prop {LogFn} error **Someone must act**: a bug, lost or wrong data, an
75
+ * upstream call that failed after the retries, a business result that was not
76
+ * saved. Goes to App Insights (AppEvents, AppExceptions) and to Sentry, and
77
+ * triggers the error alerts, so do NOT use it for client mistakes or for
78
+ * handled states. Log it once, where the error is handled: an adapter that
79
+ * throws calls `log.annotate()` instead.
80
+ * Examples: a result written to the wrong row, `Bot preloader is not set`,
81
+ * SAP setResult failed after the retries.
82
+ * @prop {LogFn} warn **Degraded, but handled**: a 4xx from a client (bad
83
+ * body, invalid token), a transient upstream error before a retry, a discarded
84
+ * LLM reply, a duplicate-key retry, a fallback that was used.
85
+ * Examples: `HTTP 400 POST /api`, SAP timeout (next run retries), #CANARY.
86
+ * @prop {LogFn} info **Business events and expected states**: a slot opened,
87
+ * a task synced, an email sent, a test whitelist block, a missing translation.
88
+ * Dropped unless `verbose` (verboseLog in config). AppTraces.
89
+ * @prop {LogFn} log like `info`, but always written (AppEvents). Kept for the
90
+ * old call sites; prefer `info` (an event worth seeing in production) or
91
+ * `debug` (detail).
92
+ * @prop {LogFn} debug **Detail**: payloads, request / response bodies, step by
93
+ * step flow. Dropped unless `verbose`.
94
+ * @prop {SpanFn} [span] Runs `fn` in a child span (the shared logger only):
95
+ * see `LogFunctions.span` of the shared logger.
96
+ */
97
+
98
+ module.exports = {};
@@ -110,11 +110,7 @@ const DEFAULT_CAMPAIGN_DATA = {
110
110
  * @prop {object} meta
111
111
  */
112
112
 
113
- /**
114
- * @typedef {object} Logger
115
- * @prop {Function} log
116
- * @prop {Function} error
117
- */
113
+ /** @typedef {import('../log/types').LevelLogger} Logger */
118
114
 
119
115
  /** @typedef {import('./api/notificationsApiFactory').NotificationsApiOptions} ApiOptions */
120
116
 
@@ -457,6 +457,7 @@ function message (params, context = {}) {
457
457
  let session;
458
458
  let evaluation;
459
459
  let discard;
460
+ let llmError;
460
461
 
461
462
  try {
462
463
  session = res.llmSessionWithHistory(params.llmContextType);
@@ -467,13 +468,25 @@ function message (params, context = {}) {
467
468
  ({ discard } = evaluation);
468
469
  } catch (e) {
469
470
  (context.log || console).error(`LLM ERR: ${e.message} [${req.senderId}]`, e);
471
+ llmError = e;
470
472
  discard = true;
471
473
  }
472
474
 
473
475
  if (discard) {
476
+ // short summary: where it happened, what the LLM generated,
477
+ // what is sent instead and why (every rule with its score)
474
478
  canaryLog(context.log, context.canaryLogs, 'LLM discarded', {
475
- evaluation,
476
- session
479
+ reason: llmError ? 'llm-error' : 'rule-matched',
480
+ ...(llmError && { error: llmError.message }),
481
+ source: {
482
+ resolverId,
483
+ action: req.action(),
484
+ contextType: params.llmContextType || null
485
+ },
486
+ generated: (llmError ? null : session?.lastResponseSync()) || null,
487
+ fallbackText: text,
488
+ threshold: evaluation?.threshold ?? null,
489
+ rules: evaluation?.evaluated ?? []
477
490
  });
478
491
  } else {
479
492
 
@@ -3,12 +3,7 @@
3
3
  */
4
4
  'use strict';
5
5
 
6
- /**
7
- * @typedef {object} ILogger
8
- * @prop {Function} log
9
- * @prop {Function} warn
10
- * @prop {Function} error
11
- */
6
+ /** @typedef {import('../log/types').LevelLogger} ILogger */
12
7
 
13
8
  /**
14
9
  *
@@ -0,0 +1,36 @@
1
+ /**
2
+ * @author wingbot.ai
3
+ */
4
+ 'use strict';
5
+
6
+ /**
7
+ * @typedef {object} SpanLogger
8
+ * @prop {<T>(name: string, fn: () => T|Promise<T>, attributes?: object) => Promise<T>} [span]
9
+ */
10
+
11
+ /**
12
+ * Runs `fn` in a span of the app logger, when the logger has `span()`
13
+ * (the shared wingbot logger: an OpenTelemetry span, App Insights and Sentry).
14
+ * Other loggers (console) only run `fn`.
15
+ *
16
+ * The attributes without a value are left out.
17
+ *
18
+ * @template T
19
+ * @param {SpanLogger|any} log
20
+ * @param {string} name
21
+ * @param {() => T|Promise<T>} fn
22
+ * @param {Record<string, any>} [attributes]
23
+ * @returns {Promise<T>}
24
+ */
25
+ async function logSpan (log, name, fn, attributes = {}) {
26
+ if (!log || typeof log.span !== 'function') {
27
+ return fn();
28
+ }
29
+ const attrs = Object.keys(attributes)
30
+ .filter((key) => attributes[key] !== undefined && attributes[key] !== null)
31
+ .reduce((o, key) => Object.assign(o, { [key]: attributes[key] }), {});
32
+
33
+ return log.span(name, fn, attrs);
34
+ }
35
+
36
+ module.exports = logSpan;
@@ -45,8 +45,14 @@ function parseActionPayload (object, needRawData = false) {
45
45
  let isObject = typeof payload === 'object' && payload !== null;
46
46
 
47
47
  if (typeof payload === 'string' && payload.match(/^\s*\{.*\}\s*$/)) {
48
- payload = JSON.parse(payload);
49
- isObject = true;
48
+ try {
49
+ payload = JSON.parse(payload);
50
+ isObject = true;
51
+ } catch (e) {
52
+ if (!(e instanceof SyntaxError)) throw e;
53
+ // broken JSON from the client: an unknown action, not a 500
54
+ // (the action check below rejects the raw string)
55
+ }
50
56
  }
51
57
 
52
58
  if (isObject) {
@@ -98,7 +98,11 @@ class CachedModel extends CustomEntityDetectionModel {
98
98
  .filter((e) => this._entityDetectors.has(e.entity)
99
99
  && this._entityDetectors.get(e.entity).clearOverlaps);
100
100
 
101
- if (this._options.verbose) this._log.log(`#NLP: ${text} [before]`, { before, intents, entities });
101
+ if (this._options.verbose) {
102
+ this._log.debug('#NLP before', {
103
+ text, before, intents, entities
104
+ });
105
+ }
102
106
 
103
107
  [intents, entities] = this._attachEntities(intents, entities, before, expectedEntities);
104
108
 
@@ -106,11 +110,19 @@ class CachedModel extends CustomEntityDetectionModel {
106
110
  .filter((e) => !this._entityDetectors.has(e.entity)
107
111
  || !this._entityDetectors.get(e.entity).clearOverlaps);
108
112
 
109
- if (this._options.verbose) this._log.log(`#NLP: ${text} [after]`, { after, intents, entities });
113
+ if (this._options.verbose) {
114
+ this._log.debug('#NLP after', {
115
+ text, after, intents, entities
116
+ });
117
+ }
110
118
 
111
119
  [intents, entities] = this._attachEntities(intents, entities, after);
112
120
 
113
- if (this._options.verbose) this._log.log(`#NLP: ${text} [attached]`, { intents, entities, expectedEntities });
121
+ if (this._options.verbose) {
122
+ this._log.debug('#NLP attached', {
123
+ text, intents, entities, expectedEntities
124
+ });
125
+ }
114
126
 
115
127
  return {
116
128
  text: local.text,
@@ -304,7 +304,7 @@ class CustomEntityDetectionModel {
304
304
  return zLen - aLen;
305
305
  });
306
306
 
307
- if (this._options.verbose) this._log.log('#NLP [nonOverlapping]', { entities, expectedEntities, justDuplicates });
307
+ if (this._options.verbose) this._log.debug('#NLP [nonOverlapping]', { entities, expectedEntities, justDuplicates });
308
308
 
309
309
  let res = [];
310
310
 
@@ -354,7 +354,7 @@ class CustomEntityDetectionModel {
354
354
  const othersConflict = res.some((e) => putback === e
355
355
  || (e.start < putback.end && e.end > putback.start));
356
356
 
357
- this._log.log(`#NLP (${i}|${k} [putBack: ${entity.entity}|${putback.entity}] (${i}|${k})`, {
357
+ this._log.debug(`#NLP (${i}|${k} [putBack: ${entity.entity}|${putback.entity}] (${i}|${k})`, {
358
358
  putback, entity, currentConflict, othersConflict
359
359
  });
360
360
 
@@ -366,7 +366,7 @@ class CustomEntityDetectionModel {
366
366
  }
367
367
 
368
368
  if (this._options.verbose) {
369
- this._log.log(`#NLP (${i}) [nonOverlapping| ${entity.entity}:${entity.value}]`, {
369
+ this._log.debug(`#NLP (${i}) [nonOverlapping| ${entity.entity}:${entity.value}]`, {
370
370
  willRemoveEntity: overlapping, overlapping, duplicate, isExpected, entity
371
371
  });
372
372
  }
@@ -109,7 +109,7 @@ class WingbotModel extends CachedModel {
109
109
  `matches=${encodeURIComponent(this._matches)}`
110
110
  ];
111
111
 
112
- if (this._options.verbose) this._log.log(`#NLP: ${text} [query]`, { matches: this._matches, localEntities: entities });
112
+ if (this._options.verbose) this._log.debug('#NLP query', { text, matches: this._matches, localEntities: entities });
113
113
 
114
114
  if (entities) {
115
115
  const buf = await this._brotli(Buffer.from(JSON.stringify({ entities })));
@@ -124,7 +124,7 @@ class WingbotModel extends CachedModel {
124
124
 
125
125
  if (res.status === 403) {
126
126
  // model does not exist
127
- this._log.log(`NLP model '${this._model}' does not exist or is not deployed yet.`);
127
+ this._log.warn('NLP model does not exist or is not deployed yet', { model: this._model });
128
128
  return { intents: [], entities: [] };
129
129
  }
130
130
 
@@ -136,7 +136,7 @@ class WingbotModel extends CachedModel {
136
136
  throw new Error(`[${res.status}] Invalid JSON response: ${responseText}`);
137
137
  }
138
138
 
139
- if (this._options.verbose) this._log.log(`#NLP: ${text} [response]`, response);
139
+ if (this._options.verbose) this._log.debug('#NLP response', { text, response });
140
140
 
141
141
  if (res.status >= 300) {
142
142
  this._log.warn(`AI query failed: ${response.message || res.statusText}`);