@juspay/neurolink 13.5.0 → 13.6.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/CHANGELOG.md +2 -2
- package/dist/adapters/video/videoAnalyzer.d.ts +1 -0
- package/dist/adapters/video/videoAnalyzer.js +4 -0
- package/dist/autoresearch/index.d.ts +1 -0
- package/dist/autoresearch/index.js +4 -0
- package/dist/autoresearch/worker.js +4 -0
- package/dist/browser/neurolink.min.js +476 -476
- package/dist/cli/commands/observability.js +11 -4
- package/dist/cli/commands/telemetry.js +36 -6
- package/dist/cli/utils/formatters.d.ts +17 -0
- package/dist/cli/utils/formatters.js +39 -0
- package/dist/constants/langfuse.d.ts +17 -1
- package/dist/constants/langfuse.js +23 -1
- package/dist/constants/telemetry.d.ts +6 -0
- package/dist/constants/telemetry.js +6 -0
- package/dist/core/baseProvider.d.ts +3 -1
- package/dist/core/baseProvider.js +30 -8
- package/dist/core/geminiLoopAdapter.js +19 -0
- package/dist/core/loopEngine.js +10 -7
- package/dist/core/modules/StreamHandler.js +5 -4
- package/dist/core/modules/TelemetryHandler.d.ts +5 -1
- package/dist/core/modules/TelemetryHandler.js +16 -0
- package/dist/core/modules/ToolsManager.js +14 -3
- package/dist/core/nativeGenerateLoop.d.ts +2 -0
- package/dist/core/nativeGenerateLoop.js +35 -9
- package/dist/evaluation/index.d.ts +1 -0
- package/dist/evaluation/index.js +4 -0
- package/dist/evaluation/ragasEvaluator.js +1 -0
- package/dist/mcp/externalServerManager.js +6 -2
- package/dist/mcp/toolRegistry.js +3 -0
- package/dist/neurolink.d.ts +16 -8
- package/dist/neurolink.js +115 -61
- package/dist/observability/FEATURE-STATUS.md +0 -4
- package/dist/observability/exporterRegistry.d.ts +6 -1
- package/dist/observability/exporterRegistry.js +18 -0
- package/dist/observability/exporterSpanProcessor.d.ts +20 -6
- package/dist/observability/exporterSpanProcessor.js +166 -34
- package/dist/observability/exporters/baseExporter.d.ts +22 -3
- package/dist/observability/exporters/baseExporter.js +87 -5
- package/dist/observability/exporters/langfuseExporter.d.ts +29 -3
- package/dist/observability/exporters/langfuseExporter.js +251 -41
- package/dist/observability/langfuseMasking.d.ts +1 -1
- package/dist/observability/langfuseMasking.js +1 -1
- package/dist/observability/otlpEncoding.d.ts +18 -5
- package/dist/observability/otlpEncoding.js +69 -12
- package/dist/observability/utils/spanSerializer.d.ts +3 -2
- package/dist/observability/utils/spanSerializer.js +5 -3
- package/dist/processors/base/BaseFileProcessor.d.ts +1 -0
- package/dist/processors/base/BaseFileProcessor.js +4 -0
- package/dist/processors/index.d.ts +1 -0
- package/dist/processors/index.js +4 -0
- package/dist/processors/registry/ProcessorRegistry.d.ts +1 -0
- package/dist/processors/registry/ProcessorRegistry.js +5 -0
- package/dist/providers/amazonBedrock/client.js +7 -5
- package/dist/providers/amazonSagemaker.js +32 -12
- package/dist/providers/anthropic/client.js +1 -0
- package/dist/providers/anthropic/loopAdapter.js +7 -3
- package/dist/providers/googleAiStudio/client.js +2 -0
- package/dist/providers/googleVertex/client.d.ts +6 -5
- package/dist/providers/googleVertex/client.js +46 -19
- package/dist/providers/openaiChatCompletionsBase.js +29 -18
- package/dist/proxy/codexUsage.js +59 -2
- package/dist/proxy/proxyTracer.d.ts +25 -1
- package/dist/proxy/proxyTracer.js +146 -22
- package/dist/proxy/proxyTranslationEngine.js +4 -2
- package/dist/proxy/sseInterceptor.js +6 -4
- package/dist/rag/chunkers/BaseChunker.js +1 -0
- package/dist/rag/index.d.ts +1 -0
- package/dist/rag/index.js +4 -0
- package/dist/rag/ragIntegration.js +1 -0
- package/dist/rag/reranker/reranker.js +2 -0
- package/dist/rag/retrieval/vectorQueryTool.js +1 -0
- package/dist/server/routes/codexProxyRoutes.js +3 -1
- package/dist/server/utils/validation.d.ts +3 -3
- package/dist/services/server/ai/observability/instrumentation.d.ts +8 -7
- package/dist/services/server/ai/observability/instrumentation.js +217 -47
- package/dist/services/server/ai/observability/langfuseSetup.d.ts +19 -1
- package/dist/services/server/ai/observability/langfuseSetup.js +62 -4
- package/dist/services/server/ai/observability/otlpSetup.d.ts +12 -2
- package/dist/services/server/ai/observability/otlpSetup.js +55 -4
- package/dist/telemetry/aiMetrics.d.ts +56 -0
- package/dist/telemetry/aiMetrics.js +229 -0
- package/dist/telemetry/attributes.d.ts +24 -1
- package/dist/telemetry/attributes.js +94 -9
- package/dist/telemetry/chatMessages.d.ts +6 -0
- package/dist/telemetry/chatMessages.js +37 -0
- package/dist/telemetry/index.d.ts +1 -1
- package/dist/telemetry/index.js +1 -1
- package/dist/telemetry/observation.d.ts +5 -5
- package/dist/telemetry/observation.js +128 -39
- package/dist/telemetry/spanSanitizer.d.ts +2 -2
- package/dist/telemetry/spanSanitizer.js +76 -8
- package/dist/telemetry/telemetryStarter.d.ts +16 -0
- package/dist/telemetry/telemetryStarter.js +28 -0
- package/dist/telemetry/traceContext.d.ts +3 -3
- package/dist/telemetry/traceContext.js +3 -3
- package/dist/telemetry/tracers.d.ts +13 -1
- package/dist/telemetry/tracers.js +49 -10
- package/dist/telemetry/withSpan.d.ts +19 -3
- package/dist/telemetry/withSpan.js +149 -20
- package/dist/tools/toolDiscovery.js +2 -1
- package/dist/tools/toolPolicy.js +22 -2
- package/dist/types/config.d.ts +19 -0
- package/dist/types/exporter.d.ts +98 -3
- package/dist/types/generate.d.ts +7 -1
- package/dist/types/livekit.d.ts +16 -1
- package/dist/types/loopEngine.d.ts +16 -1
- package/dist/types/observability.d.ts +38 -5
- package/dist/types/proxy.d.ts +2 -0
- package/dist/types/span.d.ts +26 -0
- package/dist/types/toolResolution.d.ts +5 -1
- package/dist/utils/timeout.js +4 -2
- package/dist/voice/livekit/realtimeMcpTools.js +11 -2
- package/dist/voice/livekit/realtimeMemory.d.ts +20 -2
- package/dist/voice/livekit/realtimeMemory.js +139 -46
- package/dist/voice/livekit/realtimeVoiceAgent.js +20 -8
- package/dist/voice/livekit/turnRecorder.js +22 -9
- package/dist/workflow/core/ensembleExecutor.js +2 -0
- package/dist/workflow/core/judgeScorer.js +1 -0
- package/dist/workflow/core/responseConditioner.js +1 -0
- package/dist/workflow/core/workflowRunner.js +6 -1
- package/dist/workflow/index.d.ts +1 -0
- package/dist/workflow/index.js +4 -0
- package/docs-site/static/search-index.json +32 -27
- package/package.json +21 -2
|
@@ -11,7 +11,8 @@ import chalk from "chalk";
|
|
|
11
11
|
import ora from "ora";
|
|
12
12
|
import { logger } from "../../utils/logger.js";
|
|
13
13
|
import { NeuroLink } from "../../neurolink.js";
|
|
14
|
-
import {
|
|
14
|
+
import { ensureTelemetry } from "../../services/server/ai/observability/instrumentation.js";
|
|
15
|
+
import { formatRow, formatCost, formatExporterLines, formatOtelStatus, } from "../utils/formatters.js";
|
|
15
16
|
import { redactUrlCredentials } from "../../utils/logSanitize.js";
|
|
16
17
|
/**
|
|
17
18
|
* Observability Command Factory
|
|
@@ -66,6 +67,9 @@ export class ObservabilityCommandFactory {
|
|
|
66
67
|
? null
|
|
67
68
|
: ora("Checking observability status...").start();
|
|
68
69
|
try {
|
|
70
|
+
// A one-shot process only starts telemetry with its first span, so
|
|
71
|
+
// start it here to report the exporters this process would run.
|
|
72
|
+
ensureTelemetry();
|
|
69
73
|
const neurolink = new NeuroLink();
|
|
70
74
|
const status = neurolink.getTelemetryStatus();
|
|
71
75
|
if (spinner) {
|
|
@@ -105,9 +109,7 @@ export class ObservabilityCommandFactory {
|
|
|
105
109
|
if (status.openTelemetry) {
|
|
106
110
|
logger.always("");
|
|
107
111
|
logger.always(chalk.bold("OpenTelemetry:"));
|
|
108
|
-
const otelStatus = status.openTelemetry
|
|
109
|
-
? chalk.green("Enabled")
|
|
110
|
-
: chalk.gray("Disabled");
|
|
112
|
+
const otelStatus = formatOtelStatus(status.openTelemetry, "Enabled", "Disabled");
|
|
111
113
|
logger.always(formatRow(" Status:", otelStatus));
|
|
112
114
|
if (status.openTelemetry.endpoint) {
|
|
113
115
|
logger.always(formatRow(" Endpoint:", redactUrlCredentials(status.openTelemetry.endpoint)));
|
|
@@ -117,6 +119,11 @@ export class ObservabilityCommandFactory {
|
|
|
117
119
|
}
|
|
118
120
|
}
|
|
119
121
|
logger.always("");
|
|
122
|
+
logger.always(chalk.bold("Exporters:"));
|
|
123
|
+
for (const line of formatExporterLines(status.exporters)) {
|
|
124
|
+
logger.always(line);
|
|
125
|
+
}
|
|
126
|
+
logger.always("");
|
|
120
127
|
}
|
|
121
128
|
}
|
|
122
129
|
catch (error) {
|
|
@@ -11,10 +11,20 @@ import chalk from "chalk";
|
|
|
11
11
|
import ora from "ora";
|
|
12
12
|
import { logger } from "../../utils/logger.js";
|
|
13
13
|
import { NeuroLink } from "../../neurolink.js";
|
|
14
|
-
import { flushOpenTelemetry } from "../../services/server/ai/observability/instrumentation.js";
|
|
15
|
-
import {
|
|
14
|
+
import { ensureTelemetry, flushOpenTelemetry, } from "../../services/server/ai/observability/instrumentation.js";
|
|
15
|
+
import { getExporterRegistry } from "../../observability/exporterRegistry.js";
|
|
16
|
+
import { formatRow, formatCost, formatExporterLines, formatOtelStatus, } from "../utils/formatters.js";
|
|
16
17
|
import { redactUrlCredentials } from "../../utils/logSanitize.js";
|
|
17
18
|
import { prepareNativeTelemetry, validateNativeTelemetry, } from "../utils/nativeTelemetry.js";
|
|
19
|
+
function exporterTotals() {
|
|
20
|
+
return getExporterRegistry()
|
|
21
|
+
.getStatuses()
|
|
22
|
+
.reduce((sum, exporter) => ({
|
|
23
|
+
pending: sum.pending + exporter.pendingSpans,
|
|
24
|
+
delivered: sum.delivered + exporter.deliveredSpans,
|
|
25
|
+
failed: sum.failed + exporter.failedSpans,
|
|
26
|
+
}), { pending: 0, delivered: 0, failed: 0 });
|
|
27
|
+
}
|
|
18
28
|
/**
|
|
19
29
|
* Telemetry Command Factory
|
|
20
30
|
*/
|
|
@@ -180,6 +190,9 @@ export class TelemetryCommandFactory {
|
|
|
180
190
|
? null
|
|
181
191
|
: ora("Checking telemetry status...").start();
|
|
182
192
|
try {
|
|
193
|
+
// A one-shot process only starts telemetry with its first span, so
|
|
194
|
+
// start it here to report the exporters this process would run.
|
|
195
|
+
ensureTelemetry();
|
|
183
196
|
const neurolink = new NeuroLink();
|
|
184
197
|
const status = neurolink.getTelemetryStatus();
|
|
185
198
|
if (spinner) {
|
|
@@ -204,9 +217,7 @@ export class TelemetryCommandFactory {
|
|
|
204
217
|
if (status.openTelemetry) {
|
|
205
218
|
logger.always("");
|
|
206
219
|
logger.always(chalk.bold("OpenTelemetry:"));
|
|
207
|
-
const otelStatus = status.openTelemetry
|
|
208
|
-
? chalk.green("Active")
|
|
209
|
-
: chalk.gray("Inactive");
|
|
220
|
+
const otelStatus = formatOtelStatus(status.openTelemetry, "Active", "Inactive");
|
|
210
221
|
logger.always(formatRow(" Status:", otelStatus));
|
|
211
222
|
if (status.openTelemetry.endpoint) {
|
|
212
223
|
logger.always(formatRow(" Endpoint:", redactUrlCredentials(status.openTelemetry.endpoint)));
|
|
@@ -231,6 +242,11 @@ export class TelemetryCommandFactory {
|
|
|
231
242
|
}
|
|
232
243
|
}
|
|
233
244
|
logger.always("");
|
|
245
|
+
logger.always(chalk.bold("Exporters:"));
|
|
246
|
+
for (const line of formatExporterLines(status.exporters)) {
|
|
247
|
+
logger.always(line);
|
|
248
|
+
}
|
|
249
|
+
logger.always("");
|
|
234
250
|
}
|
|
235
251
|
}
|
|
236
252
|
catch (error) {
|
|
@@ -277,6 +293,7 @@ export class TelemetryCommandFactory {
|
|
|
277
293
|
? null
|
|
278
294
|
: ora("Flushing pending spans...").start();
|
|
279
295
|
try {
|
|
296
|
+
const before = exporterTotals();
|
|
280
297
|
// Create a timeout promise
|
|
281
298
|
let flushTimer;
|
|
282
299
|
const timeoutPromise = new Promise((_, reject) => {
|
|
@@ -293,13 +310,26 @@ export class TelemetryCommandFactory {
|
|
|
293
310
|
if (spinner) {
|
|
294
311
|
spinner.succeed("Flush completed");
|
|
295
312
|
}
|
|
313
|
+
const after = exporterTotals();
|
|
314
|
+
const flushed = after.delivered - before.delivered;
|
|
315
|
+
const dropped = after.failed - before.failed;
|
|
296
316
|
if (args.format === "json") {
|
|
297
|
-
logger.always(JSON.stringify({
|
|
317
|
+
logger.always(JSON.stringify({
|
|
318
|
+
success: true,
|
|
319
|
+
pendingBefore: before.pending,
|
|
320
|
+
pendingAfter: after.pending,
|
|
321
|
+
flushed,
|
|
322
|
+
dropped,
|
|
323
|
+
}, null, 2));
|
|
298
324
|
}
|
|
299
325
|
else {
|
|
300
326
|
logger.always("");
|
|
301
327
|
logger.always(chalk.bold.cyan("=== Flush Complete ==="));
|
|
302
328
|
logger.always("");
|
|
329
|
+
logger.always(formatRow("Spans before:", before.pending.toString()));
|
|
330
|
+
logger.always(formatRow("Spans after:", after.pending.toString()));
|
|
331
|
+
logger.always(formatRow("Flushed:", chalk.green(flushed.toString())));
|
|
332
|
+
logger.always(formatRow("Dropped:", dropped > 0 ? chalk.red(dropped.toString()) : "0"));
|
|
303
333
|
logger.always("");
|
|
304
334
|
}
|
|
305
335
|
}
|
|
@@ -3,10 +3,27 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Common formatting functions used across CLI command modules.
|
|
5
5
|
*/
|
|
6
|
+
import type { ExporterStatus } from "../../types/index.js";
|
|
6
7
|
/**
|
|
7
8
|
* Format a table row with padding
|
|
8
9
|
*/
|
|
9
10
|
export declare function formatRow(label: string, value: string, width?: number): string;
|
|
11
|
+
/**
|
|
12
|
+
* Format the status rows of the registered exporters
|
|
13
|
+
*/
|
|
14
|
+
export declare function formatExporterLines(exporters: ExporterStatus[]): string[];
|
|
15
|
+
/**
|
|
16
|
+
* @description Labels NeuroLink's OpenTelemetry export for the status commands. Beside a host TracerProvider NeuroLink exports nothing to the endpoint, so it is shown as ignored rather than active.
|
|
17
|
+
* @param otel {{ enabled: boolean; endpoint?: string; usingExternalProvider?: boolean }} - `getTelemetryStatus().openTelemetry`.
|
|
18
|
+
* @param on {string} - Label for an export NeuroLink runs.
|
|
19
|
+
* @param off {string} - Label for no export.
|
|
20
|
+
* @returns {string} The coloured label.
|
|
21
|
+
*/
|
|
22
|
+
export declare function formatOtelStatus(otel: {
|
|
23
|
+
enabled: boolean;
|
|
24
|
+
endpoint?: string;
|
|
25
|
+
usingExternalProvider?: boolean;
|
|
26
|
+
}, on: string, off: string): string;
|
|
10
27
|
/**
|
|
11
28
|
* Format currency value
|
|
12
29
|
*/
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Common formatting functions used across CLI command modules.
|
|
5
5
|
*/
|
|
6
|
+
import chalk from "chalk";
|
|
6
7
|
/**
|
|
7
8
|
* Format a table row with padding
|
|
8
9
|
*/
|
|
@@ -10,6 +11,44 @@ export function formatRow(label, value, width = 30) {
|
|
|
10
11
|
const paddedLabel = label.padEnd(width);
|
|
11
12
|
return `${paddedLabel} ${value}`;
|
|
12
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* Format the status rows of the registered exporters
|
|
16
|
+
*/
|
|
17
|
+
export function formatExporterLines(exporters) {
|
|
18
|
+
if (exporters.length === 0) {
|
|
19
|
+
return [" None registered"];
|
|
20
|
+
}
|
|
21
|
+
return exporters.flatMap((exporter) => {
|
|
22
|
+
const state = !exporter.enabled
|
|
23
|
+
? "Stopped"
|
|
24
|
+
: exporter.healthy
|
|
25
|
+
? "Active"
|
|
26
|
+
: "Failing";
|
|
27
|
+
const counts = `pending ${exporter.pendingSpans}, delivered ${exporter.deliveredSpans}, failed ${exporter.failedSpans}`;
|
|
28
|
+
return [
|
|
29
|
+
formatRow(` ${exporter.name}:`, `${state} (${counts})`),
|
|
30
|
+
...(exporter.lastExportTime
|
|
31
|
+
? [formatRow(" Last export:", exporter.lastExportTime)]
|
|
32
|
+
: []),
|
|
33
|
+
...(exporter.errors ?? []).map((error) => formatRow(" Error:", error)),
|
|
34
|
+
];
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* @description Labels NeuroLink's OpenTelemetry export for the status commands. Beside a host TracerProvider NeuroLink exports nothing to the endpoint, so it is shown as ignored rather than active.
|
|
39
|
+
* @param otel {{ enabled: boolean; endpoint?: string; usingExternalProvider?: boolean }} - `getTelemetryStatus().openTelemetry`.
|
|
40
|
+
* @param on {string} - Label for an export NeuroLink runs.
|
|
41
|
+
* @param off {string} - Label for no export.
|
|
42
|
+
* @returns {string} The coloured label.
|
|
43
|
+
*/
|
|
44
|
+
export function formatOtelStatus(otel, on, off) {
|
|
45
|
+
if (otel.usingExternalProvider) {
|
|
46
|
+
return chalk.yellow(otel.endpoint
|
|
47
|
+
? "Ignored (a host TracerProvider is registered)"
|
|
48
|
+
: "Host TracerProvider");
|
|
49
|
+
}
|
|
50
|
+
return otel.enabled ? chalk.green(on) : chalk.gray(off);
|
|
51
|
+
}
|
|
13
52
|
/**
|
|
14
53
|
* Format currency value
|
|
15
54
|
*/
|
|
@@ -11,15 +11,31 @@ export declare const DEFAULT_FLUSH_AT = 15;
|
|
|
11
11
|
export declare const DEFAULT_FLUSH_INTERVAL_MS = 5000;
|
|
12
12
|
/** @description Milliseconds before an export request is aborted. */
|
|
13
13
|
export declare const DEFAULT_TIMEOUT_MS = 30000;
|
|
14
|
+
/** @description Milliseconds a flush or shutdown waits for delivery before it abandons what is still in flight. */
|
|
15
|
+
export declare const DEFAULT_FLUSH_BUDGET_MS = 3000;
|
|
14
16
|
/** @description Maximum spans sent in one export request. */
|
|
15
17
|
export declare const MAX_SPANS_PER_REQUEST = 500;
|
|
18
|
+
/** @description Export requests to Langfuse in flight at once. Spans that end while every one is busy wait in the buffer. */
|
|
19
|
+
export declare const MAX_CONCURRENT_DELIVERIES = 4;
|
|
20
|
+
/** @description Spans that may wait for a delivery slot. Beyond it the oldest waiting spans are dropped and counted, so a Langfuse that stops answering cannot hold an unbounded backlog in memory. */
|
|
21
|
+
export declare const MAX_QUEUED_SPANS = 10000;
|
|
22
|
+
/** @description Milliseconds between warn-level logs about waiting spans dropped for lack of a delivery slot. */
|
|
23
|
+
export declare const QUEUE_DROP_WARN_INTERVAL_MS = 60000;
|
|
24
|
+
/** @description Longest `Retry-After` a 429 or 503 from Langfuse is waited out before the retry. */
|
|
25
|
+
export declare const MAX_EXPORT_RETRY_AFTER_MS = 60000;
|
|
16
26
|
/** @description Maximum serialized bytes sent in one export request. */
|
|
17
27
|
export declare const MAX_BYTES_PER_REQUEST = 3500000;
|
|
18
28
|
/** @description Placeholder that replaces a value fully removed by the user's mask. */
|
|
19
29
|
export declare const FULLY_MASKED = "<fully masked>";
|
|
20
|
-
/** @description Span attributes holding trace and observation input or output, which the mask
|
|
30
|
+
/** @description Span attributes holding trace and observation input or output, which the Langfuse exporter's mask and `redactIO` apply to. */
|
|
21
31
|
export declare const CONTENT_KEYS: Set<string>;
|
|
22
32
|
/** @description Attribute prefixes whose values are metadata entries. */
|
|
23
33
|
export declare const METADATA_PREFIXES: string[];
|
|
34
|
+
/** @description Observation types that hold no model, usage or cost in Langfuse. GenAI model and usage keys on them are left out of the Langfuse export, so a request's generation stays its only priced record. */
|
|
35
|
+
export declare const UNPRICED_OBSERVATION_TYPES: ReadonlySet<string>;
|
|
36
|
+
/** @description GenAI attributes Langfuse reads a model name from. */
|
|
37
|
+
export declare const MODEL_ATTRIBUTE_KEYS: ReadonlySet<string>;
|
|
38
|
+
/** @description Prefix of the GenAI attributes Langfuse reads usage and cost from. */
|
|
39
|
+
export declare const USAGE_ATTRIBUTE_PREFIX = "gen_ai.usage.";
|
|
24
40
|
/** @description OTLP numeric status code for each span status. */
|
|
25
41
|
export declare const OTLP_STATUS_CODES: Record<SpanStatus, number>;
|
|
@@ -11,13 +11,23 @@ export const DEFAULT_FLUSH_AT = 15;
|
|
|
11
11
|
export const DEFAULT_FLUSH_INTERVAL_MS = 5_000;
|
|
12
12
|
/** @description Milliseconds before an export request is aborted. */
|
|
13
13
|
export const DEFAULT_TIMEOUT_MS = 30_000;
|
|
14
|
+
/** @description Milliseconds a flush or shutdown waits for delivery before it abandons what is still in flight. */
|
|
15
|
+
export const DEFAULT_FLUSH_BUDGET_MS = 3_000;
|
|
14
16
|
/** @description Maximum spans sent in one export request. */
|
|
15
17
|
export const MAX_SPANS_PER_REQUEST = 500;
|
|
18
|
+
/** @description Export requests to Langfuse in flight at once. Spans that end while every one is busy wait in the buffer. */
|
|
19
|
+
export const MAX_CONCURRENT_DELIVERIES = 4;
|
|
20
|
+
/** @description Spans that may wait for a delivery slot. Beyond it the oldest waiting spans are dropped and counted, so a Langfuse that stops answering cannot hold an unbounded backlog in memory. */
|
|
21
|
+
export const MAX_QUEUED_SPANS = 10_000;
|
|
22
|
+
/** @description Milliseconds between warn-level logs about waiting spans dropped for lack of a delivery slot. */
|
|
23
|
+
export const QUEUE_DROP_WARN_INTERVAL_MS = 60_000;
|
|
24
|
+
/** @description Longest `Retry-After` a 429 or 503 from Langfuse is waited out before the retry. */
|
|
25
|
+
export const MAX_EXPORT_RETRY_AFTER_MS = 60_000;
|
|
16
26
|
/** @description Maximum serialized bytes sent in one export request. */
|
|
17
27
|
export const MAX_BYTES_PER_REQUEST = 3_500_000;
|
|
18
28
|
/** @description Placeholder that replaces a value fully removed by the user's mask. */
|
|
19
29
|
export const FULLY_MASKED = "<fully masked>";
|
|
20
|
-
/** @description Span attributes holding trace and observation input or output, which the mask
|
|
30
|
+
/** @description Span attributes holding trace and observation input or output, which the Langfuse exporter's mask and `redactIO` apply to. */
|
|
21
31
|
export const CONTENT_KEYS = new Set([
|
|
22
32
|
"langfuse.observation.input",
|
|
23
33
|
"langfuse.observation.output",
|
|
@@ -29,6 +39,18 @@ export const METADATA_PREFIXES = [
|
|
|
29
39
|
"langfuse.observation.metadata.",
|
|
30
40
|
"langfuse.trace.metadata.",
|
|
31
41
|
];
|
|
42
|
+
/** @description Observation types that hold no model, usage or cost in Langfuse. GenAI model and usage keys on them are left out of the Langfuse export, so a request's generation stays its only priced record. */
|
|
43
|
+
export const UNPRICED_OBSERVATION_TYPES = new Set([
|
|
44
|
+
"span",
|
|
45
|
+
"event",
|
|
46
|
+
]);
|
|
47
|
+
/** @description GenAI attributes Langfuse reads a model name from. */
|
|
48
|
+
export const MODEL_ATTRIBUTE_KEYS = new Set([
|
|
49
|
+
"gen_ai.request.model",
|
|
50
|
+
"gen_ai.response.model",
|
|
51
|
+
]);
|
|
52
|
+
/** @description Prefix of the GenAI attributes Langfuse reads usage and cost from. */
|
|
53
|
+
export const USAGE_ATTRIBUTE_PREFIX = "gen_ai.usage.";
|
|
32
54
|
/** @description OTLP numeric status code for each span status. */
|
|
33
55
|
export const OTLP_STATUS_CODES = {
|
|
34
56
|
[SpanStatus.UNSET]: 0,
|
|
@@ -18,3 +18,9 @@ export declare const TRACE_METADATA_CONTEXT_KEY = "neurolink.trace.metadata";
|
|
|
18
18
|
export declare const OTEL_LOG_PREFIX = "[OpenTelemetry]";
|
|
19
19
|
/** @description Most open and most ended spans the exporter span processor remembers for re-parenting; the oldest are forgotten first. */
|
|
20
20
|
export declare const MAX_REMEMBERED_SPANS = 5000;
|
|
21
|
+
/** @description Most redacted span attribute strings remembered so `observe` does not redact them a second time. */
|
|
22
|
+
export declare const MAX_REDACTED_ATTRIBUTES = 64;
|
|
23
|
+
/** @description Span attribute that keeps a wrapper span out of every exporter. */
|
|
24
|
+
export declare const INTERNAL_SPAN_ATTRIBUTE = "langfuse.internal";
|
|
25
|
+
/** @description Instrumentation scope of the proxy's tracer. Of the proxy's spans only the ones it marks as observations (the request root and its generation) reach the exporters, although its spans also carry `gen_ai.*` keys for collectors. */
|
|
26
|
+
export declare const PROXY_TRACER_SCOPE = "neurolink.proxy";
|
|
@@ -32,3 +32,9 @@ export const TRACE_METADATA_CONTEXT_KEY = "neurolink.trace.metadata";
|
|
|
32
32
|
export const OTEL_LOG_PREFIX = "[OpenTelemetry]";
|
|
33
33
|
/** @description Most open and most ended spans the exporter span processor remembers for re-parenting; the oldest are forgotten first. */
|
|
34
34
|
export const MAX_REMEMBERED_SPANS = 5_000;
|
|
35
|
+
/** @description Most redacted span attribute strings remembered so `observe` does not redact them a second time. */
|
|
36
|
+
export const MAX_REDACTED_ATTRIBUTES = 64;
|
|
37
|
+
/** @description Span attribute that keeps a wrapper span out of every exporter. */
|
|
38
|
+
export const INTERNAL_SPAN_ATTRIBUTE = "langfuse.internal";
|
|
39
|
+
/** @description Instrumentation scope of the proxy's tracer. Of the proxy's spans only the ones it marks as observations (the request root and its generation) reach the exporters, although its spans also carry `gen_ai.*` keys for collectors. */
|
|
40
|
+
export const PROXY_TRACER_SCOPE = "neurolink.proxy";
|
|
@@ -152,7 +152,9 @@ export declare abstract class BaseProvider implements AIProvider {
|
|
|
152
152
|
private sortToolRecord;
|
|
153
153
|
/**
|
|
154
154
|
* On-demand discovery (`tools.discovery: true`): defer external MCP tool
|
|
155
|
-
* schemas
|
|
155
|
+
* schemas — or, with `tools.discoveryScope: "all"`, every non-built-in
|
|
156
|
+
* tool — behind one `search_tools` meta-tool, except names matching
|
|
157
|
+
* `tools.discoveryAlwaysVisible`. Built-in tools, per-call
|
|
156
158
|
* tools, explicitly whitelisted tools, and session-pinned (previously
|
|
157
159
|
* discovered) tools always stay hot. No-op when discovery is off — with a
|
|
158
160
|
* one-time WARN when the catalog is large enough that selection accuracy
|
|
@@ -1246,7 +1246,9 @@ export class BaseProvider {
|
|
|
1246
1246
|
}
|
|
1247
1247
|
/**
|
|
1248
1248
|
* On-demand discovery (`tools.discovery: true`): defer external MCP tool
|
|
1249
|
-
* schemas
|
|
1249
|
+
* schemas — or, with `tools.discoveryScope: "all"`, every non-built-in
|
|
1250
|
+
* tool — behind one `search_tools` meta-tool, except names matching
|
|
1251
|
+
* `tools.discoveryAlwaysVisible`. Built-in tools, per-call
|
|
1250
1252
|
* tools, explicitly whitelisted tools, and session-pinned (previously
|
|
1251
1253
|
* discovered) tools always stay hot. No-op when discovery is off — with a
|
|
1252
1254
|
* one-time WARN when the catalog is large enough that selection accuracy
|
|
@@ -1273,8 +1275,13 @@ export class BaseProvider {
|
|
|
1273
1275
|
}
|
|
1274
1276
|
return tools;
|
|
1275
1277
|
}
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
+
// "external" defers only attached MCP servers' tools; "all" defers every
|
|
1279
|
+
// tool in the record except the built-ins.
|
|
1280
|
+
const builtinNames = new Set(Object.keys(this.directTools ?? {}));
|
|
1281
|
+
const candidateNames = policy.discoveryScope === "all"
|
|
1282
|
+
? Object.keys(tools).filter((name) => !builtinNames.has(name))
|
|
1283
|
+
: (this.neurolink?.getExternalMCPTools() ?? []).map((t) => t.name);
|
|
1284
|
+
if (candidateNames.length === 0) {
|
|
1278
1285
|
return tools;
|
|
1279
1286
|
}
|
|
1280
1287
|
// Session pinning requires a caller-provided sessionId. Without one there
|
|
@@ -1305,12 +1312,15 @@ export class BaseProvider {
|
|
|
1305
1312
|
explicitPatterns.push(toolChoice.toolName);
|
|
1306
1313
|
}
|
|
1307
1314
|
const explicitMatcher = explicitPatterns.length > 0 ? toolNameMatcher(explicitPatterns) : null;
|
|
1308
|
-
const
|
|
1309
|
-
|
|
1310
|
-
|
|
1315
|
+
const alwaysVisiblePatterns = policy.discoveryAlwaysVisible ?? [];
|
|
1316
|
+
const alwaysVisibleMatcher = alwaysVisiblePatterns.length > 0
|
|
1317
|
+
? toolNameMatcher(alwaysVisiblePatterns)
|
|
1318
|
+
: null;
|
|
1319
|
+
const deferrableNames = candidateNames.filter((name) => name in tools &&
|
|
1311
1320
|
!perCallNames.has(name) &&
|
|
1312
|
-
!(explicitMatcher ? explicitMatcher(name) : false)
|
|
1313
|
-
|
|
1321
|
+
!(explicitMatcher ? explicitMatcher(name) : false) &&
|
|
1322
|
+
!(alwaysVisibleMatcher ? alwaysVisibleMatcher(name) : false));
|
|
1323
|
+
const partitioned = partitionToolsForDiscovery(tools, {
|
|
1314
1324
|
deferrableNames,
|
|
1315
1325
|
pinnedNames,
|
|
1316
1326
|
onHydrate: (names) => {
|
|
@@ -1319,6 +1329,16 @@ export class BaseProvider {
|
|
|
1319
1329
|
}
|
|
1320
1330
|
},
|
|
1321
1331
|
});
|
|
1332
|
+
// The meta-tool is built after the event wrappers ran, so without this
|
|
1333
|
+
// a search_tools call never reaches tool:start / tool:end listeners.
|
|
1334
|
+
const metaTool = partitioned["search_tools"];
|
|
1335
|
+
if (isDiscoveryMetaTool(metaTool)) {
|
|
1336
|
+
partitioned["search_tools"] =
|
|
1337
|
+
this.toolsManager.wrapExternalToolsWithEvents({
|
|
1338
|
+
search_tools: metaTool,
|
|
1339
|
+
})["search_tools"];
|
|
1340
|
+
}
|
|
1341
|
+
return partitioned;
|
|
1322
1342
|
}
|
|
1323
1343
|
static warnedLargeCatalog = false;
|
|
1324
1344
|
warnLargeCatalogOnce(toolCount) {
|
|
@@ -1760,6 +1780,7 @@ export class BaseProvider {
|
|
|
1760
1780
|
error,
|
|
1761
1781
|
latency,
|
|
1762
1782
|
};
|
|
1783
|
+
this.telemetryHandler.recordTTSFailure(ttsProvider, error, latency);
|
|
1763
1784
|
logger.error(`TTS synthesis failed in Mode 1 (direct input synthesis):`, ttsError);
|
|
1764
1785
|
}
|
|
1765
1786
|
return this.enhanceResult(baseResult, options, startTime);
|
|
@@ -2117,6 +2138,7 @@ export class BaseProvider {
|
|
|
2117
2138
|
catch (ttsError) {
|
|
2118
2139
|
const latency = Date.now() - ttsStartTime;
|
|
2119
2140
|
const error = this.getTTSErrorDetails(ttsError);
|
|
2141
|
+
this.telemetryHandler.recordTTSFailure(ttsProvider, error, latency);
|
|
2120
2142
|
logger.error(`TTS synthesis failed in Mode 2 (AI response synthesis):`, ttsError);
|
|
2121
2143
|
return {
|
|
2122
2144
|
...enhancedResult,
|
|
@@ -242,6 +242,25 @@ export function createGeminiLoopAdapter(config) {
|
|
|
242
242
|
});
|
|
243
243
|
return next;
|
|
244
244
|
},
|
|
245
|
+
// Gemini's prompt count already contains the cached tokens, and its
|
|
246
|
+
// candidate count leaves out the thinking tokens. The step's usage keeps
|
|
247
|
+
// that raw shape because the provider clients reconcile the turn totals
|
|
248
|
+
// from it once, and callers get those counts as the API reported them;
|
|
249
|
+
// only the generation observation is rebased, so a cached token is not
|
|
250
|
+
// billed at the input rate as well as the cache rate, a cached count above
|
|
251
|
+
// the prompt is not billed as extra tokens, and a thinking token is billed
|
|
252
|
+
// at the output rate.
|
|
253
|
+
observedUsage(usage) {
|
|
254
|
+
const cached = usage.cacheReadTokens === undefined
|
|
255
|
+
? undefined
|
|
256
|
+
: Math.min(usage.cacheReadTokens, usage.inputTokens);
|
|
257
|
+
return {
|
|
258
|
+
...usage,
|
|
259
|
+
inputTokens: Math.max(0, usage.inputTokens - (cached ?? 0)),
|
|
260
|
+
outputTokens: usage.outputTokens + (usage.reasoningTokens ?? 0),
|
|
261
|
+
...(cached !== undefined && { cacheReadTokens: cached }),
|
|
262
|
+
};
|
|
263
|
+
},
|
|
245
264
|
mapFinishReason(rawStopReason, hadToolCallsAtCap) {
|
|
246
265
|
const mapped = mapGeminiFinishReason(rawStopReason);
|
|
247
266
|
// "tool-calls" from this hook means the step cap cut the turn off with
|
package/dist/core/loopEngine.js
CHANGED
|
@@ -306,6 +306,7 @@ async function executeObservedStep(adapter, request, channel, signal, span) {
|
|
|
306
306
|
catch (err) {
|
|
307
307
|
throw err instanceof PostEmissionStepError ? err.cause : err;
|
|
308
308
|
}
|
|
309
|
+
const observed = adapter.observedUsage?.(result.usage) ?? result.usage;
|
|
309
310
|
observeGeneration(generationSpan, {
|
|
310
311
|
provider: request.generation?.provider,
|
|
311
312
|
model: request.generation?.model,
|
|
@@ -316,13 +317,15 @@ async function executeObservedStep(adapter, request, channel, signal, span) {
|
|
|
316
317
|
...(result.toolCalls.length > 0 ? { toolCalls: result.toolCalls } : {}),
|
|
317
318
|
},
|
|
318
319
|
usage: {
|
|
319
|
-
inputTokens:
|
|
320
|
-
outputTokens:
|
|
321
|
-
cacheReadTokens:
|
|
322
|
-
cacheCreationTokens:
|
|
323
|
-
reasoningTokens:
|
|
320
|
+
inputTokens: observed.inputTokens,
|
|
321
|
+
outputTokens: observed.outputTokens,
|
|
322
|
+
cacheReadTokens: observed.cacheReadTokens,
|
|
323
|
+
cacheCreationTokens: observed.cacheWriteTokens,
|
|
324
|
+
reasoningTokens: observed.reasoningTokens,
|
|
324
325
|
},
|
|
325
|
-
|
|
326
|
+
// A step cut short before the provider's terminal event has no stop
|
|
327
|
+
// reason; record "other", as the turn's own finishReason does.
|
|
328
|
+
finishReason: result.rawStopReason ?? (signal.aborted ? "other" : undefined),
|
|
326
329
|
aborted: signal.aborted,
|
|
327
330
|
completionStartTime: firstChunkAt,
|
|
328
331
|
});
|
|
@@ -370,7 +373,7 @@ export function runAgenticLoop(adapter, initialConversation, options) {
|
|
|
370
373
|
// ever writes to it, so the loop is the same loop it was.
|
|
371
374
|
let stepCap = adapter.maxSteps;
|
|
372
375
|
const turnStartedAt = Date.now();
|
|
373
|
-
const stepSpans = new StepSpans();
|
|
376
|
+
const stepSpans = new StepSpans(options.operation);
|
|
374
377
|
try {
|
|
375
378
|
for (let step = 0; step < stepCap; step++) {
|
|
376
379
|
if (internalAbort.signal.aborted) {
|
|
@@ -129,10 +129,11 @@ export class StreamHandler {
|
|
|
129
129
|
// captured upstream error (if any) so providerError /
|
|
130
130
|
// modelResponseRaw carry the real cause.
|
|
131
131
|
const sentinel = await buildNoOutputSentinel(error, result, getUnderlyingError?.());
|
|
132
|
-
// Curator P2-5 + P3-6: stamp the active OTel span
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
// stampNoOutputSpan so every wired
|
|
132
|
+
// Curator P2-5 + P3-6: stamp the active OTel span with the
|
|
133
|
+
// WARNING observation level and a status message carrying
|
|
134
|
+
// finishReason + token usage, which ExporterSpanProcessor exports
|
|
135
|
+
// with the span. Centralized in stampNoOutputSpan so every wired
|
|
136
|
+
// site stamps consistently.
|
|
136
137
|
stampNoOutputSpan(sentinel);
|
|
137
138
|
// S4 fix: yield a sentinel chunk so Pipeline B can detect the empty stream
|
|
138
139
|
// and set the span to WARNING status instead of OK
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* @module core/modules/TelemetryHandler
|
|
14
14
|
*/
|
|
15
15
|
import type { NeuroLink } from "../../neurolink.js";
|
|
16
|
-
import type { StreamOptions, AIProviderName, AnalyticsData, EnhancedGenerateResult, EvaluationData, MemoryToolCallRecord, MemoryToolResultRecord, RawUsageObject, TextGenerationOptions, TokenUsage } from "../../types/index.js";
|
|
16
|
+
import type { StreamOptions, AIProviderName, AnalyticsData, EnhancedGenerateResult, EvaluationData, MemoryToolCallRecord, MemoryToolResultRecord, RawUsageObject, TextGenerationOptions, TokenUsage, TTSMetadata } from "../../types/index.js";
|
|
17
17
|
/**
|
|
18
18
|
* TelemetryHandler class - Handles analytics and telemetry for AI providers
|
|
19
19
|
*/
|
|
@@ -34,6 +34,10 @@ export declare class TelemetryHandler {
|
|
|
34
34
|
* Record performance metrics for a generation
|
|
35
35
|
*/
|
|
36
36
|
recordPerformanceMetrics(usage: RawUsageObject | undefined, responseTime: number): Promise<void>;
|
|
37
|
+
/**
|
|
38
|
+
* Record a TTS synthesis failure without affecting the generation result.
|
|
39
|
+
*/
|
|
40
|
+
recordTTSFailure(ttsProvider: string, error: NonNullable<TTSMetadata["error"]>, latency: number): void;
|
|
37
41
|
/**
|
|
38
42
|
* Estimate cost only from known per-model rates. Unknown pricing remains
|
|
39
43
|
* absent from cost metrics instead of using another model's default rate.
|
|
@@ -16,6 +16,7 @@ import { nanoid } from "nanoid";
|
|
|
16
16
|
import { extractTokenUsage } from "../../utils/tokenUtils.js";
|
|
17
17
|
import { logger } from "../../utils/logger.js";
|
|
18
18
|
import { recordProviderPerformanceFromMetrics } from "../evaluationProviders.js";
|
|
19
|
+
import { recordAIRequest, recordCustomHistogram, recordCustomMetric, } from "../../telemetry/aiMetrics.js";
|
|
19
20
|
import { calculateCost, hasPricing } from "../../utils/pricing.js";
|
|
20
21
|
/**
|
|
21
22
|
* TelemetryHandler class - Handles analytics and telemetry for AI providers
|
|
@@ -84,6 +85,7 @@ export class TelemetryHandler {
|
|
|
84
85
|
cost: actualCost,
|
|
85
86
|
success: true,
|
|
86
87
|
});
|
|
88
|
+
recordAIRequest(this.providerName, this.modelName, totalTokens, responseTime, actualCost !== undefined && actualCost > 0 ? actualCost : undefined);
|
|
87
89
|
logger.debug(`Performance recorded for ${this.providerName}`, {
|
|
88
90
|
responseTime: `${responseTime}ms`,
|
|
89
91
|
tokens: totalTokens,
|
|
@@ -94,6 +96,20 @@ export class TelemetryHandler {
|
|
|
94
96
|
logger.warn("⚠️ Performance recording failed:", perfError);
|
|
95
97
|
}
|
|
96
98
|
}
|
|
99
|
+
/**
|
|
100
|
+
* Record a TTS synthesis failure without affecting the generation result.
|
|
101
|
+
*/
|
|
102
|
+
recordTTSFailure(ttsProvider, error, latency) {
|
|
103
|
+
const labels = {
|
|
104
|
+
provider: ttsProvider,
|
|
105
|
+
error_code: error.code,
|
|
106
|
+
...(error.retriable !== undefined
|
|
107
|
+
? { retriable: error.retriable.toString() }
|
|
108
|
+
: {}),
|
|
109
|
+
};
|
|
110
|
+
recordCustomMetric("tts_failures", 1, labels);
|
|
111
|
+
recordCustomHistogram("tts_failure_latency_ms", latency, labels);
|
|
112
|
+
}
|
|
97
113
|
/**
|
|
98
114
|
* Estimate cost only from known per-model rates. Unknown pricing remains
|
|
99
115
|
* absent from cost metrics instead of using another model's default rate.
|
|
@@ -512,6 +512,14 @@ export class ToolsManager {
|
|
|
512
512
|
const idField = typeof toolCallId === "string" && toolCallId.length > 0
|
|
513
513
|
? { toolCallId }
|
|
514
514
|
: {};
|
|
515
|
+
// Same per-invocation id executeTool gives registered tools, so a
|
|
516
|
+
// listener can pair this start with its end.
|
|
517
|
+
const eventIds = {
|
|
518
|
+
...idField,
|
|
519
|
+
executionId: `${toolName}-${Date.now()}-${Math.random()
|
|
520
|
+
.toString(36)
|
|
521
|
+
.slice(2, 11)}`,
|
|
522
|
+
};
|
|
515
523
|
return withSpan({
|
|
516
524
|
name: toolSpanName(toolName),
|
|
517
525
|
tracer: tracers.mcp,
|
|
@@ -521,7 +529,7 @@ export class ToolsManager {
|
|
|
521
529
|
const startTime = Date.now();
|
|
522
530
|
this.emitToolEvent("tool:start", toolName, {
|
|
523
531
|
input: params,
|
|
524
|
-
...
|
|
532
|
+
...eventIds,
|
|
525
533
|
});
|
|
526
534
|
let result;
|
|
527
535
|
try {
|
|
@@ -533,7 +541,7 @@ export class ToolsManager {
|
|
|
533
541
|
error: errorMsg,
|
|
534
542
|
success: false,
|
|
535
543
|
responseTime: Date.now() - startTime,
|
|
536
|
-
...
|
|
544
|
+
...eventIds,
|
|
537
545
|
});
|
|
538
546
|
throw error;
|
|
539
547
|
}
|
|
@@ -549,7 +557,7 @@ export class ToolsManager {
|
|
|
549
557
|
success: isErrorText === undefined,
|
|
550
558
|
...(isErrorText !== undefined ? { error: isErrorText } : {}),
|
|
551
559
|
responseTime: Date.now() - startTime,
|
|
552
|
-
...
|
|
560
|
+
...eventIds,
|
|
553
561
|
});
|
|
554
562
|
return result;
|
|
555
563
|
});
|
|
@@ -860,6 +868,9 @@ export class ToolsManager {
|
|
|
860
868
|
attributes: {
|
|
861
869
|
"tool.name": toolName,
|
|
862
870
|
"tool.type": "custom",
|
|
871
|
+
// Pure wrapper around the `tool` observation of the same
|
|
872
|
+
// call: it stays an OpenTelemetry span but is not exported.
|
|
873
|
+
[ATTR.NL_INTERNAL]: true,
|
|
863
874
|
},
|
|
864
875
|
});
|
|
865
876
|
const startTime = Date.now();
|
|
@@ -84,6 +84,8 @@ export declare const toPrepareStepRecord: (step: {
|
|
|
84
84
|
export declare const hasNativeDoGenerate: (value: unknown) => value is {
|
|
85
85
|
doGenerate: (options: Record<string, unknown>) => Promise<Record<string, unknown>>;
|
|
86
86
|
};
|
|
87
|
+
/** A tool's inputSchema with its lazy (function) form, if any, resolved. */
|
|
88
|
+
export declare const resolveToolInputSchema: (schema: Tool["inputSchema"]) => Exclude<Tool["inputSchema"], (...args: never[]) => unknown>;
|
|
87
89
|
/**
|
|
88
90
|
* Spell a JSON Schema into the conversation's system turn.
|
|
89
91
|
*
|