@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.
Files changed (125) hide show
  1. package/CHANGELOG.md +2 -2
  2. package/dist/adapters/video/videoAnalyzer.d.ts +1 -0
  3. package/dist/adapters/video/videoAnalyzer.js +4 -0
  4. package/dist/autoresearch/index.d.ts +1 -0
  5. package/dist/autoresearch/index.js +4 -0
  6. package/dist/autoresearch/worker.js +4 -0
  7. package/dist/browser/neurolink.min.js +476 -476
  8. package/dist/cli/commands/observability.js +11 -4
  9. package/dist/cli/commands/telemetry.js +36 -6
  10. package/dist/cli/utils/formatters.d.ts +17 -0
  11. package/dist/cli/utils/formatters.js +39 -0
  12. package/dist/constants/langfuse.d.ts +17 -1
  13. package/dist/constants/langfuse.js +23 -1
  14. package/dist/constants/telemetry.d.ts +6 -0
  15. package/dist/constants/telemetry.js +6 -0
  16. package/dist/core/baseProvider.d.ts +3 -1
  17. package/dist/core/baseProvider.js +30 -8
  18. package/dist/core/geminiLoopAdapter.js +19 -0
  19. package/dist/core/loopEngine.js +10 -7
  20. package/dist/core/modules/StreamHandler.js +5 -4
  21. package/dist/core/modules/TelemetryHandler.d.ts +5 -1
  22. package/dist/core/modules/TelemetryHandler.js +16 -0
  23. package/dist/core/modules/ToolsManager.js +14 -3
  24. package/dist/core/nativeGenerateLoop.d.ts +2 -0
  25. package/dist/core/nativeGenerateLoop.js +35 -9
  26. package/dist/evaluation/index.d.ts +1 -0
  27. package/dist/evaluation/index.js +4 -0
  28. package/dist/evaluation/ragasEvaluator.js +1 -0
  29. package/dist/mcp/externalServerManager.js +6 -2
  30. package/dist/mcp/toolRegistry.js +3 -0
  31. package/dist/neurolink.d.ts +16 -8
  32. package/dist/neurolink.js +115 -61
  33. package/dist/observability/FEATURE-STATUS.md +0 -4
  34. package/dist/observability/exporterRegistry.d.ts +6 -1
  35. package/dist/observability/exporterRegistry.js +18 -0
  36. package/dist/observability/exporterSpanProcessor.d.ts +20 -6
  37. package/dist/observability/exporterSpanProcessor.js +166 -34
  38. package/dist/observability/exporters/baseExporter.d.ts +22 -3
  39. package/dist/observability/exporters/baseExporter.js +87 -5
  40. package/dist/observability/exporters/langfuseExporter.d.ts +29 -3
  41. package/dist/observability/exporters/langfuseExporter.js +251 -41
  42. package/dist/observability/langfuseMasking.d.ts +1 -1
  43. package/dist/observability/langfuseMasking.js +1 -1
  44. package/dist/observability/otlpEncoding.d.ts +18 -5
  45. package/dist/observability/otlpEncoding.js +69 -12
  46. package/dist/observability/utils/spanSerializer.d.ts +3 -2
  47. package/dist/observability/utils/spanSerializer.js +5 -3
  48. package/dist/processors/base/BaseFileProcessor.d.ts +1 -0
  49. package/dist/processors/base/BaseFileProcessor.js +4 -0
  50. package/dist/processors/index.d.ts +1 -0
  51. package/dist/processors/index.js +4 -0
  52. package/dist/processors/registry/ProcessorRegistry.d.ts +1 -0
  53. package/dist/processors/registry/ProcessorRegistry.js +5 -0
  54. package/dist/providers/amazonBedrock/client.js +7 -5
  55. package/dist/providers/amazonSagemaker.js +32 -12
  56. package/dist/providers/anthropic/client.js +1 -0
  57. package/dist/providers/anthropic/loopAdapter.js +7 -3
  58. package/dist/providers/googleAiStudio/client.js +2 -0
  59. package/dist/providers/googleVertex/client.d.ts +6 -5
  60. package/dist/providers/googleVertex/client.js +46 -19
  61. package/dist/providers/openaiChatCompletionsBase.js +29 -18
  62. package/dist/proxy/codexUsage.js +59 -2
  63. package/dist/proxy/proxyTracer.d.ts +25 -1
  64. package/dist/proxy/proxyTracer.js +146 -22
  65. package/dist/proxy/proxyTranslationEngine.js +4 -2
  66. package/dist/proxy/sseInterceptor.js +6 -4
  67. package/dist/rag/chunkers/BaseChunker.js +1 -0
  68. package/dist/rag/index.d.ts +1 -0
  69. package/dist/rag/index.js +4 -0
  70. package/dist/rag/ragIntegration.js +1 -0
  71. package/dist/rag/reranker/reranker.js +2 -0
  72. package/dist/rag/retrieval/vectorQueryTool.js +1 -0
  73. package/dist/server/routes/codexProxyRoutes.js +3 -1
  74. package/dist/server/utils/validation.d.ts +3 -3
  75. package/dist/services/server/ai/observability/instrumentation.d.ts +8 -7
  76. package/dist/services/server/ai/observability/instrumentation.js +217 -47
  77. package/dist/services/server/ai/observability/langfuseSetup.d.ts +19 -1
  78. package/dist/services/server/ai/observability/langfuseSetup.js +62 -4
  79. package/dist/services/server/ai/observability/otlpSetup.d.ts +12 -2
  80. package/dist/services/server/ai/observability/otlpSetup.js +55 -4
  81. package/dist/telemetry/aiMetrics.d.ts +56 -0
  82. package/dist/telemetry/aiMetrics.js +229 -0
  83. package/dist/telemetry/attributes.d.ts +24 -1
  84. package/dist/telemetry/attributes.js +94 -9
  85. package/dist/telemetry/chatMessages.d.ts +6 -0
  86. package/dist/telemetry/chatMessages.js +37 -0
  87. package/dist/telemetry/index.d.ts +1 -1
  88. package/dist/telemetry/index.js +1 -1
  89. package/dist/telemetry/observation.d.ts +5 -5
  90. package/dist/telemetry/observation.js +128 -39
  91. package/dist/telemetry/spanSanitizer.d.ts +2 -2
  92. package/dist/telemetry/spanSanitizer.js +76 -8
  93. package/dist/telemetry/telemetryStarter.d.ts +16 -0
  94. package/dist/telemetry/telemetryStarter.js +28 -0
  95. package/dist/telemetry/traceContext.d.ts +3 -3
  96. package/dist/telemetry/traceContext.js +3 -3
  97. package/dist/telemetry/tracers.d.ts +13 -1
  98. package/dist/telemetry/tracers.js +49 -10
  99. package/dist/telemetry/withSpan.d.ts +19 -3
  100. package/dist/telemetry/withSpan.js +149 -20
  101. package/dist/tools/toolDiscovery.js +2 -1
  102. package/dist/tools/toolPolicy.js +22 -2
  103. package/dist/types/config.d.ts +19 -0
  104. package/dist/types/exporter.d.ts +98 -3
  105. package/dist/types/generate.d.ts +7 -1
  106. package/dist/types/livekit.d.ts +16 -1
  107. package/dist/types/loopEngine.d.ts +16 -1
  108. package/dist/types/observability.d.ts +38 -5
  109. package/dist/types/proxy.d.ts +2 -0
  110. package/dist/types/span.d.ts +26 -0
  111. package/dist/types/toolResolution.d.ts +5 -1
  112. package/dist/utils/timeout.js +4 -2
  113. package/dist/voice/livekit/realtimeMcpTools.js +11 -2
  114. package/dist/voice/livekit/realtimeMemory.d.ts +20 -2
  115. package/dist/voice/livekit/realtimeMemory.js +139 -46
  116. package/dist/voice/livekit/realtimeVoiceAgent.js +20 -8
  117. package/dist/voice/livekit/turnRecorder.js +22 -9
  118. package/dist/workflow/core/ensembleExecutor.js +2 -0
  119. package/dist/workflow/core/judgeScorer.js +1 -0
  120. package/dist/workflow/core/responseConditioner.js +1 -0
  121. package/dist/workflow/core/workflowRunner.js +6 -1
  122. package/dist/workflow/index.d.ts +1 -0
  123. package/dist/workflow/index.js +4 -0
  124. package/docs-site/static/search-index.json +32 -27
  125. package/package.json +21 -2
@@ -1,23 +1,81 @@
1
- import { DEFAULT_BASE_URL, DEFAULT_FLUSH_AT, DEFAULT_FLUSH_INTERVAL_MS, DEFAULT_TIMEOUT_MS, HEALTH_PATH, TRACES_PATH, } from "../../constants/langfuse.js";
1
+ import { DEFAULT_BASE_URL, DEFAULT_FLUSH_AT, DEFAULT_FLUSH_BUDGET_MS, DEFAULT_FLUSH_INTERVAL_MS, DEFAULT_TIMEOUT_MS, HEALTH_PATH, MAX_CONCURRENT_DELIVERIES, MAX_EXPORT_RETRY_AFTER_MS, MAX_QUEUED_SPANS, MAX_SPANS_PER_REQUEST, QUEUE_DROP_WARN_INTERVAL_MS, TRACES_PATH, } from "../../constants/langfuse.js";
2
2
  import { logger } from "../../utils/logger.js";
3
- import { chunkOtlpSpans, toOtlpAttributes, toOtlpSpan, } from "../otlpEncoding.js";
4
- import { BaseExporter } from "./baseExporter.js";
3
+ import { parseRetryAfterMs } from "../../utils/retryAfter.js";
4
+ import { chunkOtlpSpans, toOtlpEncodedSpan, toOtlpRequest, } from "../otlpEncoding.js";
5
+ import { BaseExporter, exponentialRetryDelayMs, exporterStatusOf, reportDeliveriesWith, } from "./baseExporter.js";
5
6
  let credentialsWarned = false;
7
+ let malformedRejectionLogged = false;
6
8
  function isRetryable(status) {
7
9
  return status === 429 || status >= 500;
8
10
  }
11
+ function messageOf(error) {
12
+ return error instanceof Error ? error.message : String(error);
13
+ }
14
+ function retryAfterOf(error) {
15
+ return error instanceof Error &&
16
+ "retryAfterMs" in error &&
17
+ typeof error.retryAfterMs === "number"
18
+ ? error.retryAfterMs
19
+ : undefined;
20
+ }
21
+ // A flush or shutdown still ends at its budget: the delivery signal cuts the
22
+ // wait short.
23
+ function retryDelayFor(error, attempt) {
24
+ const requested = retryAfterOf(error);
25
+ return requested === undefined
26
+ ? exponentialRetryDelayMs(error, attempt)
27
+ : Math.min(requested, MAX_EXPORT_RETRY_AFTER_MS);
28
+ }
29
+ function partialSuccessOf(body) {
30
+ return typeof body === "object" &&
31
+ body !== null &&
32
+ "partialSuccess" in body &&
33
+ typeof body.partialSuccess === "object" &&
34
+ body.partialSuccess !== null
35
+ ? body.partialSuccess
36
+ : undefined;
37
+ }
38
+ // A count Langfuse cannot have meant (not a whole number, negative, or more
39
+ // than was sent) is read as none, so it neither hides earlier drops nor
40
+ // invents new ones.
41
+ function rejectedCount(partial, sent) {
42
+ const raw = partial && "rejectedSpans" in partial ? partial.rejectedSpans : undefined;
43
+ if (raw === undefined) {
44
+ return 0;
45
+ }
46
+ const count = typeof raw === "number" || typeof raw === "string"
47
+ ? Number(raw)
48
+ : Number.NaN;
49
+ if (Number.isInteger(count) && count >= 0 && count <= sent) {
50
+ return count;
51
+ }
52
+ if (!malformedRejectionLogged) {
53
+ malformedRejectionLogged = true;
54
+ logger.debug("[langfuse] Ignoring a partialSuccess.rejectedSpans that is not a count of the spans sent", { sent });
55
+ }
56
+ return 0;
57
+ }
9
58
  /**
10
- * @description Exporter that delivers observation spans to Langfuse's OTLP/HTTP JSON endpoint (`POST /api/public/otel/v1/traces`), which keeps native observation types and identity on every observation. Spans are queued without blocking and sent in batches; failed requests are retried with backoff when the failure is transient (429, 5xx, network).
59
+ * @description Exporter that delivers observation spans to Langfuse's OTLP/HTTP JSON endpoint (`POST /api/public/otel/v1/traces`), which keeps native observation types and identity on every observation. Spans are queued without blocking and sent in batches, with at most `MAX_CONCURRENT_DELIVERIES` requests in flight; spans that end while every one is busy wait, at most `MAX_QUEUED_SPANS` of them, and the oldest beyond that are dropped, counted and warned about once a minute. Failed requests are retried with backoff when the failure is transient (429, 5xx, network), after the `Retry-After` of a 429 or 503 when it names one (at most `MAX_EXPORT_RETRY_AFTER_MS`). `flush()` and `shutdown()` wait at most `flushBudgetMs` and then drop what is still in flight, and a batch that could not be delivered is reported by `exportBatch`, logged at warn level and counted in the health check; when Langfuse took none of the latest batch (refused, failed or every span rejected), `getStatus()` and `healthCheck()` read unhealthy with the dropped count until a batch is accepted again. `exportSpan` reports only the buffer step, so a failed delivery never opens the registry's circuit breaker and spans that end after an outage are still buffered and sent.
11
60
  */
12
61
  export class LangfuseExporter extends BaseExporter {
13
62
  langfuse;
14
63
  authorization;
15
64
  baseUrl;
16
65
  policy;
66
+ flushBudgetMs;
17
67
  inflight = new Set();
68
+ delivery = new AbortController();
69
+ droppedSpans = 0;
70
+ // Counted when Langfuse accepts a batch; the base counter also counts a span
71
+ // queued by exportSpan, which is not a delivery.
72
+ acceptedSpans = 0;
73
+ lastDeliveryError;
74
+ unwarnedQueueDrops = 0;
75
+ lastQueueDropWarning = 0;
18
76
  /**
19
77
  * @description Creates an exporter for one Langfuse project.
20
- * @param config {LangfuseExporterConfig} - Credentials, endpoint, environment, release, masking and batching options. `environment` and `release` fall back to `LANGFUSE_TRACING_ENVIRONMENT` and `LANGFUSE_RELEASE`, and only fill spans that do not carry them already.
78
+ * @param config {LangfuseExporterConfig} - Credentials, endpoint, environment, release, masking, batching and flush-budget options. `environment` and `release` fall back to `LANGFUSE_TRACING_ENVIRONMENT` and `LANGFUSE_RELEASE`, and only fill spans that do not carry them already.
21
79
  */
22
80
  constructor(langfuse) {
23
81
  super("langfuse", {
@@ -27,12 +85,21 @@ export class LangfuseExporter extends BaseExporter {
27
85
  this.langfuse = langfuse;
28
86
  this.authorization = `Basic ${Buffer.from(`${langfuse.publicKey}:${langfuse.secretKey}`).toString("base64")}`;
29
87
  this.baseUrl = (langfuse.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
88
+ this.flushBudgetMs =
89
+ langfuse.flushBudgetMs !== undefined && langfuse.flushBudgetMs > 0
90
+ ? langfuse.flushBudgetMs
91
+ : DEFAULT_FLUSH_BUDGET_MS;
30
92
  this.policy = {
31
93
  environment: langfuse.environment ?? process.env.LANGFUSE_TRACING_ENVIRONMENT,
32
94
  release: langfuse.release ?? process.env.LANGFUSE_RELEASE,
33
95
  redactIO: langfuse.redactIO ?? false,
34
96
  mask: langfuse.mask,
35
97
  };
98
+ reportDeliveriesWith(this, () => ({
99
+ delivered: this.acceptedSpans,
100
+ failed: this.droppedSpans,
101
+ lastError: this.lastDeliveryError,
102
+ }));
36
103
  }
37
104
  async initialize() {
38
105
  if (this.initialized) {
@@ -42,8 +109,10 @@ export class LangfuseExporter extends BaseExporter {
42
109
  throw new Error("Langfuse publicKey and secretKey are required");
43
110
  }
44
111
  this.initialized = true;
45
- this.startFlushInterval(this.config.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS);
46
- this.flushInterval?.unref();
112
+ // Periodic and size-triggered sends are not subject to the flush budget:
113
+ // only a caller that waits for delivery is.
114
+ this.flushInterval = setInterval(() => this.dispatch(), this.config.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS);
115
+ this.flushInterval.unref();
47
116
  }
48
117
  async exportSpan(span) {
49
118
  this.bufferSpan(span);
@@ -51,41 +120,132 @@ export class LangfuseExporter extends BaseExporter {
51
120
  }
52
121
  async exportBatch(spans) {
53
122
  const startTime = Date.now();
54
- await this.deliver(spans.map((span) => toOtlpSpan(span, this.policy)));
55
- return this.createSuccessResult(spans.length, Date.now() - startTime);
123
+ const report = await this.deliver(spans.map((span) => toOtlpEncodedSpan(span, this.policy)), this.delivery.signal);
124
+ return this.record(spans.length, report, Date.now() - startTime);
56
125
  }
126
+ /**
127
+ * @description Sends everything buffered and waits for delivery, but no longer than the flush budget; spans still in flight when it runs out are dropped and counted, and spans still waiting for a delivery slot stay buffered.
128
+ * @returns {Promise<void>} Resolves when delivery finished or the budget ran out.
129
+ */
57
130
  async flush() {
58
- const spans = this.buffer;
59
- this.buffer = [];
60
- if (spans.length > 0) {
131
+ await this.settle();
132
+ }
133
+ bufferSpan(span) {
134
+ this.buffer.push(span);
135
+ if (this.buffer.length >= this.maxBufferSize ||
136
+ this.buffer.length > MAX_QUEUED_SPANS) {
137
+ this.dispatch();
138
+ }
139
+ if (this.buffer.length > MAX_QUEUED_SPANS) {
140
+ this.dropOldestWaiting(this.buffer.length - MAX_QUEUED_SPANS);
141
+ }
142
+ }
143
+ dispatch() {
144
+ while (this.buffer.length > 0 &&
145
+ this.inflight.size < MAX_CONCURRENT_DELIVERIES) {
146
+ const spans = this.buffer.slice(0, MAX_SPANS_PER_REQUEST);
147
+ this.buffer = this.buffer.slice(spans.length);
61
148
  const pending = this.exportQuietly(spans);
62
149
  this.inflight.add(pending);
63
150
  void pending.finally(() => this.inflight.delete(pending));
64
151
  }
65
- await Promise.all(this.inflight);
152
+ }
153
+ // Langfuse is not taking spans as fast as they end. Spans keep being
154
+ // accepted, so the oldest waiting ones make room; no breaker is involved.
155
+ dropOldestWaiting(count) {
156
+ this.buffer = this.buffer.slice(count);
157
+ this.droppedSpans += count;
158
+ this.unwarnedQueueDrops += count;
159
+ this.lastDeliveryError = `${MAX_CONCURRENT_DELIVERIES} deliveries in flight and ${MAX_QUEUED_SPANS} spans waiting; dropping the oldest`;
160
+ const now = Date.now();
161
+ if (now - this.lastQueueDropWarning >= QUEUE_DROP_WARN_INTERVAL_MS) {
162
+ logger.warn(`[langfuse] Langfuse is not keeping up; dropped the ${this.unwarnedQueueDrops} oldest waiting spans`);
163
+ this.lastQueueDropWarning = now;
164
+ this.unwarnedQueueDrops = 0;
165
+ }
166
+ }
167
+ // Deliveries free their slot for the next waiting spans until nothing is
168
+ // left or the budget runs out, so spans that end meanwhile are sent too.
169
+ async settle() {
170
+ const deadline = Date.now() + this.flushBudgetMs;
171
+ for (this.dispatch(); this.inflight.size > 0; this.dispatch()) {
172
+ if ((await this.nextDelivery(deadline)) === "expired") {
173
+ logger.warn(`[langfuse] Delivery did not finish within ${this.flushBudgetMs}ms; dropping what is still in flight`);
174
+ const abandoned = this.delivery;
175
+ this.delivery = new AbortController();
176
+ abandoned.abort(new Error("Langfuse flush budget exhausted"));
177
+ await Promise.all(this.inflight);
178
+ return;
179
+ }
180
+ }
181
+ }
182
+ async nextDelivery(deadline) {
183
+ let timer;
184
+ const expired = new Promise((resolve) => {
185
+ timer = setTimeout(() => resolve("expired"), Math.max(0, deadline - Date.now()));
186
+ });
187
+ const outcome = await Promise.race([
188
+ Promise.race(this.inflight).then(() => "delivered"),
189
+ expired,
190
+ ]);
191
+ clearTimeout(timer);
192
+ return outcome;
66
193
  }
67
194
  async exportQuietly(spans) {
68
195
  try {
69
196
  await this.exportBatch(spans);
70
197
  }
71
198
  catch (error) {
199
+ this.droppedSpans += spans.length;
200
+ this.lastDeliveryError = messageOf(error);
72
201
  logger.warn("[langfuse] Export failed", {
73
- error: error instanceof Error ? error.message : String(error),
202
+ error: this.lastDeliveryError,
74
203
  });
75
204
  }
76
205
  }
206
+ /**
207
+ * @description Delivers what is buffered, including spans that end while it drains, within the flush budget, then stops the exporter. Spans that could not be sent by then are dropped and counted, so none is left pending.
208
+ * @returns {Promise<void>} Resolves when the exporter has stopped.
209
+ */
77
210
  async shutdown() {
78
- await this.flush();
211
+ await this.settle();
79
212
  this.stopFlushInterval();
80
213
  this.initialized = false;
214
+ const stranded = this.buffer.length;
215
+ if (stranded > 0) {
216
+ this.buffer = [];
217
+ this.droppedSpans += stranded;
218
+ this.lastDeliveryError = "Langfuse exporter shut down before delivery";
219
+ logger.warn(`[langfuse] Dropped ${stranded} spans still waiting at shutdown`);
220
+ }
221
+ }
222
+ /**
223
+ * @description Snapshot of this exporter for status reporting, without a network call: what is queued, what Langfuse accepted and what was dropped, and unhealthy while the latest delivery failed.
224
+ * @returns {ExporterStatus} The exporter's current state.
225
+ */
226
+ getStatus() {
227
+ return exporterStatusOf(this);
81
228
  }
229
+ // Reachable is not enough: like getStatus(), the exporter reads unhealthy
230
+ // while its latest delivery failed, until Langfuse accepts a batch again.
82
231
  async healthCheck() {
232
+ const problems = [
233
+ ...(this.lastDeliveryError !== undefined ? [this.lastDeliveryError] : []),
234
+ ...(this.droppedSpans > 0
235
+ ? [
236
+ `${this.droppedSpans} ${this.droppedSpans === 1 ? "span" : "spans"} dropped since start`,
237
+ ]
238
+ : []),
239
+ ];
83
240
  try {
84
241
  await this.withRetry(() => this.ping(), "health check");
85
- return this.createHealthStatus(true);
242
+ return this.createHealthStatus(this.lastDeliveryError === undefined, problems.length > 0 ? problems : undefined);
86
243
  }
87
244
  catch {
88
- return this.createHealthStatus(false, ["Health check failed"]);
245
+ return this.createHealthStatus(false, [
246
+ "Health check failed",
247
+ ...problems,
248
+ ]);
89
249
  }
90
250
  }
91
251
  async ping() {
@@ -97,19 +257,67 @@ export class LangfuseExporter extends BaseExporter {
97
257
  throw new Error(`Langfuse API unreachable: ${response.status}`);
98
258
  }
99
259
  }
100
- async deliver(spans) {
101
- for (const chunk of chunkOtlpSpans(spans)) {
260
+ record(total, report, durationMs) {
261
+ if (total === 0) {
262
+ return this.createSuccessResult(0, durationMs);
263
+ }
264
+ const failed = report.droppedIds.length + report.rejected;
265
+ this.droppedSpans += failed;
266
+ // Langfuse took none of the batch, refused or rejected: that is a failed
267
+ // delivery even when the response was a 200 with partialSuccess.
268
+ this.lastDeliveryError =
269
+ report.delivered === 0 && failed > 0
270
+ ? `Dropped ${failed} of ${total} spans: ${report.error ?? "Langfuse rejected every span"}`
271
+ : report.error;
272
+ if (failed > 0) {
273
+ logger.warn(`[langfuse] Dropped ${failed} of ${total} spans`, {
274
+ error: report.error,
275
+ });
276
+ }
277
+ if (report.delivered > 0) {
278
+ this.lastExportTime = Date.now();
279
+ this.acceptedSpans += report.delivered;
280
+ }
281
+ return {
282
+ success: failed === 0,
283
+ exportedCount: report.delivered,
284
+ failedCount: failed,
285
+ ...(report.droppedIds.length > 0 && {
286
+ errors: report.droppedIds.map((spanId) => ({
287
+ spanId,
288
+ error: report.error ?? "Delivery failed",
289
+ retryable: true,
290
+ })),
291
+ }),
292
+ durationMs,
293
+ };
294
+ }
295
+ async deliver(items, signal) {
296
+ const report = {
297
+ delivered: 0,
298
+ rejected: 0,
299
+ droppedIds: [],
300
+ };
301
+ for (const chunk of chunkOtlpSpans(items)) {
102
302
  try {
103
- await this.withRetry(() => this.post(chunk), "export");
303
+ const outcome = await this.withRetry(() => this.post(chunk, signal), "export", signal, retryDelayFor);
304
+ if (outcome.accepted) {
305
+ report.delivered += chunk.length - outcome.rejected;
306
+ report.rejected += outcome.rejected;
307
+ }
308
+ else {
309
+ report.droppedIds.push(...chunk.map(({ span }) => span.spanId));
310
+ report.error = "Langfuse did not accept the batch";
311
+ }
104
312
  }
105
313
  catch (error) {
106
- logger.warn(`[langfuse] Dropped ${chunk.length} spans`, {
107
- error: error instanceof Error ? error.message : String(error),
108
- });
314
+ report.droppedIds.push(...chunk.map(({ span }) => span.spanId));
315
+ report.error = messageOf(error);
109
316
  }
110
317
  }
318
+ return report;
111
319
  }
112
- async post(spans) {
320
+ async post(chunk, signal) {
113
321
  const response = await fetch(`${this.baseUrl}${TRACES_PATH}`, {
114
322
  method: "POST",
115
323
  headers: {
@@ -119,38 +327,40 @@ export class LangfuseExporter extends BaseExporter {
119
327
  "x-langfuse-ingestion-version": "4",
120
328
  "x-langfuse-public-key": this.langfuse.publicKey,
121
329
  },
122
- body: JSON.stringify({
123
- resourceSpans: [
124
- {
125
- resource: {
126
- attributes: toOtlpAttributes({ "service.name": "service" }),
127
- },
128
- scopeSpans: [{ scope: { name: "sdk" }, spans }],
129
- },
130
- ],
131
- }),
132
- signal: AbortSignal.timeout(this.config.timeoutMs ?? DEFAULT_TIMEOUT_MS),
330
+ body: JSON.stringify(toOtlpRequest(chunk)),
331
+ signal: AbortSignal.any([
332
+ AbortSignal.timeout(this.config.timeoutMs ?? DEFAULT_TIMEOUT_MS),
333
+ signal,
334
+ ]),
133
335
  });
134
336
  if (response.status === 401 || response.status === 403) {
135
337
  if (!credentialsWarned) {
136
338
  credentialsWarned = true;
137
339
  logger.warn("[langfuse] Langfuse credentials rejected");
138
340
  }
139
- return;
341
+ return { accepted: false };
140
342
  }
141
343
  if (isRetryable(response.status)) {
142
- throw new Error(`Langfuse API error: ${response.status}`);
344
+ const retryAfterMs = response.status === 429 || response.status === 503
345
+ ? parseRetryAfterMs(response.headers)
346
+ : undefined;
347
+ throw Object.assign(new Error(`Langfuse API error: ${response.status}`), retryAfterMs === undefined ? {} : { retryAfterMs });
143
348
  }
144
349
  if (!response.ok) {
145
350
  logger.warn(`[langfuse] Export rejected with ${response.status}`);
146
- return;
351
+ return { accepted: false };
147
352
  }
148
- const body = (await response.json().catch(() => ({})));
149
- const rejected = Number(body.partialSuccess?.rejectedSpans ?? 0);
353
+ // Langfuse took the request; a body that is not an object, or not JSON,
354
+ // reports no rejections.
355
+ const partial = partialSuccessOf(await response.json().catch(() => undefined));
356
+ const rejected = rejectedCount(partial, chunk.length);
150
357
  if (rejected > 0) {
151
358
  logger.warn(`[langfuse] ${rejected} spans rejected`, {
152
- message: body.partialSuccess?.errorMessage,
359
+ message: partial && "errorMessage" in partial
360
+ ? partial.errorMessage
361
+ : undefined,
153
362
  });
154
363
  }
364
+ return { accepted: true, rejected };
155
365
  }
156
366
  }
@@ -1,6 +1,6 @@
1
1
  import type { LangfuseAttributePolicy, SpanAttributes } from "../types/index.js";
2
2
  /**
3
- * @description Applies the exporter's policy to a span's attributes: fills environment and release when the span has none, removes input and output when `redactIO` is set, and masks input, output and metadata when a mask is configured.
3
+ * @description Applies the Langfuse exporter's policy to a span's attributes as they are sent to Langfuse: fills environment and release when the span has none, removes input and output when `redactIO` is set, and masks input, output and metadata when a mask is configured. Nothing else is touched: other attributes, events and the status message go to Langfuse as they are, and no other exporter sees the policy.
4
4
  * @param attributes {SpanAttributes} - Attributes of the span being exported.
5
5
  * @param policy {LangfuseAttributePolicy} - Environment, release, redaction and mask settings.
6
6
  * @returns {SpanAttributes} A new attribute record; the input is not modified.
@@ -18,7 +18,7 @@ function maskValue(value, mask) {
18
18
  }
19
19
  }
20
20
  /**
21
- * @description Applies the exporter's policy to a span's attributes: fills environment and release when the span has none, removes input and output when `redactIO` is set, and masks input, output and metadata when a mask is configured.
21
+ * @description Applies the Langfuse exporter's policy to a span's attributes as they are sent to Langfuse: fills environment and release when the span has none, removes input and output when `redactIO` is set, and masks input, output and metadata when a mask is configured. Nothing else is touched: other attributes, events and the status message go to Langfuse as they are, and no other exporter sees the policy.
22
22
  * @param attributes {SpanAttributes} - Attributes of the span being exported.
23
23
  * @param policy {LangfuseAttributePolicy} - Environment, release, redaction and mask settings.
24
24
  * @returns {SpanAttributes} A new attribute record; the input is not modified.
@@ -1,4 +1,4 @@
1
- import type { LangfuseAttributePolicy, OtlpKeyValue, OtlpSpan, SpanAttributes, SpanData } from "../types/index.js";
1
+ import type { LangfuseAttributePolicy, OtlpEncodedSpan, OtlpKeyValue, OtlpSpan, OtlpTraceRequest, SpanAttributes, SpanData } from "../types/index.js";
2
2
  /**
3
3
  * @description Encodes attributes as OTLP/JSON key-values, dropping `undefined` and `null` entries.
4
4
  * @param attributes {SpanAttributes} - Attribute record to encode.
@@ -6,15 +6,28 @@ import type { LangfuseAttributePolicy, OtlpKeyValue, OtlpSpan, SpanAttributes, S
6
6
  */
7
7
  export declare function toOtlpAttributes(attributes: SpanAttributes): OtlpKeyValue[];
8
8
  /**
9
- * @description Encodes one span as OTLP/JSON, applying the exporter's attribute policy to its attributes.
9
+ * @description Encodes one span as OTLP/JSON, applying the exporter's attribute policy to its attributes (its events and status message are sent as they are), leaving GenAI model and usage keys off a plain span or event observation, and keeping the span's links and nanosecond times. Every span is sent as kind INTERNAL, as Langfuse has always received it.
10
10
  * @param span {SpanData} - Span to encode.
11
11
  * @param policy {LangfuseAttributePolicy} - Environment, release, redaction and mask settings.
12
12
  * @returns {OtlpSpan} The span in the shape Langfuse's ingestion endpoint reads.
13
13
  */
14
14
  export declare function toOtlpSpan(span: SpanData, policy: LangfuseAttributePolicy): OtlpSpan;
15
+ /**
16
+ * @description Encodes a span together with the resource it was produced under, falling back to a generic service name for a span that carries none, and the instrumentation scope `sdk` that the Langfuse request has always named.
17
+ * @param span {SpanData} - Span to encode.
18
+ * @param policy {LangfuseAttributePolicy} - Environment, release, redaction and mask settings.
19
+ * @returns {OtlpEncodedSpan} The encoded span with its resource and scope.
20
+ */
21
+ export declare function toOtlpEncodedSpan(span: SpanData, policy: LangfuseAttributePolicy): OtlpEncodedSpan;
22
+ /**
23
+ * @description Builds an OTLP/JSON export request, grouping spans under the resource and instrumentation scope they were produced under.
24
+ * @param items {OtlpEncodedSpan[]} - Encoded spans, in export order.
25
+ * @returns {OtlpTraceRequest} The request body, with one `resourceSpans` entry per distinct resource and one `scopeSpans` entry per distinct scope within it.
26
+ */
27
+ export declare function toOtlpRequest(items: OtlpEncodedSpan[]): OtlpTraceRequest;
15
28
  /**
16
29
  * @description Splits encoded spans into request-sized batches, bounded by span count and serialized bytes.
17
- * @param spans {OtlpSpan[]} - Encoded spans, in export order.
18
- * @returns {OtlpSpan[][]} Batches that each fit one export request; a single oversized span still gets its own batch.
30
+ * @param items {OtlpEncodedSpan[]} - Encoded spans, in export order.
31
+ * @returns {OtlpEncodedSpan[][]} Batches that each fit one export request; a single oversized span still gets its own batch.
19
32
  */
20
- export declare function chunkOtlpSpans(spans: OtlpSpan[]): OtlpSpan[][];
33
+ export declare function chunkOtlpSpans(items: OtlpEncodedSpan[]): OtlpEncodedSpan[][];
@@ -1,7 +1,10 @@
1
- import { MAX_BYTES_PER_REQUEST, MAX_SPANS_PER_REQUEST, OTLP_STATUS_CODES, } from "../constants/langfuse.js";
1
+ import { LangfuseOtelSpanAttributes } from "@langfuse/tracing";
2
+ import { MAX_BYTES_PER_REQUEST, MAX_SPANS_PER_REQUEST, MODEL_ATTRIBUTE_KEYS, OTLP_STATUS_CODES, UNPRICED_OBSERVATION_TYPES, USAGE_ATTRIBUTE_PREFIX, } from "../constants/langfuse.js";
2
3
  import { SpanStatus } from "../types/index.js";
3
4
  import { protectAttributes } from "./langfuseMasking.js";
4
5
  const SPAN_KIND_INTERNAL = 1;
6
+ const FALLBACK_SERVICE_NAME = "service";
7
+ const SCOPE_NAME = "sdk";
5
8
  function toNanos(iso) {
6
9
  return (BigInt(Date.parse(iso)) * 1000000n).toString();
7
10
  }
@@ -29,8 +32,18 @@ export function toOtlpAttributes(attributes) {
29
32
  .filter(([, value]) => value !== undefined && value !== null)
30
33
  .map(([key, value]) => ({ key, value: toOtlpValue(value) }));
31
34
  }
35
+ // The proxy root also carries gen_ai model and usage keys for collectors;
36
+ // Langfuse would price it a second time next to the request's generation.
37
+ function withoutUnpricedUsage(attributes) {
38
+ const type = attributes[LangfuseOtelSpanAttributes.OBSERVATION_TYPE];
39
+ if (typeof type !== "string" || !UNPRICED_OBSERVATION_TYPES.has(type)) {
40
+ return attributes;
41
+ }
42
+ return Object.fromEntries(Object.entries(attributes).filter(([key]) => !MODEL_ATTRIBUTE_KEYS.has(key) &&
43
+ !key.startsWith(USAGE_ATTRIBUTE_PREFIX)));
44
+ }
32
45
  /**
33
- * @description Encodes one span as OTLP/JSON, applying the exporter's attribute policy to its attributes.
46
+ * @description Encodes one span as OTLP/JSON, applying the exporter's attribute policy to its attributes (its events and status message are sent as they are), leaving GenAI model and usage keys off a plain span or event observation, and keeping the span's links and nanosecond times. Every span is sent as kind INTERNAL, as Langfuse has always received it.
34
47
  * @param span {SpanData} - Span to encode.
35
48
  * @param policy {LangfuseAttributePolicy} - Environment, release, redaction and mask settings.
36
49
  * @returns {OtlpSpan} The span in the shape Langfuse's ingestion endpoint reads.
@@ -43,31 +56,75 @@ export function toOtlpSpan(span, policy) {
43
56
  ...(span.parentSpanId && { parentSpanId: span.parentSpanId }),
44
57
  name: span.name,
45
58
  kind: SPAN_KIND_INTERNAL,
46
- startTimeUnixNano: toNanos(span.startTime),
47
- endTimeUnixNano: toNanos(span.endTime ?? span.startTime),
48
- attributes: toOtlpAttributes(protectAttributes(span.attributes, policy)),
59
+ startTimeUnixNano: span.startTimeUnixNano ?? toNanos(span.startTime),
60
+ endTimeUnixNano: span.endTimeUnixNano ?? toNanos(span.endTime ?? span.startTime),
61
+ attributes: toOtlpAttributes(protectAttributes(withoutUnpricedUsage(span.attributes), policy)),
49
62
  events: span.events.map((event) => ({
50
63
  name: event.name,
51
- timeUnixNano: toNanos(event.timestamp),
64
+ timeUnixNano: event.timestampUnixNano ?? toNanos(event.timestamp),
52
65
  attributes: toOtlpAttributes(event.attributes ?? {}),
53
66
  })),
67
+ links: span.links.map((link) => ({
68
+ traceId: link.traceId,
69
+ spanId: link.spanId,
70
+ attributes: toOtlpAttributes(link.attributes ?? {}),
71
+ })),
54
72
  status: {
55
73
  code: OTLP_STATUS_CODES[span.status],
56
74
  ...(failed && span.statusMessage && { message: span.statusMessage }),
57
75
  },
58
76
  };
59
77
  }
78
+ /**
79
+ * @description Encodes a span together with the resource it was produced under, falling back to a generic service name for a span that carries none, and the instrumentation scope `sdk` that the Langfuse request has always named.
80
+ * @param span {SpanData} - Span to encode.
81
+ * @param policy {LangfuseAttributePolicy} - Environment, release, redaction and mask settings.
82
+ * @returns {OtlpEncodedSpan} The encoded span with its resource and scope.
83
+ */
84
+ export function toOtlpEncodedSpan(span, policy) {
85
+ return {
86
+ span: toOtlpSpan(span, policy),
87
+ resource: toOtlpAttributes(span.resource ?? { "service.name": FALLBACK_SERVICE_NAME }),
88
+ scope: { name: SCOPE_NAME },
89
+ };
90
+ }
91
+ /**
92
+ * @description Builds an OTLP/JSON export request, grouping spans under the resource and instrumentation scope they were produced under.
93
+ * @param items {OtlpEncodedSpan[]} - Encoded spans, in export order.
94
+ * @returns {OtlpTraceRequest} The request body, with one `resourceSpans` entry per distinct resource and one `scopeSpans` entry per distinct scope within it.
95
+ */
96
+ export function toOtlpRequest(items) {
97
+ const resources = new Map();
98
+ for (const { span, resource, scope } of items) {
99
+ const resourceKey = JSON.stringify(resource);
100
+ const group = resources.get(resourceKey) ?? {
101
+ resource,
102
+ scopes: new Map(),
103
+ };
104
+ resources.set(resourceKey, group);
105
+ const scopeKey = `${scope.name}\u0000${scope.version ?? ""}`;
106
+ const scoped = group.scopes.get(scopeKey) ?? { scope, spans: [] };
107
+ group.scopes.set(scopeKey, scoped);
108
+ scoped.spans.push(span);
109
+ }
110
+ return {
111
+ resourceSpans: [...resources.values()].map((group) => ({
112
+ resource: { attributes: group.resource },
113
+ scopeSpans: [...group.scopes.values()],
114
+ })),
115
+ };
116
+ }
60
117
  /**
61
118
  * @description Splits encoded spans into request-sized batches, bounded by span count and serialized bytes.
62
- * @param spans {OtlpSpan[]} - Encoded spans, in export order.
63
- * @returns {OtlpSpan[][]} Batches that each fit one export request; a single oversized span still gets its own batch.
119
+ * @param items {OtlpEncodedSpan[]} - Encoded spans, in export order.
120
+ * @returns {OtlpEncodedSpan[][]} Batches that each fit one export request; a single oversized span still gets its own batch.
64
121
  */
65
- export function chunkOtlpSpans(spans) {
122
+ export function chunkOtlpSpans(items) {
66
123
  const chunks = [];
67
124
  let current = [];
68
125
  let bytes = 0;
69
- for (const span of spans) {
70
- const size = Buffer.byteLength(JSON.stringify(span));
126
+ for (const item of items) {
127
+ const size = Buffer.byteLength(JSON.stringify(item.span));
71
128
  if (current.length > 0 &&
72
129
  (current.length >= MAX_SPANS_PER_REQUEST ||
73
130
  bytes + size > MAX_BYTES_PER_REQUEST)) {
@@ -75,7 +132,7 @@ export function chunkOtlpSpans(spans) {
75
132
  current = [];
76
133
  bytes = 0;
77
134
  }
78
- current.push(span);
135
+ current.push(item);
79
136
  bytes += size;
80
137
  }
81
138
  if (current.length > 0) {
@@ -11,8 +11,9 @@ export declare class SpanSerializer {
11
11
  * Create a new span with generated IDs.
12
12
  *
13
13
  * When `traceId` / `parentSpanId` are omitted, the method automatically
14
- * attempts to inherit them from the active OTel context so that Pipeline B
15
- * spans land inside the same Langfuse trace as Pipeline A spans (fix A5).
14
+ * attempts to inherit them from the active OTel context so that these
15
+ * in-process span records carry the traceId of the exported OTEL trace they
16
+ * belong to (fix A5). The records themselves are never exported.
16
17
  */
17
18
  static createSpan(type: SpanType, name: string, attributes?: Partial<SpanAttributes>, parentSpanId?: string, traceId?: string): SpanData;
18
19
  /**
@@ -13,12 +13,14 @@ export class SpanSerializer {
13
13
  * Create a new span with generated IDs.
14
14
  *
15
15
  * When `traceId` / `parentSpanId` are omitted, the method automatically
16
- * attempts to inherit them from the active OTel context so that Pipeline B
17
- * spans land inside the same Langfuse trace as Pipeline A spans (fix A5).
16
+ * attempts to inherit them from the active OTel context so that these
17
+ * in-process span records carry the traceId of the exported OTEL trace they
18
+ * belong to (fix A5). The records themselves are never exported.
18
19
  */
19
20
  static createSpan(type, name, attributes = {}, parentSpanId, traceId) {
20
21
  // A5 fix: When no explicit traceId is provided, try to inherit from the
21
- // active OTel context so Pipeline B spans are linked to Pipeline A traces.
22
+ // active OTel context so these in-process records can be matched to the
23
+ // OTEL trace of the request.
22
24
  let resolvedTraceId = traceId;
23
25
  let resolvedParentSpanId = parentSpanId;
24
26
  if (!resolvedTraceId) {
@@ -43,6 +43,7 @@
43
43
  * }
44
44
  * ```
45
45
  */
46
+ import "../../services/server/ai/observability/instrumentation.js";
46
47
  import { FileErrorCode } from "../errors/index.js";
47
48
  import type { BatchProcessingSummary, FileInfo, FileProcessingError, ProcessorFileProcessingResult, FileProcessorConfig, ProcessorOperationResult, ProcessedFileBase, ProcessOptions } from "../../types/index.js";
48
49
  /**
@@ -49,6 +49,10 @@ import { SIZE_LIMITS } from "../config/index.js";
49
49
  import { isAbortError } from "../../utils/errorHandling.js";
50
50
  import { withSpan } from "../../telemetry/withSpan.js";
51
51
  import { tracers } from "../../telemetry/tracers.js";
52
+ // Starts tracing at the first span, as this module always did: the shared
53
+ // tracers only start what a loaded entry registered, and this path can be
54
+ // imported without the package entry. The client entry stays free of it.
55
+ import "../../services/server/ai/observability/instrumentation.js";
52
56
  import { createFileError, extractHttpStatus, FileErrorCode, isRetryableError, } from "../errors/index.js";
53
57
  import { DEFAULT_RETRY_CONFIG } from "../../types/index.js";
54
58
  const gunzipAsync = promisify(gunzip);
@@ -64,6 +64,7 @@
64
64
  * });
65
65
  * ```
66
66
  */
67
+ import "../services/server/ai/observability/instrumentation.js";
67
68
  export { BaseFileProcessor, DEFAULT_IMAGE_MAX_SIZE_MB, DEFAULT_IMAGE_TIMEOUT_MS, DEFAULT_RETRY_CONFIG, DEFAULT_TEXT_MAX_SIZE_MB, DEFAULT_TEXT_TIMEOUT_MS, getDefaultImageMaxSizeMB, getDefaultImageTimeout, getDefaultTextMaxSizeMB, getDefaultTextTimeout, PROCESSOR_PRIORITIES, } from "./base/index.js";
68
69
  export { AI_VISION_EXTENSIONS, ARCHIVE_EXTENSIONS, ARCHIVE_LIMITS, ARCHIVE_MIME_TYPES, AUDIO_EXTENSIONS, AUDIO_MIME_TYPES, bytesToMB, CONFIG_EXTENSIONS, CSV_EXTENSIONS, DATA_EXTENSIONS, DATA_MIME_TYPES, DATABASE_EXTENSIONS, DESIGN_EXTENSIONS, DOCUMENT_EXTENSIONS, DOCUMENT_MIME_TYPES, detectLanguageFromFilename, EXACT_FILENAME_MAP, EXCEL_EXTENSIONS, EXECUTABLE_EXTENSIONS, FILE_EXTENSIONS, formatBytes, getLanguageIdentifier, getSizeLimitForType, getSupportedExtensions, getSupportedFilenames, HTML_EXTENSIONS, IMAGE_EXTENSIONS, IMAGE_MIME_TYPES, isSourceCodeFile, isWithinSizeLimit, JSON_EXTENSIONS, LANGUAGE_MAP, MARKDOWN_EXTENSIONS, MIME_TYPES, mbToBytes, OPENDOCUMENT_EXTENSIONS, PDF_EXTENSIONS, POWERPOINT_EXTENSIONS, PROCESSING_LIMITS, RTF_EXTENSIONS, SIZE_LIMITS, SIZE_LIMITS_BYTES, SIZE_LIMITS_MB, SOURCE_CODE_EXTENSIONS, SOURCE_CODE_MIME_TYPES, TEXT_EXTENSIONS, TEXT_MIME_TYPES, VIDEO_EXTENSIONS, VIDEO_MIME_TYPES, validateFileSize, WORD_EXTENSIONS, XML_EXTENSIONS, YAML_EXTENSIONS, YAML_LIMITS, } from "./config/index.js";
69
70
  export { combineSummaries, createCustomFileError, createFileError, createProcessingSummary, ERROR_MESSAGES, extractHttpStatus, extractSafeMetadata, FileErrorCode, formatFileError, generateErrorFingerprint, getErrorTemplate, getRetryDelay, isFileProcessingError, isRetryableError, isRetryableErrorCode, mapErrorToCode, safeStringify, serializeError, summarizeError, } from "./errors/index.js";