@juspay/neurolink 12.58.1 → 13.0.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 +14 -3
- package/dist/auth/anthropicOAuth.js +3 -3
- package/dist/auth/middleware/AuthMiddleware.d.ts +1 -1
- package/dist/auth/sessionManager.js +4 -4
- package/dist/auth/tokenStore.js +4 -4
- package/dist/browser/neurolink.min.js +474 -473
- package/dist/cli/commands/observability.d.ts +1 -6
- package/dist/cli/commands/observability.js +0 -106
- package/dist/cli/commands/proxy.js +9 -18
- package/dist/cli/commands/telemetry.d.ts +3 -13
- package/dist/cli/commands/telemetry.js +7 -354
- package/dist/cli/loop/session.js +1 -1
- package/dist/client/httpClient.js +1 -1
- package/dist/client/sseClient.js +1 -1
- package/dist/client/streamingClient.js +2 -2
- package/dist/client/wsClient.js +1 -1
- package/dist/constants/langfuse.d.ts +25 -0
- package/dist/constants/langfuse.js +38 -0
- package/dist/constants/telemetry.d.ts +20 -0
- package/dist/constants/telemetry.js +34 -0
- package/dist/core/baseProvider.d.ts +2 -1
- package/dist/core/baseProvider.js +4 -11
- package/dist/core/conversationMemoryManager.js +5 -8
- package/dist/core/factory.js +1 -1
- package/dist/core/geminiLoopAdapter.d.ts +8 -1
- package/dist/core/geminiLoopAdapter.js +25 -1
- package/dist/core/loopEngine.js +69 -40
- package/dist/core/modules/MessageBuilder.js +2 -2
- package/dist/core/modules/StreamHandler.js +2 -2
- package/dist/core/modules/TelemetryHandler.d.ts +2 -18
- package/dist/core/modules/TelemetryHandler.js +1 -66
- package/dist/core/modules/ToolsManager.js +40 -31
- package/dist/core/nativeGenerateLoop.js +78 -8
- package/dist/core/redisConversationMemoryManager.js +8 -15
- package/dist/core/toolExecutionGuards.js +1 -13
- package/dist/index.d.ts +5 -12
- package/dist/index.js +5 -37
- package/dist/mcp/batching/requestBatcher.js +1 -1
- package/dist/mcp/externalServerManager.js +3 -21
- package/dist/mcp/toolDiscoveryService.js +4 -16
- package/dist/mcp/toolRegistry.js +2 -5
- package/dist/memory/memoryRetrievalTools.js +1 -1
- package/dist/neurolink.d.ts +16 -27
- package/dist/neurolink.js +788 -792
- package/dist/observability/exporterSpanProcessor.d.ts +50 -0
- package/dist/observability/exporterSpanProcessor.js +181 -0
- package/dist/observability/exporters/langfuseExporter.d.ts +13 -34
- package/dist/observability/exporters/langfuseExporter.js +101 -154
- package/dist/observability/index.d.ts +1 -0
- package/dist/observability/index.js +1 -0
- package/dist/observability/langfuseMasking.d.ts +8 -0
- package/dist/observability/langfuseMasking.js +47 -0
- package/dist/observability/otlpEncoding.d.ts +20 -0
- package/dist/observability/otlpEncoding.js +85 -0
- package/dist/providers/amazonBedrock/client.js +142 -176
- package/dist/providers/amazonBedrock/loopAdapter.js +14 -1
- package/dist/providers/amazonSagemaker.js +118 -80
- package/dist/providers/anthropic/client.js +3 -52
- package/dist/providers/anthropic/loopAdapter.js +24 -1
- package/dist/providers/anthropic/rateLimitCapture.js +9 -12
- package/dist/providers/googleAiStudio/client.d.ts +4 -5
- package/dist/providers/googleAiStudio/client.js +38 -40
- package/dist/providers/googleVertex/client.d.ts +2 -7
- package/dist/providers/googleVertex/client.js +282 -378
- package/dist/providers/googleVertex/constants.js +2 -2
- package/dist/providers/litellm/client.d.ts +2 -9
- package/dist/providers/litellm/client.js +1 -63
- package/dist/providers/openAI/client.d.ts +1 -8
- package/dist/providers/openAI/client.js +0 -65
- package/dist/providers/openaiChatCompletionsBase.d.ts +1 -0
- package/dist/providers/openaiChatCompletionsBase.js +61 -4
- package/dist/providers/replicate.js +22 -4
- package/dist/proxy/codexUsage.js +7 -0
- package/dist/proxy/proxyFetch.js +19 -25
- package/dist/proxy/proxyTracer.d.ts +14 -7
- package/dist/proxy/proxyTracer.js +129 -83
- package/dist/proxy/proxyTranslationEngine.js +0 -1
- package/dist/proxy/requestLogger.d.ts +1 -1
- package/dist/proxy/requestLogger.js +39 -45
- package/dist/server/middleware/common.js +1 -1
- package/dist/server/routes/agentRoutes.js +4 -4
- package/dist/server/routes/claudeProxyRoutes.js +99 -196
- package/dist/server/routes/codexProxyRoutes.js +621 -591
- package/dist/server/routes/healthRoutes.js +6 -6
- package/dist/server/routes/mcpRoutes.js +7 -7
- package/dist/server/routes/memoryRoutes.js +7 -7
- package/dist/server/routes/openApiRoutes.js +3 -3
- package/dist/server/routes/toolRoutes.js +5 -5
- package/dist/server/routes/transcribeRoutes.js +1 -1
- package/dist/server/utils/validation.d.ts +3 -3
- package/dist/services/server/ai/observability/instrumentation.d.ts +34 -201
- package/dist/services/server/ai/observability/instrumentation.js +220 -1267
- package/dist/services/server/ai/observability/langfuseSetup.d.ts +14 -0
- package/dist/services/server/ai/observability/langfuseSetup.js +55 -0
- package/dist/services/server/ai/observability/otlpSetup.d.ts +16 -0
- package/dist/services/server/ai/observability/otlpSetup.js +58 -0
- package/dist/session/globalSessionState.js +0 -11
- package/dist/telemetry/attributes.d.ts +23 -46
- package/dist/telemetry/attributes.js +76 -162
- package/dist/telemetry/chatMessages.d.ts +13 -0
- package/dist/telemetry/chatMessages.js +59 -0
- package/dist/telemetry/index.d.ts +5 -19
- package/dist/telemetry/index.js +5 -26
- package/dist/telemetry/observation.d.ts +37 -0
- package/dist/telemetry/observation.js +112 -0
- package/dist/telemetry/observationNames.d.ts +18 -0
- package/dist/telemetry/observationNames.js +24 -0
- package/dist/telemetry/spanSanitizer.d.ts +39 -0
- package/dist/telemetry/spanSanitizer.js +92 -0
- package/dist/telemetry/spanSerialization.d.ts +13 -0
- package/dist/telemetry/spanSerialization.js +46 -0
- package/dist/telemetry/tracers.d.ts +37 -18
- package/dist/telemetry/tracers.js +97 -19
- package/dist/telemetry/withSpan.d.ts +47 -25
- package/dist/telemetry/withSpan.js +158 -71
- package/dist/types/cli.d.ts +0 -11
- package/dist/types/decision.d.ts +3 -0
- package/dist/types/exporter.d.ts +54 -0
- package/dist/types/exporter.js +0 -4
- package/dist/types/generate.d.ts +4 -1
- package/dist/types/isolatedAgent.d.ts +5 -4
- package/dist/types/loopEngine.d.ts +18 -28
- package/dist/types/observability.d.ts +127 -160
- package/dist/types/providers.d.ts +15 -0
- package/dist/types/proxy.d.ts +41 -2
- package/dist/utils/anthropicTraceSanitizer.d.ts +2 -2
- package/dist/utils/anthropicTraceSanitizer.js +3 -1
- package/dist/utils/conversationMemory.js +4 -4
- package/dist/utils/fileDetector.js +1 -1
- package/dist/utils/messageBuilder.js +1 -1
- package/dist/utils/noOutputSentinel.d.ts +4 -16
- package/dist/utils/noOutputSentinel.js +12 -28
- package/dist/utils/providerRetry.js +4 -4
- package/dist/voice/livekit/brain.js +1 -1
- package/dist/voice/livekit/realtimeMemory.d.ts +1 -1
- package/dist/voice/livekit/realtimeMemory.js +66 -1
- package/dist/voice/livekit/realtimeVoiceAgent.js +1 -1
- package/docs-site/static/search-index.json +42 -63
- package/package.json +10 -4
- package/dist/telemetry/telemetryService.d.ts +0 -79
- package/dist/telemetry/telemetryService.js +0 -572
- package/dist/utils/observabilityHelpers.d.ts +0 -27
- package/dist/utils/observabilityHelpers.js +0 -47
|
@@ -1,1332 +1,285 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
1
|
+
import { propagateAttributes, setLangfuseTracerProvider, } from "@langfuse/tracing";
|
|
2
|
+
import { context, createContextKey, metrics, ProxyTracerProvider, trace, } from "@opentelemetry/api";
|
|
3
|
+
import { W3CTraceContextPropagator } from "@opentelemetry/core";
|
|
4
|
+
import { resourceFromAttributes } from "@opentelemetry/resources";
|
|
5
|
+
import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
|
|
6
|
+
import { ATTR_SERVICE_NAME } from "@opentelemetry/semantic-conventions";
|
|
7
|
+
import { OTEL_LOG_PREFIX, TRACE_METADATA_CONTEXT_KEY, } from "../../../../constants/telemetry.js";
|
|
8
|
+
import { getExporterRegistry } from "../../../../observability/exporterRegistry.js";
|
|
9
|
+
import { ExporterSpanProcessor } from "../../../../observability/exporterSpanProcessor.js";
|
|
10
|
+
import { LangfuseExporter } from "../../../../observability/exporters/langfuseExporter.js";
|
|
11
|
+
import { spanJsonAttribute } from "../../../../telemetry/attributes.js";
|
|
12
12
|
import { logger } from "../../../../utils/logger.js";
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
import("@opentelemetry/resources"),
|
|
19
|
-
import("@opentelemetry/semantic-conventions"),
|
|
20
|
-
]);
|
|
21
|
-
return resourceFromAttributes({
|
|
22
|
-
[ATTR_SERVICE_NAME]: serviceName,
|
|
23
|
-
[ATTR_SERVICE_VERSION]: config.release || "v1.0.0",
|
|
24
|
-
"deployment.environment": config.environment || "dev",
|
|
25
|
-
});
|
|
26
|
-
}
|
|
27
|
-
/**
|
|
28
|
-
* Configure OTLP metrics and logs with bounded serialization batches for
|
|
29
|
-
* request-serving processes. Initialization awaits this so a caller may flush
|
|
30
|
-
* or shut down immediately after it returns without racing providers that have
|
|
31
|
-
* not been constructed yet. Signal-specific failures stay non-fatal here.
|
|
32
|
-
*/
|
|
33
|
-
async function initializeOtlpMetricsAndLogs(resource, otlpEndpoint, serviceName) {
|
|
34
|
-
if (!otlpEndpoint) {
|
|
35
|
-
return;
|
|
36
|
-
}
|
|
37
|
-
try {
|
|
38
|
-
const [{ OTLPMetricExporter }, { MeterProvider, PeriodicExportingMetricReader },] = await Promise.all([
|
|
39
|
-
import("@opentelemetry/exporter-metrics-otlp-http"),
|
|
40
|
-
import("@opentelemetry/sdk-metrics"),
|
|
41
|
-
]);
|
|
42
|
-
const metricExporter = new OTLPMetricExporter({
|
|
43
|
-
url: `${otlpEndpoint}/v1/metrics`,
|
|
44
|
-
});
|
|
45
|
-
const metricReader = new PeriodicExportingMetricReader({
|
|
46
|
-
exporter: metricExporter,
|
|
47
|
-
exportIntervalMillis: 15000,
|
|
48
|
-
exportTimeoutMillis: 10000,
|
|
49
|
-
});
|
|
50
|
-
meterProvider = new MeterProvider({
|
|
51
|
-
resource,
|
|
52
|
-
readers: [metricReader],
|
|
53
|
-
});
|
|
54
|
-
metrics.setGlobalMeterProvider(meterProvider);
|
|
55
|
-
logger.info(`${LOG_PREFIX} OTLP metric exporter added — MeterProvider registered globally`, {
|
|
56
|
-
endpoint: `${otlpEndpoint}/v1/metrics`,
|
|
57
|
-
exportIntervalMs: 15000,
|
|
58
|
-
serviceName,
|
|
59
|
-
meterProviderType: meterProvider.constructor.name,
|
|
60
|
-
});
|
|
61
|
-
}
|
|
62
|
-
catch (metricsError) {
|
|
63
|
-
logger.warn(`${LOG_PREFIX} Failed to create OTLP metric exporter (non-fatal)`, {
|
|
64
|
-
error: metricsError instanceof Error
|
|
65
|
-
? metricsError.message
|
|
66
|
-
: String(metricsError),
|
|
67
|
-
endpoint: otlpEndpoint,
|
|
68
|
-
});
|
|
69
|
-
}
|
|
70
|
-
try {
|
|
71
|
-
const [{ OTLPLogExporter }, { BatchLogRecordProcessor, LoggerProvider }] = await Promise.all([
|
|
72
|
-
import("@opentelemetry/exporter-logs-otlp-http"),
|
|
73
|
-
import("@opentelemetry/sdk-logs"),
|
|
74
|
-
]);
|
|
75
|
-
const logExporter = new OTLPLogExporter({
|
|
76
|
-
url: `${otlpEndpoint}/v1/logs`,
|
|
77
|
-
});
|
|
78
|
-
const logProcessor = new BatchLogRecordProcessor(logExporter, {
|
|
79
|
-
maxQueueSize: 2048,
|
|
80
|
-
// Body logs contain up to 16 KB each. Bound exporter JSON work to
|
|
81
|
-
// roughly 1 MB per batch instead of an 8 MB main-thread serialization.
|
|
82
|
-
maxExportBatchSize: 64,
|
|
83
|
-
scheduledDelayMillis: 2000,
|
|
84
|
-
exportTimeoutMillis: 30000,
|
|
85
|
-
});
|
|
86
|
-
loggerProvider = new LoggerProvider({
|
|
87
|
-
resource,
|
|
88
|
-
processors: [logProcessor],
|
|
89
|
-
});
|
|
90
|
-
logger.info(`${LOG_PREFIX} OTLP log exporter added — LoggerProvider created`, {
|
|
91
|
-
endpoint: `${otlpEndpoint}/v1/logs`,
|
|
92
|
-
serviceName,
|
|
93
|
-
});
|
|
94
|
-
}
|
|
95
|
-
catch (logsError) {
|
|
96
|
-
logger.warn(`${LOG_PREFIX} Failed to create OTLP log exporter (non-fatal)`, {
|
|
97
|
-
error: logsError instanceof Error ? logsError.message : String(logsError),
|
|
98
|
-
endpoint: otlpEndpoint,
|
|
99
|
-
});
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
const contextStorage = new AsyncLocalStorage();
|
|
13
|
+
import { createLangfuseExporter, isLangfuseEnabled } from "./langfuseSetup.js";
|
|
14
|
+
import { createOtlpMeterProvider, createOtlpSpanProcessor, } from "./otlpSetup.js";
|
|
15
|
+
const MAX_PROPAGATED_CHARS = 200;
|
|
16
|
+
const LANGFUSE_EXPORTER_NAME = "langfuse";
|
|
17
|
+
const TRACE_METADATA_KEY = createContextKey(TRACE_METADATA_CONTEXT_KEY);
|
|
103
18
|
let tracerProvider = null;
|
|
104
19
|
let meterProvider = null;
|
|
105
|
-
let
|
|
106
|
-
let langfuseProcessor = null;
|
|
107
|
-
let isInitialized = false;
|
|
108
|
-
let initializationPromise = null;
|
|
109
|
-
let shutdownPromise = null;
|
|
110
|
-
let isCredentialsValid = false;
|
|
111
|
-
let currentConfig = null;
|
|
20
|
+
let langfuseExporter = null;
|
|
112
21
|
let usingExternalProvider = false;
|
|
113
|
-
let
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
try {
|
|
128
|
-
const provider = trace.getTracerProvider();
|
|
129
|
-
if (!provider) {
|
|
130
|
-
return false;
|
|
131
|
-
}
|
|
132
|
-
// ProxyTracerProvider is the default "no-op" provider
|
|
133
|
-
// Any other provider means someone else registered one
|
|
134
|
-
const providerName = provider.constructor?.name || "";
|
|
135
|
-
const isProxy = providerName === "ProxyTracerProvider" ||
|
|
136
|
-
providerName === "NoopTracerProvider";
|
|
137
|
-
if (!isProxy) {
|
|
138
|
-
logger.debug(`${LOG_PREFIX} Detected external TracerProvider: ${providerName}`);
|
|
139
|
-
}
|
|
140
|
-
return !isProxy;
|
|
141
|
-
}
|
|
142
|
-
catch (error) {
|
|
143
|
-
logger.warn(`${LOG_PREFIX} Error checking for external TracerProvider`, {
|
|
144
|
-
error: error instanceof Error ? error.message : String(error),
|
|
145
|
-
});
|
|
146
|
-
return false;
|
|
147
|
-
}
|
|
22
|
+
let started = false;
|
|
23
|
+
let initialized = false;
|
|
24
|
+
let ignoredConfigLogged = false;
|
|
25
|
+
let initializationError = null;
|
|
26
|
+
let terminated = false;
|
|
27
|
+
let terminationLogged = false;
|
|
28
|
+
let unconfiguredLogged = false;
|
|
29
|
+
let holders = 0;
|
|
30
|
+
function hasHostTracerProvider() {
|
|
31
|
+
const global = trace.getTracerProvider();
|
|
32
|
+
if (!(global instanceof ProxyTracerProvider)) {
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
return global.getDelegateTracer("ai") !== undefined;
|
|
148
36
|
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
const resultAttr = attrs["ai.toolCall.result"];
|
|
155
|
-
if (typeof resultAttr !== "string" || resultAttr.length === 0) {
|
|
156
|
-
return;
|
|
37
|
+
function registerLangfuseExporter(config) {
|
|
38
|
+
const registry = getExporterRegistry();
|
|
39
|
+
const existing = registry.get(LANGFUSE_EXPORTER_NAME);
|
|
40
|
+
if (existing) {
|
|
41
|
+
return existing instanceof LangfuseExporter ? existing : null;
|
|
157
42
|
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
}
|
|
162
|
-
catch {
|
|
163
|
-
return;
|
|
43
|
+
const exporter = createLangfuseExporter(config);
|
|
44
|
+
if (exporter) {
|
|
45
|
+
registry.register(exporter);
|
|
164
46
|
}
|
|
165
|
-
|
|
166
|
-
typeof parsed !== "object" ||
|
|
167
|
-
parsed.isError !== true) {
|
|
168
|
-
return;
|
|
169
|
-
}
|
|
170
|
-
attrs["langfuse.level"] = "ERROR";
|
|
171
|
-
// Always set a status_message, even when the MCP payload has non-text or
|
|
172
|
-
// empty content. Without a fallback the Curator P0-1 gap reappears for
|
|
173
|
-
// those failures (level=ERROR but statusMessage=null).
|
|
174
|
-
const errorText = extractMcpErrorText(parsed);
|
|
175
|
-
const toolName = typeof attrs["ai.toolCall.name"] === "string"
|
|
176
|
-
? attrs["ai.toolCall.name"]
|
|
177
|
-
: "tool";
|
|
178
|
-
attrs["langfuse.status_message"] =
|
|
179
|
-
errorText || `MCP ${toolName} returned isError=true`;
|
|
47
|
+
return exporter;
|
|
180
48
|
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
? finishReason.join(",")
|
|
189
|
-
: String(finishReason ?? "");
|
|
190
|
-
if (reasonStr.includes("content-filter") || reasonStr === "length") {
|
|
191
|
-
attrs["langfuse.level"] = "WARNING";
|
|
192
|
-
attrs["langfuse.status_message"] =
|
|
193
|
-
`Generation stopped: finishReason=${reasonStr}`;
|
|
194
|
-
return;
|
|
195
|
-
}
|
|
196
|
-
if (attrs["neurolink.no_output"] === true) {
|
|
197
|
-
attrs["langfuse.level"] = "WARNING";
|
|
198
|
-
// Preserve any enriched status message StreamHandler already set
|
|
199
|
-
// (carries finishReason / token counts via buildNoOutputStatusMessage).
|
|
200
|
-
// Only fall back to the generic message when none was set upstream.
|
|
201
|
-
if (typeof attrs["langfuse.status_message"] !== "string") {
|
|
202
|
-
attrs["langfuse.status_message"] =
|
|
203
|
-
"Stream produced no output (NoOutputGeneratedError)";
|
|
204
|
-
}
|
|
205
|
-
return;
|
|
206
|
-
}
|
|
207
|
-
if (reasonStr === "aborted") {
|
|
208
|
-
attrs["langfuse.level"] = "WARNING";
|
|
209
|
-
attrs["langfuse.status_message"] = "Generation aborted by client";
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
/**
|
|
213
|
-
* Span processor that enriches spans with user and session context from AsyncLocalStorage
|
|
214
|
-
* Also extracts GenAI semantic convention attributes for Langfuse integration
|
|
215
|
-
*
|
|
216
|
-
* Key features:
|
|
217
|
-
* - Enriches spans with userId, sessionId, conversationId, requestId
|
|
218
|
-
* - Auto-detects operation names from Vercel AI SDK span names
|
|
219
|
-
* - Builds formatted trace names for Langfuse (e.g., "user@email.com:ai.streamText")
|
|
220
|
-
* - Supports custom trace name formats via configuration
|
|
221
|
-
* - Handles wrapper spans by detecting operations from child spans and updating trace name in onEnd()
|
|
222
|
-
*/
|
|
223
|
-
class ContextEnricher {
|
|
224
|
-
/**
|
|
225
|
-
* Maximum number of detected operations to track to prevent memory leaks.
|
|
226
|
-
* Once this limit is reached, oldest entries are evicted (FIFO).
|
|
227
|
-
*/
|
|
228
|
-
static MAX_DETECTED_OPERATIONS = 10000;
|
|
229
|
-
/**
|
|
230
|
-
* Track detected operations per trace for wrapper span support.
|
|
231
|
-
* When a host app creates a wrapper span before AI operations, the wrapper's
|
|
232
|
-
* onStart() runs before the AI SDK child span exists. We store detected
|
|
233
|
-
* operations here so we can update the trace name in onEnd().
|
|
234
|
-
*/
|
|
235
|
-
detectedOperations = new Map();
|
|
236
|
-
onStart(span, parentContext) {
|
|
237
|
-
const context = contextStorage.getStore();
|
|
238
|
-
const userId = context?.userId ?? currentConfig?.userId ?? "guest";
|
|
239
|
-
const sessionId = context?.sessionId ?? currentConfig?.sessionId;
|
|
240
|
-
// Get span name for operation auto-detection
|
|
241
|
-
// (sdk-trace-base's Span type includes the ReadableSpan members)
|
|
242
|
-
const spanName = span.name;
|
|
243
|
-
// Determine if auto-detection is enabled for this context
|
|
244
|
-
const autoDetect = this.shouldAutoDetectOperationName(context);
|
|
245
|
-
// Resolve operation name: explicit > auto-detected > undefined
|
|
246
|
-
const operationName = this.resolveOperationName(context?.operationName, spanName, autoDetect);
|
|
247
|
-
// Store detected AI operations for wrapper span support (optional, defensive).
|
|
248
|
-
// When a host app creates a wrapper span before calling AI operations,
|
|
249
|
-
// this allows us to update the trace name in onEnd() with the operation.
|
|
250
|
-
// Only store the first detected operation for each trace (subsequent operations are ignored).
|
|
251
|
-
try {
|
|
252
|
-
if (operationName && spanName?.startsWith("ai.")) {
|
|
253
|
-
const traceId = span.spanContext?.()?.traceId;
|
|
254
|
-
if (traceId && !this.detectedOperations.has(traceId)) {
|
|
255
|
-
// Evict oldest entry if at capacity to prevent memory leak
|
|
256
|
-
if (this.detectedOperations.size >=
|
|
257
|
-
ContextEnricher.MAX_DETECTED_OPERATIONS) {
|
|
258
|
-
const firstKey = this.detectedOperations.keys().next().value;
|
|
259
|
-
if (firstKey) {
|
|
260
|
-
this.detectedOperations.delete(firstKey);
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
this.detectedOperations.set(traceId, operationName);
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
catch {
|
|
268
|
-
// Wrapper span support is optional - don't fail if spanContext isn't available
|
|
269
|
-
}
|
|
270
|
-
// Build trace name based on priority:
|
|
271
|
-
// 1. Explicit traceName (100% backward compatible)
|
|
272
|
-
// 2. Formatted name with userId + operationName
|
|
273
|
-
// 3. userId only (legacy fallback)
|
|
274
|
-
const traceName = this.buildTraceName(context?.traceName, userId, operationName);
|
|
275
|
-
// Apply custom attributes FIRST so internal attributes always take precedence
|
|
276
|
-
// and cannot be accidentally overwritten by user-provided values
|
|
277
|
-
if (context?.customAttributes) {
|
|
278
|
-
for (const [key, value] of Object.entries(context.customAttributes)) {
|
|
279
|
-
span.setAttribute(key, value);
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
// Set user and session attributes (internal - always override custom)
|
|
283
|
-
if (userId && userId !== "guest") {
|
|
284
|
-
span.setAttribute("user.id", userId);
|
|
285
|
-
}
|
|
286
|
-
if (sessionId) {
|
|
287
|
-
span.setAttribute("session.id", sessionId);
|
|
288
|
-
}
|
|
289
|
-
// Add extended context fields
|
|
290
|
-
if (context?.conversationId) {
|
|
291
|
-
span.setAttribute("conversation.id", context.conversationId);
|
|
292
|
-
}
|
|
293
|
-
if (context?.requestId) {
|
|
294
|
-
span.setAttribute("request.id", context.requestId);
|
|
295
|
-
}
|
|
296
|
-
const isRootSpan = !trace.getSpan(parentContext);
|
|
297
|
-
if (traceName && isRootSpan) {
|
|
298
|
-
span.setAttribute("langfuse.trace.name", traceName);
|
|
299
|
-
span.setAttribute("trace.name", traceName);
|
|
300
|
-
}
|
|
301
|
-
if (isRootSpan && context?.tags && context.tags.length > 0) {
|
|
302
|
-
span.setAttribute("langfuse.trace.tags", [...context.tags]);
|
|
303
|
-
}
|
|
304
|
-
// Set operation name as separate attribute for filtering/analytics
|
|
305
|
-
if (operationName) {
|
|
306
|
-
span.setAttribute("gen_ai.operation.name", operationName);
|
|
307
|
-
}
|
|
308
|
-
// Add custom metadata as span attributes
|
|
309
|
-
const metadata = context?.metadata;
|
|
310
|
-
if (metadata && typeof metadata === "object") {
|
|
311
|
-
for (const [key, value] of Object.entries(metadata)) {
|
|
312
|
-
if (value !== undefined && value !== null) {
|
|
313
|
-
// Preserve primitive types that OTEL supports natively
|
|
314
|
-
if (typeof value === "string" ||
|
|
315
|
-
typeof value === "number" ||
|
|
316
|
-
typeof value === "boolean") {
|
|
317
|
-
if (metadata && isRootSpan) {
|
|
318
|
-
span.setAttribute("langfuse.trace.metadata", JSON.stringify(metadata));
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
else if (Array.isArray(value) &&
|
|
322
|
-
value.every((v) => typeof v === "string" ||
|
|
323
|
-
typeof v === "number" ||
|
|
324
|
-
typeof v === "boolean")) {
|
|
325
|
-
// OTEL supports homogeneous arrays of primitives
|
|
326
|
-
span.setAttribute(`metadata.${key}`, JSON.stringify(value));
|
|
327
|
-
}
|
|
328
|
-
else {
|
|
329
|
-
// Fall back to JSON string for complex types
|
|
330
|
-
span.setAttribute(`metadata.${key}`, JSON.stringify(value));
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
/**
|
|
337
|
-
* Determine if auto-detection should be used for operation names
|
|
338
|
-
*/
|
|
339
|
-
shouldAutoDetectOperationName(context) {
|
|
340
|
-
// Context-level override takes precedence
|
|
341
|
-
if (context?.autoDetectOperationName !== undefined) {
|
|
342
|
-
return context.autoDetectOperationName;
|
|
343
|
-
}
|
|
344
|
-
// Fall back to global config (default: true)
|
|
345
|
-
return currentConfig?.autoDetectOperationName !== false;
|
|
346
|
-
}
|
|
347
|
-
/**
|
|
348
|
-
* Resolve operation name from explicit setting, auto-detection, or undefined
|
|
349
|
-
*/
|
|
350
|
-
resolveOperationName(explicit, spanName, autoDetect) {
|
|
351
|
-
// Explicit operation name takes precedence
|
|
352
|
-
if (explicit) {
|
|
353
|
-
return explicit;
|
|
354
|
-
}
|
|
355
|
-
// Auto-detect from span name if enabled
|
|
356
|
-
if (autoDetect && spanName) {
|
|
357
|
-
// Detect Vercel AI SDK operation spans (ai.streamText, ai.generateText, etc.)
|
|
358
|
-
if (spanName.startsWith("ai.")) {
|
|
359
|
-
return spanName;
|
|
360
|
-
}
|
|
361
|
-
// Detect OpenTelemetry GenAI convention spans (chat, embeddings, text_completion)
|
|
362
|
-
if (spanName === "chat" ||
|
|
363
|
-
spanName === "embeddings" ||
|
|
364
|
-
spanName === "text_completion") {
|
|
365
|
-
return spanName;
|
|
366
|
-
}
|
|
49
|
+
function initialize(config) {
|
|
50
|
+
const langfuse = isLangfuseEnabled(config);
|
|
51
|
+
const otlpEndpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
|
|
52
|
+
if (!langfuse && !otlpEndpoint) {
|
|
53
|
+
if (!unconfiguredLogged) {
|
|
54
|
+
unconfiguredLogged = true;
|
|
55
|
+
logger.debug(`${OTEL_LOG_PREFIX} Langfuse and OTLP not configured; skipping`);
|
|
367
56
|
}
|
|
368
|
-
return
|
|
57
|
+
return config?.langfuse === false;
|
|
369
58
|
}
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
return explicitTraceName;
|
|
377
|
-
}
|
|
378
|
-
// 2. Build formatted trace name based on config
|
|
379
|
-
const format = currentConfig?.traceNameFormat ?? "userId:operationName";
|
|
380
|
-
// Handle custom function format
|
|
381
|
-
if (typeof format === "function") {
|
|
382
|
-
return format({ userId, operationName });
|
|
59
|
+
if (hasHostTracerProvider()) {
|
|
60
|
+
usingExternalProvider = true;
|
|
61
|
+
initialized = true;
|
|
62
|
+
langfuseExporter = langfuse ? registerLangfuseExporter(config) : null;
|
|
63
|
+
if (langfuseExporter) {
|
|
64
|
+
logger.info(`${OTEL_LOG_PREFIX} Host TracerProvider detected; Langfuse exporter registered. Add \`createExporterSpanProcessor()\` to the host provider to export NeuroLink spans to it.`);
|
|
383
65
|
}
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
case "userId:operationName":
|
|
387
|
-
return operationName ? `${userId}:${operationName}` : userId;
|
|
388
|
-
case "operationName:userId":
|
|
389
|
-
return operationName ? `${operationName}:${userId}` : userId;
|
|
390
|
-
case "operationName":
|
|
391
|
-
return operationName || userId;
|
|
392
|
-
case "userId":
|
|
393
|
-
default:
|
|
394
|
-
return userId;
|
|
66
|
+
else {
|
|
67
|
+
logger.info(`${OTEL_LOG_PREFIX} Host TracerProvider detected; NeuroLink registers no provider and no exporter.`);
|
|
395
68
|
}
|
|
69
|
+
return true;
|
|
396
70
|
}
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
logger.debug(`${LOG_PREFIX} GenAI span detected`, {
|
|
459
|
-
spanName: span.name,
|
|
460
|
-
model,
|
|
461
|
-
provider,
|
|
462
|
-
});
|
|
463
|
-
// L4/L6 fix: Set explicit Langfuse observation attributes so
|
|
464
|
-
// cost dashboards and model analytics work correctly.
|
|
465
|
-
try {
|
|
466
|
-
const mAttrs = span.attributes;
|
|
467
|
-
// L6: Model identity
|
|
468
|
-
if (model) {
|
|
469
|
-
mAttrs["gen_ai.response.model"] = model;
|
|
470
|
-
}
|
|
471
|
-
// L4: Usage details — aggregate from AI SDK attributes into a
|
|
472
|
-
// structured JSON object that Langfuse can parse for cost analysis.
|
|
473
|
-
const inputTokens = attributes["gen_ai.usage.input_tokens"] ??
|
|
474
|
-
attributes["ai.usage.promptTokens"];
|
|
475
|
-
const outputTokens = attributes["gen_ai.usage.output_tokens"] ??
|
|
476
|
-
attributes["ai.usage.completionTokens"];
|
|
477
|
-
const totalTokens = attributes["gen_ai.usage.total_tokens"] ??
|
|
478
|
-
(inputTokens !== undefined && outputTokens !== undefined
|
|
479
|
-
? inputTokens + outputTokens
|
|
480
|
-
: undefined);
|
|
481
|
-
const reasoningTokens = attributes["gen_ai.usage.reasoning_tokens"] ??
|
|
482
|
-
attributes["ai.usage.reasoningTokens"];
|
|
483
|
-
const cachedTokens = attributes["gen_ai.usage.input_cached_tokens"];
|
|
484
|
-
if (inputTokens !== undefined || outputTokens !== undefined) {
|
|
485
|
-
const usageDetails = {};
|
|
486
|
-
if (inputTokens !== undefined) {
|
|
487
|
-
usageDetails.input = inputTokens;
|
|
488
|
-
}
|
|
489
|
-
if (outputTokens !== undefined) {
|
|
490
|
-
usageDetails.output = outputTokens;
|
|
491
|
-
}
|
|
492
|
-
if (totalTokens !== undefined) {
|
|
493
|
-
usageDetails.total = totalTokens;
|
|
494
|
-
}
|
|
495
|
-
if (reasoningTokens !== undefined) {
|
|
496
|
-
usageDetails.reasoning_tokens = reasoningTokens;
|
|
497
|
-
}
|
|
498
|
-
if (cachedTokens !== undefined) {
|
|
499
|
-
usageDetails.input_cached_tokens = cachedTokens;
|
|
500
|
-
}
|
|
501
|
-
mAttrs["langfuse.usage_details"] = JSON.stringify(usageDetails);
|
|
502
|
-
logger.debug(`${LOG_PREFIX} Token usage captured`, {
|
|
503
|
-
inputTokens,
|
|
504
|
-
outputTokens,
|
|
505
|
-
totalTokens,
|
|
506
|
-
});
|
|
507
|
-
}
|
|
508
|
-
// L7: Model parameters — surface temperature and max_tokens for
|
|
509
|
-
// generation tuning visibility.
|
|
510
|
-
const temperature = attributes["gen_ai.request.temperature"] ??
|
|
511
|
-
attributes["ai.settings.temperature"];
|
|
512
|
-
const maxTokens = attributes["gen_ai.request.max_tokens"] ??
|
|
513
|
-
attributes["ai.settings.maxTokens"];
|
|
514
|
-
const topP = attributes["gen_ai.request.top_p"] ??
|
|
515
|
-
attributes["ai.settings.topP"];
|
|
516
|
-
if (temperature !== undefined ||
|
|
517
|
-
maxTokens !== undefined ||
|
|
518
|
-
topP !== undefined) {
|
|
519
|
-
const params = {};
|
|
520
|
-
if (temperature !== undefined) {
|
|
521
|
-
params.temperature = temperature;
|
|
522
|
-
}
|
|
523
|
-
if (maxTokens !== undefined) {
|
|
524
|
-
params.max_tokens = maxTokens;
|
|
525
|
-
}
|
|
526
|
-
if (topP !== undefined) {
|
|
527
|
-
params.top_p = topP;
|
|
528
|
-
}
|
|
529
|
-
mAttrs["gen_ai.request.model_parameters"] = JSON.stringify(params);
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
catch {
|
|
533
|
-
// Read-only attributes — cannot enrich; Pipeline A will still
|
|
534
|
-
// export the raw GenAI attributes that Langfuse can parse.
|
|
535
|
-
}
|
|
536
|
-
}
|
|
537
|
-
// P8 fix: Propagate error status to Langfuse-consumable attributes.
|
|
538
|
-
// OTel ReadableSpan attributes may be readonly at onEnd() time; the type
|
|
539
|
-
// cast attempts late mutation. LangfuseSpanProcessor runs after
|
|
540
|
-
// ContextEnricher in the spanProcessors array and reads these attributes,
|
|
541
|
-
// so setting them here allows Langfuse to surface the correct level and
|
|
542
|
-
// status message on the trace/generation.
|
|
543
|
-
const readableStatus = span.status;
|
|
544
|
-
try {
|
|
545
|
-
const mutableAttrs = span.attributes;
|
|
546
|
-
// Curator P0-1/P0-2: detect MCP isError pattern on AI SDK tool call spans.
|
|
547
|
-
// The AI SDK's `ai.toolCall` span stays status=UNSET when the tool
|
|
548
|
-
// *returns* { isError:true } (no exception thrown), so Langfuse sees
|
|
549
|
-
// level=DEFAULT and no status message. Parse the stringified result
|
|
550
|
-
// and surface the embedded error text.
|
|
551
|
-
if (span.name === "ai.toolCall" &&
|
|
552
|
-
readableStatus?.code !== SpanStatusCode.ERROR) {
|
|
553
|
-
applyToolCallIsErrorStatus(mutableAttrs);
|
|
554
|
-
}
|
|
555
|
-
if (readableStatus?.code === SpanStatusCode.ERROR) {
|
|
556
|
-
mutableAttrs["langfuse.level"] = "ERROR";
|
|
557
|
-
if (readableStatus.message) {
|
|
558
|
-
mutableAttrs["langfuse.status_message"] = readableStatus.message;
|
|
559
|
-
}
|
|
560
|
-
}
|
|
561
|
-
else if (mutableAttrs["langfuse.level"] === undefined) {
|
|
562
|
-
applyNonErrorLangfuseLevel(mutableAttrs);
|
|
563
|
-
}
|
|
564
|
-
}
|
|
565
|
-
catch {
|
|
566
|
-
// Readonly enforcement by OTel SDK — mutation not possible; log at debug.
|
|
567
|
-
logger.debug(`${LOG_PREFIX} Could not set langfuse.level on span (read-only attributes)`);
|
|
568
|
-
}
|
|
569
|
-
}
|
|
570
|
-
catch (error) {
|
|
571
|
-
// Don't fail span processing on errors
|
|
572
|
-
logger.debug(`${LOG_PREFIX} Error reading span attributes`, {
|
|
573
|
-
error: error instanceof Error ? error.message : String(error),
|
|
71
|
+
const resource = resourceFromAttributes({
|
|
72
|
+
[ATTR_SERVICE_NAME]: process.env.OTEL_SERVICE_NAME || "ai-service",
|
|
73
|
+
});
|
|
74
|
+
const exporter = langfuse ? registerLangfuseExporter(config) : null;
|
|
75
|
+
const otlpProcessor = otlpEndpoint
|
|
76
|
+
? createOtlpSpanProcessor(otlpEndpoint)
|
|
77
|
+
: null;
|
|
78
|
+
const spanProcessors = [
|
|
79
|
+
new ExporterSpanProcessor(getExporterRegistry()),
|
|
80
|
+
];
|
|
81
|
+
if (otlpProcessor) {
|
|
82
|
+
spanProcessors.push(otlpProcessor);
|
|
83
|
+
}
|
|
84
|
+
const provider = new NodeTracerProvider({ resource, spanProcessors });
|
|
85
|
+
provider.register({ propagator: new W3CTraceContextPropagator() });
|
|
86
|
+
setLangfuseTracerProvider(provider);
|
|
87
|
+
tracerProvider = provider;
|
|
88
|
+
langfuseExporter = exporter;
|
|
89
|
+
meterProvider = otlpEndpoint
|
|
90
|
+
? createOtlpMeterProvider(otlpEndpoint, resource)
|
|
91
|
+
: null;
|
|
92
|
+
usingExternalProvider = false;
|
|
93
|
+
initialized = true;
|
|
94
|
+
logger.info(`${OTEL_LOG_PREFIX} Observability initialized`, {
|
|
95
|
+
langfuseEnabled: exporter !== null,
|
|
96
|
+
otlpEnabled: otlpProcessor !== null,
|
|
97
|
+
});
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
async function stop() {
|
|
101
|
+
const provider = tracerProvider;
|
|
102
|
+
const meters = meterProvider;
|
|
103
|
+
const hostExporter = usingExternalProvider ? langfuseExporter : null;
|
|
104
|
+
terminated = true;
|
|
105
|
+
tracerProvider = null;
|
|
106
|
+
meterProvider = null;
|
|
107
|
+
langfuseExporter = null;
|
|
108
|
+
usingExternalProvider = false;
|
|
109
|
+
started = false;
|
|
110
|
+
initialized = false;
|
|
111
|
+
initializationError = null;
|
|
112
|
+
if (provider) {
|
|
113
|
+
setLangfuseTracerProvider(null);
|
|
114
|
+
}
|
|
115
|
+
if (meters) {
|
|
116
|
+
metrics.disable();
|
|
117
|
+
}
|
|
118
|
+
if (hostExporter) {
|
|
119
|
+
getExporterRegistry().unregister(hostExporter.getName());
|
|
120
|
+
}
|
|
121
|
+
const results = await Promise.allSettled([
|
|
122
|
+
provider?.shutdown(),
|
|
123
|
+
hostExporter?.shutdown(),
|
|
124
|
+
meters?.shutdown(),
|
|
125
|
+
]);
|
|
126
|
+
for (const result of results) {
|
|
127
|
+
if (result.status === "rejected") {
|
|
128
|
+
logger.error(`${OTEL_LOG_PREFIX} Shutdown failed`, {
|
|
129
|
+
error: result.reason instanceof Error
|
|
130
|
+
? result.reason.message
|
|
131
|
+
: String(result.reason),
|
|
574
132
|
});
|
|
575
133
|
}
|
|
576
134
|
}
|
|
577
|
-
shutdown() {
|
|
578
|
-
// Clean up tracked operations to prevent memory leaks
|
|
579
|
-
this.detectedOperations.clear();
|
|
580
|
-
return Promise.resolve();
|
|
581
|
-
}
|
|
582
|
-
forceFlush() {
|
|
583
|
-
return Promise.resolve();
|
|
584
|
-
}
|
|
585
|
-
}
|
|
586
|
-
async function createLangfuseProcessor(config) {
|
|
587
|
-
const mod = await tryImport("@langfuse/otel", "Langfuse observability");
|
|
588
|
-
return new mod.LangfuseSpanProcessor({
|
|
589
|
-
publicKey: config.publicKey,
|
|
590
|
-
secretKey: config.secretKey,
|
|
591
|
-
baseUrl: config.baseUrl || "https://cloud.langfuse.com",
|
|
592
|
-
environment: config.environment || "dev",
|
|
593
|
-
release: config.release || "v1.0.0",
|
|
594
|
-
// Curator P1-3: skip internal wrapper spans that duplicate ai.toolCall /
|
|
595
|
-
// ai.generateText observations in Langfuse. Wrappers still emit OTel spans
|
|
596
|
-
// for internal metrics; they just aren't forwarded to Langfuse.
|
|
597
|
-
shouldExportSpan: langfuseShouldExportSpan,
|
|
598
|
-
});
|
|
599
|
-
}
|
|
600
|
-
/**
|
|
601
|
-
* True when a span is an internal NeuroLink wrapper that should NOT be sent to
|
|
602
|
-
* Langfuse. Internal wrappers carry the `langfuse.internal: true` attribute.
|
|
603
|
-
*
|
|
604
|
-
* Exposed so host apps that bring their own `LangfuseSpanProcessor` (e.g.
|
|
605
|
-
* `skipLangfuseSpanProcessor: true`, or manual registration on an existing
|
|
606
|
-
* TracerProvider) can apply the same filter and avoid duplicate observations.
|
|
607
|
-
*/
|
|
608
|
-
export function isLangfuseInternalSpan(span) {
|
|
609
|
-
return span.attributes?.["langfuse.internal"] === true;
|
|
610
135
|
}
|
|
611
136
|
/**
|
|
612
|
-
*
|
|
613
|
-
*
|
|
614
|
-
*
|
|
615
|
-
* Usage in host apps:
|
|
616
|
-
* ```ts
|
|
617
|
-
* import { langfuseShouldExportSpan } from "@juspay/neurolink";
|
|
618
|
-
* new LangfuseSpanProcessor({ ..., shouldExportSpan: langfuseShouldExportSpan });
|
|
619
|
-
* ```
|
|
137
|
+
* @description Starts NeuroLink's tracing once per process, synchronously. Every span created through the shared tracers calls it first, so direct provider calls without a NeuroLink instance are traced too. Langfuse is enabled unless `config.langfuse` is `false`, when an options object is given or `LANGFUSE_PUBLIC_KEY` and `LANGFUSE_SECRET_KEY` are set; an OTLP exporter is added when `OTEL_EXPORTER_OTLP_ENDPOINT` is set. When the host already registered a TracerProvider no provider is created; the Langfuse exporter, if configured, is registered on the shared exporter registry and the host adds `createExporterSpanProcessor()` to its provider. The first call that finds tracing configured decides the setup and a later `config` is ignored; calls made before anything is configured start nothing, so a later config or environment can still start tracing, unless `langfuse: false` was passed. After `shutdownOpenTelemetry` it does nothing.
|
|
138
|
+
* @param config {ObservabilityConfig | undefined} - Observability options, honoured only when this is the first call.
|
|
139
|
+
* @returns {void} Nothing; a failed setup is logged and recorded in `getLangfuseHealthStatus`.
|
|
620
140
|
*/
|
|
621
|
-
export function
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
if (!otlpEndpoint) {
|
|
627
|
-
logger.warn(`${LOG_PREFIX} External provider mode requested Langfuse but credentials are missing, and no OTLP endpoint is configured; skipping initialization`, {
|
|
628
|
-
hasPublicKey: !!config?.publicKey,
|
|
629
|
-
hasSecretKey: !!config?.secretKey,
|
|
630
|
-
});
|
|
631
|
-
isInitialized = true;
|
|
632
|
-
isCredentialsValid = false;
|
|
633
|
-
return;
|
|
634
|
-
}
|
|
635
|
-
logger.warn(`${LOG_PREFIX} External provider mode missing Langfuse credentials; continuing with OTLP-only metrics/logs`, {
|
|
636
|
-
hasPublicKey: !!config?.publicKey,
|
|
637
|
-
hasSecretKey: !!config?.secretKey,
|
|
638
|
-
otlpEnabled: true,
|
|
639
|
-
});
|
|
640
|
-
}
|
|
641
|
-
try {
|
|
642
|
-
currentConfig = config;
|
|
643
|
-
isCredentialsValid = hasLangfuseCreds;
|
|
644
|
-
langfuseProcessor =
|
|
645
|
-
langfuseRequested && hasLangfuseCreds
|
|
646
|
-
? await createLangfuseProcessor(config)
|
|
647
|
-
: null;
|
|
648
|
-
usingExternalProvider = true;
|
|
649
|
-
await initializeOtlpMetricsAndLogs(resource, otlpEndpoint, serviceName);
|
|
650
|
-
isInitialized = true;
|
|
651
|
-
try {
|
|
652
|
-
const globalProvider = trace.getTracerProvider();
|
|
653
|
-
const provider = globalProvider;
|
|
654
|
-
if (globalProvider && typeof provider.addSpanProcessor === "function") {
|
|
655
|
-
provider.addSpanProcessor(new ContextEnricher());
|
|
656
|
-
// Auto-detect: skip if consumer already registered a LangfuseSpanProcessor.
|
|
657
|
-
//
|
|
658
|
-
// Detection strategy (ordered by robustness):
|
|
659
|
-
// 1. Duck-type check for Langfuse-specific public member
|
|
660
|
-
// (`langfuseClient` property) — survives minification.
|
|
661
|
-
// 2. `constructor.name === "LangfuseSpanProcessor"` — last resort,
|
|
662
|
-
// brittle under minification or bundler renaming.
|
|
663
|
-
//
|
|
664
|
-
// NOTE: `_registeredSpanProcessors` is an internal OpenTelemetry field.
|
|
665
|
-
// If the OTel SDK removes or renames it, the array defaults to [] and
|
|
666
|
-
// `hasExistingLangfuse` is false — NeuroLink registers its own processor
|
|
667
|
-
// (same behavior as before this check). Consumers can always force skip
|
|
668
|
-
// via `skipLangfuseSpanProcessor: true`.
|
|
669
|
-
const existingProcessors = provider
|
|
670
|
-
._registeredSpanProcessors ?? [];
|
|
671
|
-
const hasExistingLangfuse = existingProcessors.some((p) => {
|
|
672
|
-
if (p === null || p === undefined || typeof p !== "object") {
|
|
673
|
-
return false;
|
|
674
|
-
}
|
|
675
|
-
// Duck-type: Langfuse processor exposes a langfuseClient property
|
|
676
|
-
if ("langfuseClient" in p) {
|
|
677
|
-
return true;
|
|
678
|
-
}
|
|
679
|
-
// Fallback: constructor name (brittle under minification)
|
|
680
|
-
return (p.constructor?.name ===
|
|
681
|
-
"LangfuseSpanProcessor");
|
|
682
|
-
});
|
|
683
|
-
const skipLangfuse = config.skipLangfuseSpanProcessor === true ||
|
|
684
|
-
!langfuseProcessor ||
|
|
685
|
-
hasExistingLangfuse;
|
|
686
|
-
if (hasExistingLangfuse && !config.skipLangfuseSpanProcessor) {
|
|
687
|
-
logger.info(`${LOG_PREFIX} Auto-detected existing LangfuseSpanProcessor — skipping SDK registration to avoid duplicates`);
|
|
688
|
-
}
|
|
689
|
-
if (!skipLangfuse && langfuseProcessor) {
|
|
690
|
-
provider.addSpanProcessor(langfuseProcessor);
|
|
691
|
-
}
|
|
692
|
-
logger.info(`${LOG_PREFIX} Auto-registered processors with global TracerProvider`, {
|
|
693
|
-
processors: skipLangfuse
|
|
694
|
-
? ["ContextEnricher"]
|
|
695
|
-
: ["ContextEnricher", "LangfuseSpanProcessor"],
|
|
696
|
-
reason: "External provider mode with auto-registration",
|
|
697
|
-
skippedLangfuseSpanProcessor: skipLangfuse,
|
|
698
|
-
});
|
|
699
|
-
return;
|
|
700
|
-
}
|
|
701
|
-
logger.info(`${LOG_PREFIX} Using external TracerProvider mode`, {
|
|
702
|
-
reason: config.useExternalTracerProvider
|
|
703
|
-
? "useExternalTracerProvider=true"
|
|
704
|
-
: "autoDetectExternalProvider=true (trusting host signal)",
|
|
705
|
-
instructions: "Add span processors to your TracerProvider using getSpanProcessors()",
|
|
706
|
-
});
|
|
707
|
-
logger.info(`${LOG_PREFIX} Span processors ready for external use`, {
|
|
708
|
-
processors: langfuseProcessor
|
|
709
|
-
? ["ContextEnricher", "LangfuseSpanProcessor"]
|
|
710
|
-
: ["ContextEnricher"],
|
|
711
|
-
usage: "import { getSpanProcessors } from '@juspay/neurolink'",
|
|
712
|
-
});
|
|
141
|
+
export function ensureTelemetry(config) {
|
|
142
|
+
if (terminated) {
|
|
143
|
+
if (!terminationLogged) {
|
|
144
|
+
terminationLogged = true;
|
|
145
|
+
logger.debug(`${OTEL_LOG_PREFIX} Telemetry was shut down for this process; not restarting`);
|
|
713
146
|
}
|
|
714
|
-
|
|
715
|
-
logger.warn(`${LOG_PREFIX} Auto-registration failed, manual registration required`, {
|
|
716
|
-
error: autoRegisterError instanceof Error
|
|
717
|
-
? autoRegisterError.message
|
|
718
|
-
: String(autoRegisterError),
|
|
719
|
-
instructions: "Add span processors to your TracerProvider using getSpanProcessors()",
|
|
720
|
-
});
|
|
721
|
-
}
|
|
722
|
-
}
|
|
723
|
-
catch (error) {
|
|
724
|
-
logger.error(`${LOG_PREFIX} Failed to create span processor for external mode`, {
|
|
725
|
-
error: error instanceof Error ? error.message : String(error),
|
|
726
|
-
stack: error instanceof Error ? error.stack : undefined,
|
|
727
|
-
});
|
|
728
|
-
isInitialized = true;
|
|
147
|
+
return;
|
|
729
148
|
}
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
logger.warn(`${LOG_PREFIX} Langfuse requested but credentials are missing, and no OTLP endpoint is configured; skipping initialization`, {
|
|
735
|
-
hasPublicKey: !!config.publicKey,
|
|
736
|
-
hasSecretKey: !!config.secretKey,
|
|
737
|
-
});
|
|
738
|
-
}
|
|
739
|
-
else {
|
|
740
|
-
logger.debug(`${LOG_PREFIX} Langfuse disabled and OTLP endpoint missing, skipping initialization`);
|
|
149
|
+
if (started) {
|
|
150
|
+
if (config && !ignoredConfigLogged) {
|
|
151
|
+
ignoredConfigLogged = true;
|
|
152
|
+
logger.debug(`${OTEL_LOG_PREFIX} Telemetry already started; ignoring observability config`);
|
|
741
153
|
}
|
|
742
|
-
isInitialized = true;
|
|
743
154
|
return;
|
|
744
155
|
}
|
|
745
|
-
|
|
746
|
-
logger.warn(`${LOG_PREFIX} Langfuse requested but credentials are missing; continuing with OTLP-only telemetry`, {
|
|
747
|
-
hasPublicKey: !!config.publicKey,
|
|
748
|
-
hasSecretKey: !!config.secretKey,
|
|
749
|
-
otlpEnabled: !!otlpEndpoint,
|
|
750
|
-
});
|
|
751
|
-
}
|
|
156
|
+
initializationError = null;
|
|
752
157
|
try {
|
|
753
|
-
|
|
754
|
-
isCredentialsValid = hasLangfuseCreds;
|
|
755
|
-
langfuseProcessor =
|
|
756
|
-
langfuseRequested && hasLangfuseCreds
|
|
757
|
-
? await createLangfuseProcessor(config)
|
|
758
|
-
: null;
|
|
759
|
-
logger.debug(`${LOG_PREFIX} Standalone observability mode`, {
|
|
760
|
-
langfuseEnabled: !!langfuseProcessor,
|
|
761
|
-
otlpEnabled: !!otlpEndpoint,
|
|
762
|
-
baseUrl: config.baseUrl || "https://cloud.langfuse.com",
|
|
763
|
-
environment: config.environment || "dev",
|
|
764
|
-
});
|
|
765
|
-
const spanProcessors = [new ContextEnricher()];
|
|
766
|
-
if (langfuseProcessor) {
|
|
767
|
-
spanProcessors.push(langfuseProcessor);
|
|
768
|
-
}
|
|
769
|
-
if (otlpEndpoint) {
|
|
770
|
-
try {
|
|
771
|
-
const [{ OTLPTraceExporter }, { BatchSpanProcessor }] = await Promise.all([
|
|
772
|
-
import("@opentelemetry/exporter-trace-otlp-http"),
|
|
773
|
-
import("@opentelemetry/sdk-trace-base"),
|
|
774
|
-
]);
|
|
775
|
-
const otlpExporter = new OTLPTraceExporter({
|
|
776
|
-
url: `${otlpEndpoint}/v1/traces`,
|
|
777
|
-
});
|
|
778
|
-
spanProcessors.push(new BatchSpanProcessor(otlpExporter, {
|
|
779
|
-
maxQueueSize: 2048,
|
|
780
|
-
maxExportBatchSize: 512,
|
|
781
|
-
scheduledDelayMillis: 1000,
|
|
782
|
-
exportTimeoutMillis: 30000,
|
|
783
|
-
}));
|
|
784
|
-
logger.info(`${LOG_PREFIX} OTLP trace exporter added`, {
|
|
785
|
-
endpoint: `${otlpEndpoint}/v1/traces`,
|
|
786
|
-
serviceName,
|
|
787
|
-
});
|
|
788
|
-
}
|
|
789
|
-
catch (otlpError) {
|
|
790
|
-
logger.warn(`${LOG_PREFIX} Failed to create OTLP exporter (non-fatal)`, {
|
|
791
|
-
error: otlpError instanceof Error
|
|
792
|
-
? otlpError.message
|
|
793
|
-
: String(otlpError),
|
|
794
|
-
endpoint: otlpEndpoint,
|
|
795
|
-
});
|
|
796
|
-
}
|
|
797
|
-
}
|
|
798
|
-
const [{ NodeTracerProvider }, { W3CTraceContextPropagator }] = await Promise.all([
|
|
799
|
-
import("@opentelemetry/sdk-trace-node"),
|
|
800
|
-
import("@opentelemetry/core"),
|
|
801
|
-
]);
|
|
802
|
-
tracerProvider = new NodeTracerProvider({ resource, spanProcessors });
|
|
803
|
-
tracerProvider.register({
|
|
804
|
-
propagator: new W3CTraceContextPropagator(),
|
|
805
|
-
});
|
|
806
|
-
usingExternalProvider = false;
|
|
807
|
-
await initializeOtlpMetricsAndLogs(resource, otlpEndpoint, serviceName);
|
|
808
|
-
isInitialized = true;
|
|
809
|
-
logger.info(`${LOG_PREFIX} Observability initialized`, {
|
|
810
|
-
baseUrl: config.baseUrl || "https://cloud.langfuse.com",
|
|
811
|
-
environment: config.environment || "dev",
|
|
812
|
-
release: config.release || "v1.0.0",
|
|
813
|
-
mode: "standalone",
|
|
814
|
-
langfuseEnabled: !!langfuseProcessor,
|
|
815
|
-
otlpEnabled: !!otlpEndpoint,
|
|
816
|
-
serviceName,
|
|
817
|
-
});
|
|
158
|
+
started = initialize(config);
|
|
818
159
|
}
|
|
819
160
|
catch (error) {
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
logger.warn(`${LOG_PREFIX} TracerProvider already registered, switching to external mode`, {
|
|
826
|
-
error: errorMessage,
|
|
827
|
-
recommendation: "Set useExternalTracerProvider=true or autoDetectExternalProvider=true in config",
|
|
828
|
-
});
|
|
829
|
-
usingExternalProvider = true;
|
|
830
|
-
isInitialized = true;
|
|
831
|
-
return;
|
|
832
|
-
}
|
|
833
|
-
logger.error(`${LOG_PREFIX} Initialization failed`, {
|
|
834
|
-
error: errorMessage,
|
|
835
|
-
stack: error instanceof Error ? error.stack : undefined,
|
|
161
|
+
started = true;
|
|
162
|
+
initializationError =
|
|
163
|
+
error instanceof Error ? error.message : String(error);
|
|
164
|
+
logger.error(`${OTEL_LOG_PREFIX} Initialization failed`, {
|
|
165
|
+
error: initializationError,
|
|
836
166
|
});
|
|
837
|
-
throw error;
|
|
838
167
|
}
|
|
839
168
|
}
|
|
840
169
|
/**
|
|
841
|
-
*
|
|
842
|
-
*
|
|
843
|
-
*
|
|
844
|
-
* in-flight promise here lets another constructor join it and lets an immediate
|
|
845
|
-
* flush or shutdown wait for the same initialization before touching providers.
|
|
170
|
+
* @description Registers a holder of the process-wide telemetry and starts it if needed. Pair every call with `releaseTelemetry`.
|
|
171
|
+
* @param config {ObservabilityConfig | undefined} - Observability options forwarded to `ensureTelemetry`.
|
|
172
|
+
* @returns {void} Nothing.
|
|
846
173
|
*/
|
|
847
|
-
export
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
// initializationPromise true or null in the first place; either reading
|
|
851
|
-
// is stale until it actually finishes. Waiting here means this call sees
|
|
852
|
-
// the settled post-teardown state — nothing initialized, nothing
|
|
853
|
-
// pending — and falls through to build fresh providers, rather than
|
|
854
|
-
// returning early on a flag that is about to flip out from under it.
|
|
855
|
-
await shutdownPromise;
|
|
856
|
-
}
|
|
857
|
-
if (isInitialized) {
|
|
858
|
-
currentConfig = config;
|
|
859
|
-
logger.debug(`${LOG_PREFIX} Already initialized, config updated`, {
|
|
860
|
-
usingExternalProvider,
|
|
861
|
-
hasLangfuseProcessor: !!langfuseProcessor,
|
|
862
|
-
hasTraceNameFormat: typeof config.traceNameFormat === "function",
|
|
863
|
-
});
|
|
864
|
-
return;
|
|
865
|
-
}
|
|
866
|
-
if (initializationPromise) {
|
|
867
|
-
await initializationPromise;
|
|
868
|
-
currentConfig = config;
|
|
869
|
-
return;
|
|
870
|
-
}
|
|
871
|
-
const pending = initializeOpenTelemetryOnce(config);
|
|
872
|
-
initializationPromise = pending;
|
|
873
|
-
try {
|
|
874
|
-
await pending;
|
|
875
|
-
}
|
|
876
|
-
finally {
|
|
877
|
-
if (initializationPromise === pending) {
|
|
878
|
-
initializationPromise = null;
|
|
879
|
-
}
|
|
880
|
-
}
|
|
174
|
+
export function acquireTelemetry(config) {
|
|
175
|
+
holders++;
|
|
176
|
+
ensureTelemetry(config);
|
|
881
177
|
}
|
|
882
178
|
/**
|
|
883
|
-
*
|
|
884
|
-
*
|
|
885
|
-
*
|
|
886
|
-
* 1. Creating LangfuseSpanProcessor with Langfuse credentials
|
|
887
|
-
* 2. Creating a NodeTracerProvider with service metadata and span processor
|
|
888
|
-
* 3. Registering the provider globally for AI SDK to use
|
|
889
|
-
*
|
|
890
|
-
* NEW: If useExternalTracerProvider is true or autoDetectExternalProvider detects
|
|
891
|
-
* an existing provider, steps 2 and 3 are skipped. The span processors are still
|
|
892
|
-
* created and can be retrieved via getSpanProcessors().
|
|
893
|
-
*
|
|
894
|
-
* @param config - Langfuse configuration passed from parent application
|
|
179
|
+
* @description Releases one holder registered by `acquireTelemetry` and flushes pending spans. The provider stays registered for the process so cached tracers keep working and later instances reuse it.
|
|
180
|
+
* @returns {Promise<void>} Resolves when pending spans are flushed.
|
|
181
|
+
* @throws {Error} When any provider fails to flush.
|
|
895
182
|
*/
|
|
896
|
-
async function
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
// calling hasExternalTracerProvider(). This is because Neurolink may bundle its own copy
|
|
900
|
-
// of @opentelemetry/api, which has a separate global state from the host application.
|
|
901
|
-
// The hasExternalTracerProvider() check would query Neurolink's bundled @opentelemetry/api
|
|
902
|
-
// global state (which has no provider registered), not the host's global state.
|
|
903
|
-
// By trusting autoDetectExternalProvider=true, we let the host application signal that
|
|
904
|
-
// it has already registered a TracerProvider.
|
|
905
|
-
const shouldUseExternal = config?.useExternalTracerProvider === true ||
|
|
906
|
-
config?.autoDetectExternalProvider === true;
|
|
907
|
-
const otlpEndpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
|
|
908
|
-
const langfuseRequested = config?.enabled === true;
|
|
909
|
-
const hasLangfuseCreds = !!config.publicKey && !!config.secretKey;
|
|
910
|
-
const serviceName = process.env.OTEL_SERVICE_NAME || "neurolink";
|
|
911
|
-
const resource = await createOtelResource(config, serviceName);
|
|
912
|
-
if (shouldUseExternal) {
|
|
913
|
-
await initializeExternalOpenTelemetryMode(config, resource, otlpEndpoint, serviceName, langfuseRequested, hasLangfuseCreds);
|
|
914
|
-
return;
|
|
915
|
-
}
|
|
916
|
-
await initializeStandaloneOpenTelemetryMode(config, resource, otlpEndpoint, serviceName, langfuseRequested, hasLangfuseCreds);
|
|
183
|
+
export async function releaseTelemetry() {
|
|
184
|
+
holders = Math.max(0, holders - 1);
|
|
185
|
+
await flushOpenTelemetry();
|
|
917
186
|
}
|
|
918
187
|
/**
|
|
919
|
-
*
|
|
188
|
+
* @description Exports pending spans and metrics from the providers NeuroLink owns and flushes its Langfuse exporter. A host provider is never flushed.
|
|
189
|
+
* @returns {Promise<void>} Resolves when every owned provider has flushed.
|
|
190
|
+
* @throws {Error} When any provider fails to flush.
|
|
920
191
|
*/
|
|
921
192
|
export async function flushOpenTelemetry() {
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
}
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
logger.debug(`${LOG_PREFIX} Not initialized, skipping flush`);
|
|
933
|
-
return;
|
|
934
|
-
}
|
|
935
|
-
const failures = [];
|
|
936
|
-
if (langfuseProcessor) {
|
|
937
|
-
try {
|
|
938
|
-
logger.info(`${LOG_PREFIX} Flushing Langfuse spans...`);
|
|
939
|
-
await langfuseProcessor.forceFlush();
|
|
940
|
-
}
|
|
941
|
-
catch (error) {
|
|
942
|
-
failures.push({ signal: "langfuse", error });
|
|
943
|
-
logger.error(`${LOG_PREFIX} Langfuse flush failed`, {
|
|
944
|
-
error: error instanceof Error ? error.message : String(error),
|
|
945
|
-
stack: error instanceof Error ? error.stack : undefined,
|
|
946
|
-
});
|
|
947
|
-
}
|
|
948
|
-
}
|
|
949
|
-
else {
|
|
950
|
-
logger.debug(`${LOG_PREFIX} Langfuse disabled, skipping Langfuse flush`);
|
|
951
|
-
}
|
|
952
|
-
if (tracerProvider && !usingExternalProvider) {
|
|
953
|
-
try {
|
|
954
|
-
logger.info(`${LOG_PREFIX} Flushing OTLP traces...`);
|
|
955
|
-
await tracerProvider.forceFlush();
|
|
956
|
-
}
|
|
957
|
-
catch (error) {
|
|
958
|
-
failures.push({ signal: "traces", error });
|
|
959
|
-
logger.error(`${LOG_PREFIX} Trace flush failed`, {
|
|
960
|
-
error: error instanceof Error ? error.message : String(error),
|
|
961
|
-
stack: error instanceof Error ? error.stack : undefined,
|
|
962
|
-
});
|
|
963
|
-
}
|
|
964
|
-
}
|
|
965
|
-
else {
|
|
966
|
-
logger.debug(`${LOG_PREFIX} No TracerProvider to flush`);
|
|
967
|
-
}
|
|
968
|
-
if (meterProvider) {
|
|
969
|
-
try {
|
|
970
|
-
logger.info(`${LOG_PREFIX} Flushing OTLP metrics...`);
|
|
971
|
-
await meterProvider.forceFlush();
|
|
972
|
-
}
|
|
973
|
-
catch (error) {
|
|
974
|
-
failures.push({ signal: "metrics", error });
|
|
975
|
-
logger.error(`${LOG_PREFIX} Metric flush failed`, {
|
|
976
|
-
error: error instanceof Error ? error.message : String(error),
|
|
977
|
-
stack: error instanceof Error ? error.stack : undefined,
|
|
978
|
-
});
|
|
979
|
-
}
|
|
980
|
-
}
|
|
981
|
-
else {
|
|
982
|
-
logger.debug(`${LOG_PREFIX} No MeterProvider to flush`);
|
|
983
|
-
}
|
|
984
|
-
if (loggerProvider) {
|
|
985
|
-
try {
|
|
986
|
-
logger.info(`${LOG_PREFIX} Flushing OTLP logs...`);
|
|
987
|
-
await loggerProvider.forceFlush();
|
|
988
|
-
}
|
|
989
|
-
catch (error) {
|
|
990
|
-
failures.push({ signal: "logs", error });
|
|
991
|
-
logger.error(`${LOG_PREFIX} Log flush failed`, {
|
|
992
|
-
error: error instanceof Error ? error.message : String(error),
|
|
993
|
-
stack: error instanceof Error ? error.stack : undefined,
|
|
994
|
-
});
|
|
995
|
-
}
|
|
996
|
-
}
|
|
997
|
-
else {
|
|
998
|
-
logger.debug(`${LOG_PREFIX} No LoggerProvider to flush`);
|
|
999
|
-
}
|
|
1000
|
-
if (failures.length > 0) {
|
|
1001
|
-
throw new Error(`${LOG_PREFIX} Flush failed for: ${failures.map((f) => f.signal).join(", ")}`);
|
|
193
|
+
const results = await Promise.allSettled([
|
|
194
|
+
tracerProvider?.forceFlush(),
|
|
195
|
+
usingExternalProvider ? langfuseExporter?.flush() : undefined,
|
|
196
|
+
meterProvider?.forceFlush(),
|
|
197
|
+
]);
|
|
198
|
+
const failed = results.filter((result) => result.status === "rejected");
|
|
199
|
+
if (failed.length > 0) {
|
|
200
|
+
throw new Error(`${OTEL_LOG_PREFIX} Flush failed`, {
|
|
201
|
+
cause: failed.map((result) => result.reason),
|
|
202
|
+
});
|
|
1002
203
|
}
|
|
1003
|
-
logger.info(`${LOG_PREFIX} Flush complete`);
|
|
1004
204
|
}
|
|
1005
205
|
/**
|
|
1006
|
-
*
|
|
1007
|
-
*
|
|
1008
|
-
* Concurrent callers share one in-flight teardown, the same way concurrent
|
|
1009
|
-
* initializers share one in-flight `initializeOpenTelemetry`: a second
|
|
1010
|
-
* shutdown call joins the first instead of tearing down the same provider
|
|
1011
|
-
* instances a second time.
|
|
206
|
+
* @description Terminally shuts down the providers NeuroLink owns once no `acquireTelemetry` holder remains; while holders remain it only flushes. Telemetry cannot be restarted in this process afterwards. A host provider is never shut down; with one, only NeuroLink's Langfuse exporter is shut down and unregistered.
|
|
207
|
+
* @returns {Promise<void>} Resolves when telemetry is flushed or shut down.
|
|
1012
208
|
*/
|
|
1013
209
|
export async function shutdownOpenTelemetry() {
|
|
1014
|
-
if (
|
|
1015
|
-
await
|
|
210
|
+
if (holders > 0) {
|
|
211
|
+
await flushOpenTelemetry();
|
|
1016
212
|
return;
|
|
1017
213
|
}
|
|
1018
|
-
|
|
1019
|
-
shutdownPromise = pending;
|
|
1020
|
-
try {
|
|
1021
|
-
await pending;
|
|
1022
|
-
}
|
|
1023
|
-
finally {
|
|
1024
|
-
if (shutdownPromise === pending) {
|
|
1025
|
-
shutdownPromise = null;
|
|
1026
|
-
}
|
|
1027
|
-
}
|
|
1028
|
-
}
|
|
1029
|
-
async function shutdownOpenTelemetryOnce() {
|
|
1030
|
-
if (initializationPromise) {
|
|
1031
|
-
try {
|
|
1032
|
-
await initializationPromise;
|
|
1033
|
-
}
|
|
1034
|
-
catch {
|
|
1035
|
-
// Initialization owns its error logging. Continue so any provider it
|
|
1036
|
-
// created before failing is still shut down below.
|
|
1037
|
-
}
|
|
1038
|
-
}
|
|
1039
|
-
try {
|
|
1040
|
-
// Only shutdown tracerProvider if we created it
|
|
1041
|
-
if (tracerProvider && !usingExternalProvider) {
|
|
1042
|
-
await tracerProvider.shutdown();
|
|
1043
|
-
}
|
|
1044
|
-
// Always shutdown the Langfuse processor
|
|
1045
|
-
if (langfuseProcessor) {
|
|
1046
|
-
await langfuseProcessor.shutdown();
|
|
1047
|
-
}
|
|
1048
|
-
// Shutdown cached ContextEnricher
|
|
1049
|
-
if (cachedContextEnricher) {
|
|
1050
|
-
await cachedContextEnricher.shutdown();
|
|
1051
|
-
}
|
|
1052
|
-
// Shutdown MeterProvider if we created it
|
|
1053
|
-
if (meterProvider) {
|
|
1054
|
-
await meterProvider.shutdown();
|
|
1055
|
-
}
|
|
1056
|
-
// Shutdown LoggerProvider if we created it
|
|
1057
|
-
if (loggerProvider) {
|
|
1058
|
-
await loggerProvider.shutdown();
|
|
1059
|
-
}
|
|
1060
|
-
tracerProvider = null;
|
|
1061
|
-
meterProvider = null;
|
|
1062
|
-
loggerProvider = null;
|
|
1063
|
-
langfuseProcessor = null;
|
|
1064
|
-
cachedContextEnricher = null;
|
|
1065
|
-
isInitialized = false;
|
|
1066
|
-
isCredentialsValid = false;
|
|
1067
|
-
usingExternalProvider = false;
|
|
1068
|
-
logger.debug(`${LOG_PREFIX} Shutdown complete`);
|
|
1069
|
-
}
|
|
1070
|
-
catch (error) {
|
|
1071
|
-
logger.error(`${LOG_PREFIX} Shutdown failed`, {
|
|
1072
|
-
error: error instanceof Error ? error.message : String(error),
|
|
1073
|
-
});
|
|
1074
|
-
}
|
|
1075
|
-
}
|
|
1076
|
-
/**
|
|
1077
|
-
* Get the Langfuse span processor
|
|
1078
|
-
*/
|
|
1079
|
-
export function getLangfuseSpanProcessor() {
|
|
1080
|
-
return langfuseProcessor;
|
|
214
|
+
await stop();
|
|
1081
215
|
}
|
|
1082
216
|
/**
|
|
1083
|
-
*
|
|
1084
|
-
|
|
1085
|
-
export function getTracerProvider() {
|
|
1086
|
-
return tracerProvider;
|
|
1087
|
-
}
|
|
1088
|
-
/**
|
|
1089
|
-
* Get the logger provider for emitting OTLP log records.
|
|
1090
|
-
* Returns null if OTLP is not configured or LoggerProvider was not created.
|
|
1091
|
-
*/
|
|
1092
|
-
export function getLoggerProvider() {
|
|
1093
|
-
return loggerProvider;
|
|
1094
|
-
}
|
|
1095
|
-
/**
|
|
1096
|
-
* Check if OpenTelemetry is initialized
|
|
217
|
+
* @description Reports whether NeuroLink's tracing is active, either on its own provider or alongside a host provider.
|
|
218
|
+
* @returns {boolean} `true` once `ensureTelemetry` has set tracing up.
|
|
1097
219
|
*/
|
|
1098
220
|
export function isOpenTelemetryInitialized() {
|
|
1099
|
-
return
|
|
221
|
+
return initialized;
|
|
1100
222
|
}
|
|
1101
223
|
/**
|
|
1102
|
-
*
|
|
1103
|
-
*
|
|
1104
|
-
* @returns Health status object with initialization and configuration details
|
|
224
|
+
* @description Summarizes the Langfuse export state for health checks.
|
|
225
|
+
* @returns {{ isHealthy: boolean; initialized: boolean; hasProcessor: boolean; usingExternalProvider: boolean; error?: string }} `isHealthy` and `hasProcessor` are `true` only while a Langfuse exporter is registered, on NeuroLink's own provider or beside a host provider (where it still needs `createExporterSpanProcessor()` on the host provider to receive spans); `error` carries the message of a failed initialization.
|
|
1105
226
|
*/
|
|
1106
227
|
export function getLangfuseHealthStatus() {
|
|
1107
228
|
return {
|
|
1108
|
-
isHealthy:
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
langfuseProcessor !== null),
|
|
1112
|
-
initialized: isInitialized,
|
|
1113
|
-
credentialsValid: isCredentialsValid,
|
|
1114
|
-
enabled: currentConfig?.enabled || false,
|
|
1115
|
-
hasProcessor: langfuseProcessor !== null,
|
|
229
|
+
isHealthy: initialized && langfuseExporter !== null,
|
|
230
|
+
initialized,
|
|
231
|
+
hasProcessor: langfuseExporter !== null,
|
|
1116
232
|
usingExternalProvider,
|
|
1117
|
-
|
|
1118
|
-
? {
|
|
1119
|
-
baseUrl: currentConfig.baseUrl || "https://cloud.langfuse.com",
|
|
1120
|
-
environment: currentConfig.environment || "dev",
|
|
1121
|
-
release: currentConfig.release || "v1.0.0",
|
|
1122
|
-
}
|
|
1123
|
-
: undefined,
|
|
233
|
+
...(initializationError !== null && { error: initializationError }),
|
|
1124
234
|
};
|
|
1125
235
|
}
|
|
1126
236
|
/**
|
|
1127
|
-
*
|
|
1128
|
-
*
|
|
1129
|
-
*
|
|
1130
|
-
* the context is scoped to that callback execution and returns the callback's result.
|
|
1131
|
-
* Without a callback, the context applies to the current execution context and its children.
|
|
1132
|
-
*
|
|
1133
|
-
* Uses AsyncLocalStorage to properly scope context per request, avoiding race conditions
|
|
1134
|
-
* in concurrent scenarios.
|
|
1135
|
-
*
|
|
1136
|
-
* @param context - Object containing context fields to merge with existing context
|
|
1137
|
-
* @param callback - Optional callback to run within the context scope. If omitted, context applies to current execution
|
|
1138
|
-
* @returns The callback's return value if provided, otherwise void
|
|
1139
|
-
*
|
|
1140
|
-
* @example
|
|
1141
|
-
* // With callback - returns the result
|
|
1142
|
-
* const result = await setLangfuseContext({ userId: "user123" }, async () => {
|
|
1143
|
-
* return await generateText({ model: "gpt-4", prompt: "Hello" });
|
|
1144
|
-
* });
|
|
1145
|
-
*
|
|
1146
|
-
* @example
|
|
1147
|
-
* // Without callback - sets context for current execution
|
|
1148
|
-
* await setLangfuseContext({ sessionId: "session456", traceName: "chat-completion" });
|
|
237
|
+
* @description Reads the full trace metadata that `setLangfuseContext` stored on a context.
|
|
238
|
+
* @param ctx {Context} - Context to read, normally the active one.
|
|
239
|
+
* @returns {Record<string, SpanContent>} Metadata set by enclosing `setLangfuseContext` calls, inner keys winning; empty when none was set.
|
|
1149
240
|
*/
|
|
1150
|
-
export
|
|
1151
|
-
const
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
? context.sessionId
|
|
1156
|
-
: currentContext.sessionId,
|
|
1157
|
-
conversationId: context.conversationId !== undefined
|
|
1158
|
-
? context.conversationId
|
|
1159
|
-
: currentContext.conversationId,
|
|
1160
|
-
requestId: context.requestId !== undefined
|
|
1161
|
-
? context.requestId
|
|
1162
|
-
: currentContext.requestId,
|
|
1163
|
-
traceName: context.traceName !== undefined
|
|
1164
|
-
? context.traceName
|
|
1165
|
-
: currentContext.traceName,
|
|
1166
|
-
metadata: context.metadata !== undefined
|
|
1167
|
-
? context.metadata
|
|
1168
|
-
: currentContext.metadata,
|
|
1169
|
-
tags: context.tags !== undefined ? context.tags : currentContext.tags,
|
|
1170
|
-
// Operation name support
|
|
1171
|
-
operationName: context.operationName !== undefined
|
|
1172
|
-
? context.operationName
|
|
1173
|
-
: currentContext.operationName,
|
|
1174
|
-
autoDetectOperationName: context.autoDetectOperationName !== undefined
|
|
1175
|
-
? context.autoDetectOperationName
|
|
1176
|
-
: currentContext.autoDetectOperationName,
|
|
1177
|
-
// Custom attributes support
|
|
1178
|
-
customAttributes: context.customAttributes !== undefined
|
|
1179
|
-
? context.customAttributes
|
|
1180
|
-
: currentContext.customAttributes,
|
|
1181
|
-
};
|
|
1182
|
-
if (callback) {
|
|
1183
|
-
return await contextStorage.run(newContext, callback);
|
|
1184
|
-
}
|
|
1185
|
-
else {
|
|
1186
|
-
contextStorage.enterWith(newContext);
|
|
1187
|
-
}
|
|
241
|
+
export function traceMetadataFromContext(ctx) {
|
|
242
|
+
const stored = ctx.getValue(TRACE_METADATA_KEY);
|
|
243
|
+
return stored && typeof stored === "object"
|
|
244
|
+
? stored
|
|
245
|
+
: {};
|
|
1188
246
|
}
|
|
1189
247
|
/**
|
|
1190
|
-
*
|
|
1191
|
-
*
|
|
1192
|
-
*
|
|
1193
|
-
*
|
|
1194
|
-
*
|
|
1195
|
-
* @returns The current LangfuseContext or undefined
|
|
1196
|
-
*
|
|
248
|
+
* @description Runs `fn` with trace attributes propagated to every span it creates. `conversationId` and `requestId` become metadata; empty values are dropped. Metadata values of up to 200 characters once stringified are on every observation; longer values and arrays are recorded in full on the trace only, written by the trace root.
|
|
249
|
+
* @param params {NeurolinkTraceContext} - User, session, trace name, tags and metadata for the trace.
|
|
250
|
+
* @param fn {() => T} - Work to run inside the propagated context.
|
|
251
|
+
* @returns {T} Whatever `fn` returns.
|
|
1197
252
|
* @example
|
|
1198
|
-
*
|
|
1199
|
-
* console.log(context?.userId, context?.sessionId);
|
|
253
|
+
* await setLangfuseContext({ userId: "u1", sessionId: "s1" }, () => neurolink.generate({ input: { text: "hi" } }));
|
|
1200
254
|
*/
|
|
1201
|
-
export function
|
|
1202
|
-
|
|
1203
|
-
}
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
}
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
if (userId && !ambient?.userId) {
|
|
1232
|
-
span.setAttribute("user.id", userId);
|
|
1233
|
-
}
|
|
1234
|
-
if (sessionId && !ambient?.sessionId) {
|
|
1235
|
-
span.setAttribute("session.id", sessionId);
|
|
1236
|
-
}
|
|
1237
|
-
}
|
|
1238
|
-
/**
|
|
1239
|
-
* Capture the current Langfuse AsyncLocalStorage context and return a wrapper
|
|
1240
|
-
* that re-enters that context when executing the provided callback.
|
|
1241
|
-
*
|
|
1242
|
-
* This is essential for preserving trace context across async boundaries that
|
|
1243
|
-
* break the automatic ALS propagation chain, such as `setImmediate()`,
|
|
1244
|
-
* `setTimeout()`, or event-emitter callbacks. Without this, spans created
|
|
1245
|
-
* inside those callbacks become orphaned traces in Langfuse.
|
|
1246
|
-
*
|
|
1247
|
-
* **How it works:**
|
|
1248
|
-
* 1. Captures the current ALS store at call time (synchronously).
|
|
1249
|
-
* 2. Returns an async function that, when invoked, re-enters the captured
|
|
1250
|
-
* context via `contextStorage.run()` before executing the callback.
|
|
1251
|
-
* 3. If no context exists at capture time, the callback runs without
|
|
1252
|
-
* ALS wrapping (no-op passthrough).
|
|
1253
|
-
*
|
|
1254
|
-
* @param fn - The async function to execute within the captured context
|
|
1255
|
-
* @returns A new async function that preserves the Langfuse ALS context
|
|
1256
|
-
*
|
|
1257
|
-
* @example
|
|
1258
|
-
* // Before (broken — setImmediate loses ALS context):
|
|
1259
|
-
* setImmediate(async () => {
|
|
1260
|
-
* await this.checkAndSummarize(session, threshold);
|
|
1261
|
-
* });
|
|
1262
|
-
*
|
|
1263
|
-
* // After (fixed — context is captured and re-entered):
|
|
1264
|
-
* const wrappedFn = runWithCurrentLangfuseContext(async () => {
|
|
1265
|
-
* await this.checkAndSummarize(session, threshold);
|
|
1266
|
-
* });
|
|
1267
|
-
* setImmediate(wrappedFn);
|
|
1268
|
-
*/
|
|
1269
|
-
export function runWithCurrentLangfuseContext(fn) {
|
|
1270
|
-
const capturedContext = contextStorage.getStore();
|
|
1271
|
-
if (capturedContext) {
|
|
1272
|
-
return () => contextStorage.run(capturedContext, fn);
|
|
1273
|
-
}
|
|
1274
|
-
// No context to preserve — return the function as-is
|
|
1275
|
-
return fn;
|
|
1276
|
-
}
|
|
1277
|
-
/**
|
|
1278
|
-
* Get an OpenTelemetry Tracer for creating custom spans
|
|
1279
|
-
*
|
|
1280
|
-
* This allows applications to create their own spans that will be
|
|
1281
|
-
* processed by the same span processors (ContextEnricher + LangfuseSpanProcessor).
|
|
1282
|
-
*
|
|
1283
|
-
* @param name - Tracer name, defaults to "neurolink"
|
|
1284
|
-
* @param version - Tracer version, optional
|
|
1285
|
-
* @returns OpenTelemetry Tracer instance
|
|
1286
|
-
*
|
|
1287
|
-
* @example
|
|
1288
|
-
* const tracer = getTracer("my-app");
|
|
1289
|
-
* const span = tracer.startSpan("custom-operation");
|
|
1290
|
-
* try {
|
|
1291
|
-
* // ... do work
|
|
1292
|
-
* } finally {
|
|
1293
|
-
* span.end();
|
|
1294
|
-
* }
|
|
1295
|
-
*/
|
|
1296
|
-
export function getTracer(name = "neurolink", version) {
|
|
1297
|
-
return trace.getTracer(name, version);
|
|
1298
|
-
}
|
|
1299
|
-
/**
|
|
1300
|
-
* Create a new ContextEnricher span processor
|
|
1301
|
-
* Use this when useExternalTracerProvider is true to add to your own TracerProvider
|
|
1302
|
-
*
|
|
1303
|
-
* @returns A new ContextEnricher instance
|
|
1304
|
-
*/
|
|
1305
|
-
export function createContextEnricher() {
|
|
1306
|
-
return new ContextEnricher();
|
|
1307
|
-
}
|
|
1308
|
-
/**
|
|
1309
|
-
* Get all span processors that NeuroLink would use
|
|
1310
|
-
* Convenience function that returns [ContextEnricher, LangfuseSpanProcessor]
|
|
1311
|
-
*
|
|
1312
|
-
* @returns Array of span processors, or empty array if not initialized
|
|
1313
|
-
*/
|
|
1314
|
-
export function getSpanProcessors() {
|
|
1315
|
-
if (!isInitialized || !langfuseProcessor) {
|
|
1316
|
-
logger.warn(`${LOG_PREFIX} getSpanProcessors called but not initialized`);
|
|
1317
|
-
return [];
|
|
1318
|
-
}
|
|
1319
|
-
// Reuse cached ContextEnricher to avoid creating multiple instances
|
|
1320
|
-
if (!cachedContextEnricher) {
|
|
1321
|
-
cachedContextEnricher = new ContextEnricher();
|
|
1322
|
-
}
|
|
1323
|
-
return [cachedContextEnricher, langfuseProcessor];
|
|
1324
|
-
}
|
|
1325
|
-
/**
|
|
1326
|
-
* Check if using external TracerProvider mode
|
|
1327
|
-
*
|
|
1328
|
-
* @returns true if operating in external TracerProvider mode
|
|
1329
|
-
*/
|
|
1330
|
-
export function isUsingExternalTracerProvider() {
|
|
1331
|
-
return usingExternalProvider;
|
|
255
|
+
export function setLangfuseContext(params, fn) {
|
|
256
|
+
const full = {};
|
|
257
|
+
const propagated = {};
|
|
258
|
+
for (const [key, value] of Object.entries({
|
|
259
|
+
...params.metadata,
|
|
260
|
+
conversationId: params.conversationId,
|
|
261
|
+
requestId: params.requestId,
|
|
262
|
+
})) {
|
|
263
|
+
if (value === undefined || value === null || value === "") {
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
full[key] = value;
|
|
267
|
+
const text = spanJsonAttribute(value, Number.POSITIVE_INFINITY);
|
|
268
|
+
if (text.length <= MAX_PROPAGATED_CHARS) {
|
|
269
|
+
propagated[key] = text;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
const tags = params.tags?.filter((tag) => tag.length > 0);
|
|
273
|
+
const active = context.active();
|
|
274
|
+
const withMetadata = active.setValue(TRACE_METADATA_KEY, {
|
|
275
|
+
...traceMetadataFromContext(active),
|
|
276
|
+
...full,
|
|
277
|
+
});
|
|
278
|
+
return context.with(withMetadata, () => propagateAttributes({
|
|
279
|
+
userId: params.userId || undefined,
|
|
280
|
+
sessionId: params.sessionId || undefined,
|
|
281
|
+
traceName: params.traceName || undefined,
|
|
282
|
+
...(tags && tags.length > 0 && { tags }),
|
|
283
|
+
...(Object.keys(propagated).length > 0 && { metadata: propagated }),
|
|
284
|
+
}, fn));
|
|
1332
285
|
}
|