diff --git a/packages/components/src/handler.test.ts b/packages/components/src/handler.test.ts index e0ebd97ef4e..a1fe8688dd1 100644 --- a/packages/components/src/handler.test.ts +++ b/packages/components/src/handler.test.ts @@ -1,5 +1,6 @@ import { OTLPTraceExporter as ProtoOTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto' -import { getPhoenixTracer } from './handler' +import { getPhoenixTracer, AnalyticHandler } from './handler' +import { resetTracingEnvCache } from './tracingEnv' jest.mock('@opentelemetry/exporter-trace-otlp-proto', () => { return { @@ -9,6 +10,42 @@ jest.mock('@opentelemetry/exporter-trace-otlp-proto', () => { } }) +// Track every RunTree constructed so tests can assert per-instance lifecycle calls. +// Prefixed with `mock` so Jest allows referencing it from inside the mock factory. +const mockRunTreeInstances: Array<{ + id: string + config: any + postRun: jest.Mock + patchRun: jest.Mock + end: jest.Mock + createChild: jest.Mock +}> = [] + +jest.mock('langsmith', () => { + let counter = 0 + class MockRunTree { + id: string + config: any + postRun: jest.Mock + patchRun: jest.Mock + end: jest.Mock + createChild: jest.Mock + constructor(config: any) { + this.config = config + this.id = `run-${++counter}` + this.postRun = jest.fn().mockResolvedValue(undefined) + this.patchRun = jest.fn().mockResolvedValue(undefined) + this.end = jest.fn().mockResolvedValue(undefined) + this.createChild = jest.fn().mockImplementation(async (childCfg: any) => new MockRunTree(childCfg)) + mockRunTreeInstances.push(this as any) + } + } + return { + Client: jest.fn().mockImplementation(() => ({})), + RunTree: MockRunTree + } +}) + describe('URL Handling For Phoenix Tracer', () => { const apiKey = 'test-api-key' const projectName = 'test-project-name' @@ -331,3 +368,69 @@ describe('onLLMEnd Usage Metadata Extraction Logic', () => { }) }) }) + +/** + * Regression coverage for the chainRun map clobbering bug. + * + * AnalyticHandler is a per-chatId singleton, so recursive executeAgentFlow calls (e.g. an + * iterationAgentflow sub-flow) re-enter `onChainStart` on the same instance. The previous + * implementation replaced `this.handlers['langSmith'].chainRun` wholesale on every call, which + * dropped the outer call's parent RunTree. When control returned to the outer flow, its + * `onChainEnd` lookup missed and the outer run was never `patchRun`-ed — leaving an open run in + * LangSmith and breaking context propagation for any subsequent nodes. + */ +describe('AnalyticHandler chainRun map on recursive onChainStart', () => { + const LANGSMITH_KEYS = ['LANGSMITH_TRACING', 'LANGSMITH_API_KEY', 'LANGSMITH_ENDPOINT', 'LANGSMITH_PROJECT'] as const + let envSnapshot: Partial> + + beforeEach(() => { + envSnapshot = {} + for (const k of LANGSMITH_KEYS) envSnapshot[k] = process.env[k] + for (const k of LANGSMITH_KEYS) delete process.env[k] + process.env.LANGSMITH_TRACING = 'true' + process.env.LANGSMITH_API_KEY = 'test-key' + resetTracingEnvCache() + mockRunTreeInstances.length = 0 + }) + + afterEach(() => { + for (const k of LANGSMITH_KEYS) delete process.env[k] + for (const k of LANGSMITH_KEYS) { + const v = envSnapshot[k] + if (v !== undefined) process.env[k] = v + } + resetTracingEnvCache() + AnalyticHandler.resetInstance('recursive-chain-test') + }) + + it('preserves the outer parent RunTree when a nested onChainStart runs on the same instance', async () => { + const chatId = 'recursive-chain-test' + const handler = AnalyticHandler.getInstance({ inputs: {} } as any, { chatId }) + await handler.init() + + // Outer flow's onChainStart — simulates the first executeAgentFlow call. + const outerIds = await handler.onChainStart('OuterFlow', 'outer input') + expect(outerIds.langSmith.chainRun).toBeDefined() + + // Recursive executeAgentFlow (iterationAgentflow) re-enters on the same singleton. + // No parentIds passed, matching the real call site in buildAgentflow.ts. + const innerIds = await handler.onChainStart('InnerFlow', 'inner input') + expect(innerIds.langSmith.chainRun).toBeDefined() + expect(innerIds.langSmith.chainRun).not.toBe(outerIds.langSmith.chainRun) + + // Inner flow completes first. + await handler.onChainEnd(innerIds, 'inner output') + + // Outer flow must still be able to end its own run. + await handler.onChainEnd(outerIds, 'outer output', true) + + // Both the outer and inner RunTree instances should have been patched exactly once — + // before the fix, the outer's patchRun was skipped because the map entry was overwritten. + expect(mockRunTreeInstances).toHaveLength(2) + const [outerRun, innerRun] = mockRunTreeInstances + expect(outerRun.patchRun).toHaveBeenCalledTimes(1) + expect(innerRun.patchRun).toHaveBeenCalledTimes(1) + expect(outerRun.end).toHaveBeenCalledWith({ outputs: { output: 'outer output' } }) + expect(innerRun.end).toHaveBeenCalledWith({ outputs: { output: 'inner output' } }) + }) +}) diff --git a/packages/components/src/handler.ts b/packages/components/src/handler.ts index 9cd8167e8d7..ffb34e23646 100644 --- a/packages/components/src/handler.ts +++ b/packages/components/src/handler.ts @@ -16,7 +16,6 @@ import { Resource } from '@opentelemetry/resources' import { SimpleSpanProcessor, Tracer } from '@opentelemetry/sdk-trace-base' import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node' import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions' - import { BaseCallbackHandler, NewTokenIndices, HandleLLMNewTokenCallbackFields } from '@langchain/core/callbacks/base' import * as CallbackManagerModule from '@langchain/core/callbacks/manager' import { LangChainTracer, LangChainTracerFields } from '@langchain/core/tracers/tracer_langchain' @@ -26,6 +25,7 @@ import { AgentAction } from '@langchain/core/agents' import { LunaryHandler } from '@langchain/community/callbacks/handlers/lunary' import { getCredentialData, getCredentialParam, getEnvironmentVariable } from './utils' +import { applyEnvTracingProviders, tracingEnvEnabled } from './tracingEnv' import { EvaluationRunTracer } from '../evaluation/EvaluationRunTracer' import { EvaluationRunTracerLlama } from '../evaluation/EvaluationRunTracerLlama' import { ICommonObject, IDatabaseEntity, INodeData, IServerSideEventStreamer } from './Interface' @@ -515,16 +515,17 @@ class ExtendedLunaryHandler extends LunaryHandler { export const additionalCallbacks = async (nodeData: INodeData, options: ICommonObject) => { try { - if (!options.analytic) return [] + if (!options.analytic && !tracingEnvEnabled()) return [] - const analytic = JSON.parse(options.analytic) + const initial = options.analytic ? JSON.parse(options.analytic) : {} + const { analytic, envCredentials } = applyEnvTracingProviders(initial) const callbacks: any = [] for (const provider in analytic) { const providerStatus = analytic[provider].status as boolean if (providerStatus) { - const credentialId = analytic[provider].credentialId as string - const credentialData = await getCredentialData(credentialId ?? '', options) + const credentialData = + envCredentials[provider] ?? (await getCredentialData((analytic[provider].credentialId as string) ?? '', options)) if (provider === 'langSmith') { const langSmithProject = analytic[provider].projectName as string @@ -774,23 +775,31 @@ export class AnalyticHandler { if (this.initialized) return try { - if (!this.options.analytic) return + const hasAnalyticsConfig = Boolean(this.options.analytic) + if (!hasAnalyticsConfig && !tracingEnvEnabled()) return - const analytic = JSON.parse(this.options.analytic) + const initial = hasAnalyticsConfig ? JSON.parse(this.options.analytic) : {} + const { analytic, envCredentials } = applyEnvTracingProviders(initial) for (const provider in analytic) { const providerStatus = analytic[provider].status as boolean if (providerStatus) { - const credentialId = analytic[provider].credentialId as string - const credentialData = await getCredentialData(credentialId ?? '', this.options) + const credentialData = + envCredentials[provider] ?? + (await getCredentialData((analytic[provider].credentialId as string) ?? '', this.options)) await this.initializeProvider(provider, analytic[provider], credentialData) } } + this.initialized = true } catch (e) { throw new Error(e) } } + hasActiveProviders(): boolean { + return Object.keys(this.handlers).length > 0 + } + // Add getter for handlers (useful for debugging) getHandlers(): ICommonObject { return this.handlers @@ -807,7 +816,10 @@ export class AnalyticHandler { apiKey: langSmithApiKey }) - this.handlers['langSmith'] = { client, langSmithProject } + this.handlers['langSmith'] = { + client, + langSmithProject + } } else if (provider === 'langFuse') { const release = providerConfig.release as string const langFuseSecretKey = getCredentialParam('langFuseSecretKey', credentialData, this.nodeData) @@ -927,7 +939,13 @@ export class AnalyticHandler { } const parentRun = new RunTree(parentRunConfig) await parentRun.postRun() - this.handlers['langSmith'].chainRun = { [parentRun.id]: parentRun } + // Merge rather than replace: AnalyticHandler is a per-chatId singleton, so recursive + // executeAgentFlow calls (e.g. iterationAgentflow) re-enter onChainStart on the same + // instance. Replacing would drop the outer call's parent entry and orphan its trace. + this.handlers['langSmith'].chainRun = { + ...this.handlers['langSmith'].chainRun, + [parentRun.id]: parentRun + } returnIds['langSmith'].chainRun = parentRun.id } else { const parentRun: RunTree | undefined = this.handlers['langSmith'].chainRun[parentIds['langSmith'].chainRun] @@ -940,7 +958,10 @@ export class AnalyticHandler { } }) await childChainRun.postRun() - this.handlers['langSmith'].chainRun = { [childChainRun.id]: childChainRun } + this.handlers['langSmith'].chainRun = { + ...this.handlers['langSmith'].chainRun, + [childChainRun.id]: childChainRun + } returnIds['langSmith'].chainRun = childChainRun.id } } diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index 6b77c943e4d..467a4e11676 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -11,6 +11,7 @@ export * from './textToSpeech' export * from './storageUtils' export * from './storage' export * from './handler' +export { tracingEnvEnabled } from './tracingEnv' export * from '../evaluation/EvaluationRunner' export * from './followUpPrompts' export * from './validator' diff --git a/packages/components/src/tracingEnv.test.ts b/packages/components/src/tracingEnv.test.ts new file mode 100644 index 00000000000..bbea684e814 --- /dev/null +++ b/packages/components/src/tracingEnv.test.ts @@ -0,0 +1,269 @@ +import { + getLangSmithEnvConfig, + resetTracingEnvCache, + TRACING_ENV_PROVIDERS, + tracingEnvEnabled, + applyEnvTracingProviders +} from './tracingEnv' + +const ENV_KEYS = [ + 'LANGSMITH_TRACING', + 'LANGCHAIN_TRACING_V2', + 'LANGSMITH_API_KEY', + 'LANGCHAIN_API_KEY', + 'LANGSMITH_ENDPOINT', + 'LANGCHAIN_ENDPOINT', + 'LANGSMITH_PROJECT', + 'LANGCHAIN_PROJECT' +] as const + +const clearEnv = () => { + for (const k of ENV_KEYS) delete process.env[k] +} + +let envSnapshot: Partial> + +beforeEach(() => { + envSnapshot = {} + for (const k of ENV_KEYS) envSnapshot[k] = process.env[k] + clearEnv() + resetTracingEnvCache() +}) + +afterEach(() => { + clearEnv() + for (const k of ENV_KEYS) { + const v = envSnapshot[k] + if (v !== undefined) process.env[k] = v + } + resetTracingEnvCache() +}) + +describe('getLangSmithEnvConfig', () => { + it('returns undefined when no tracing flag is set', () => { + expect(getLangSmithEnvConfig()).toBeUndefined() + }) + + it("returns undefined when tracing flag is not exactly 'true'", () => { + process.env.LANGSMITH_TRACING = 'false' + process.env.LANGSMITH_API_KEY = 'k' + expect(getLangSmithEnvConfig()).toBeUndefined() + + process.env.LANGSMITH_TRACING = '1' + expect(getLangSmithEnvConfig()).toBeUndefined() + + process.env.LANGSMITH_TRACING = 'TRUE' + expect(getLangSmithEnvConfig()).toBeUndefined() + }) + + it('returns undefined when tracing flag is true but no api key is set', () => { + process.env.LANGSMITH_TRACING = 'true' + expect(getLangSmithEnvConfig()).toBeUndefined() + }) + + it('returns config with only apiKey when flag + new key are set', () => { + process.env.LANGSMITH_TRACING = 'true' + process.env.LANGSMITH_API_KEY = 'new-key' + expect(getLangSmithEnvConfig()).toEqual({ + apiKey: 'new-key', + endpoint: undefined, + projectName: undefined + }) + }) + + it('honors legacy LANGCHAIN_* vars alone', () => { + process.env.LANGCHAIN_TRACING_V2 = 'true' + process.env.LANGCHAIN_API_KEY = 'legacy-key' + process.env.LANGCHAIN_ENDPOINT = 'https://legacy.example.com' + process.env.LANGCHAIN_PROJECT = 'legacy-proj' + expect(getLangSmithEnvConfig()).toEqual({ + apiKey: 'legacy-key', + endpoint: 'https://legacy.example.com', + projectName: 'legacy-proj' + }) + }) + + it('prefers new LANGSMITH_* vars over legacy LANGCHAIN_* when both are set', () => { + process.env.LANGSMITH_TRACING = 'true' + process.env.LANGCHAIN_TRACING_V2 = 'true' + process.env.LANGSMITH_API_KEY = 'new-key' + process.env.LANGCHAIN_API_KEY = 'legacy-key' + process.env.LANGSMITH_ENDPOINT = 'https://new.example.com' + process.env.LANGCHAIN_ENDPOINT = 'https://legacy.example.com' + process.env.LANGSMITH_PROJECT = 'new-proj' + process.env.LANGCHAIN_PROJECT = 'legacy-proj' + expect(getLangSmithEnvConfig()).toEqual({ + apiKey: 'new-key', + endpoint: 'https://new.example.com', + projectName: 'new-proj' + }) + }) + + it('falls back to legacy api key when only new tracing flag is set without new api key', () => { + process.env.LANGSMITH_TRACING = 'true' + process.env.LANGCHAIN_API_KEY = 'legacy-key' + expect(getLangSmithEnvConfig()).toEqual({ + apiKey: 'legacy-key', + endpoint: undefined, + projectName: undefined + }) + }) + + it('includes endpoint and projectName when provided via new vars', () => { + process.env.LANGSMITH_TRACING = 'true' + process.env.LANGSMITH_API_KEY = 'k' + process.env.LANGSMITH_ENDPOINT = 'https://api.smith.langchain.com' + process.env.LANGSMITH_PROJECT = 'my-proj' + expect(getLangSmithEnvConfig()).toEqual({ + apiKey: 'k', + endpoint: 'https://api.smith.langchain.com', + projectName: 'my-proj' + }) + }) +}) + +describe('memoized getEnvConfig via TRACING_ENV_PROVIDERS', () => { + const langSmith = TRACING_ENV_PROVIDERS.find((p) => p.name === 'langSmith')! + + it('caches the resolved config across calls', () => { + process.env.LANGSMITH_TRACING = 'true' + process.env.LANGSMITH_API_KEY = 'k1' + const first = langSmith.getEnvConfig() + expect(first).toMatchObject({ apiKey: 'k1' }) + + // Mutating env after cache should not affect subsequent reads. + process.env.LANGSMITH_API_KEY = 'k2' + const second = langSmith.getEnvConfig() + expect(second).toMatchObject({ apiKey: 'k1' }) + }) + + it('caches undefined results too', () => { + expect(langSmith.getEnvConfig()).toBeUndefined() + + // Even after enabling env, the cached undefined sticks until reset. + process.env.LANGSMITH_TRACING = 'true' + process.env.LANGSMITH_API_KEY = 'k' + expect(langSmith.getEnvConfig()).toBeUndefined() + }) + + it('resetTracingEnvCache forces re-read of env on the next call', () => { + expect(langSmith.getEnvConfig()).toBeUndefined() + + process.env.LANGSMITH_TRACING = 'true' + process.env.LANGSMITH_API_KEY = 'k' + resetTracingEnvCache() + expect(langSmith.getEnvConfig()).toMatchObject({ apiKey: 'k' }) + + clearEnv() + resetTracingEnvCache() + expect(langSmith.getEnvConfig()).toBeUndefined() + }) +}) + +describe('TRACING_ENV_PROVIDERS langSmith.buildProviderEntry', () => { + const langSmith = TRACING_ENV_PROVIDERS.find((p) => p.name === 'langSmith')! + + it('returns full provider entry when endpoint and projectName are present', () => { + const entry = langSmith.buildProviderEntry({ + apiKey: 'k', + endpoint: 'https://e.example.com', + projectName: 'proj-x' + }) + expect(entry).toEqual({ + providerConfig: { projectName: 'proj-x', status: true }, + credentialData: { + langSmithApiKey: 'k', + langSmithEndpoint: 'https://e.example.com' + } + }) + }) + + it("defaults projectName to 'default' and omits langSmithEndpoint when endpoint is missing", () => { + const entry = langSmith.buildProviderEntry({ apiKey: 'k' }) + expect(entry).toEqual({ + providerConfig: { projectName: 'default', status: true }, + credentialData: { langSmithApiKey: 'k' } + }) + expect(entry.credentialData).not.toHaveProperty('langSmithEndpoint') + }) +}) + +describe('tracingEnvEnabled', () => { + it('returns false when no tracing env is configured', () => { + expect(tracingEnvEnabled()).toBe(false) + }) + + it('returns true when LangSmith tracing env is configured', () => { + process.env.LANGSMITH_TRACING = 'true' + process.env.LANGSMITH_API_KEY = 'k' + expect(tracingEnvEnabled()).toBe(true) + }) +}) + +describe('applyEnvTracingProviders', () => { + it('returns the analytic map unchanged with empty envCredentials when no env is set', () => { + const analytic = { other: { status: true } } + const result = applyEnvTracingProviders(analytic) + expect(result.analytic).toEqual({ other: { status: true } }) + expect(result.envCredentials).toEqual({}) + }) + + it('injects provider config and credentials when env is set and analytic has no entry', () => { + process.env.LANGSMITH_TRACING = 'true' + process.env.LANGSMITH_API_KEY = 'k' + process.env.LANGSMITH_ENDPOINT = 'https://e.example.com' + process.env.LANGSMITH_PROJECT = 'proj' + + const analytic: Record = {} + const result = applyEnvTracingProviders(analytic) + + expect(result.analytic.langSmith).toEqual({ + projectName: 'proj', + status: true + }) + expect(result.envCredentials).toEqual({ + langSmith: { + langSmithApiKey: 'k', + langSmithEndpoint: 'https://e.example.com' + } + }) + }) + + it('skips env injection when UI analytic entry already has status === true', () => { + process.env.LANGSMITH_TRACING = 'true' + process.env.LANGSMITH_API_KEY = 'env-key' + + const uiEntry = { projectName: 'ui-proj', status: true } + const analytic: Record = { langSmith: uiEntry } + const result = applyEnvTracingProviders(analytic) + + expect(result.analytic.langSmith).toBe(uiEntry) + expect(result.envCredentials).toEqual({}) + }) + + it('applies env when UI entry exists but status is not true', () => { + process.env.LANGSMITH_TRACING = 'true' + process.env.LANGSMITH_API_KEY = 'env-key' + + const analytic: Record = { langSmith: { projectName: 'ui-proj', status: false } } + const result = applyEnvTracingProviders(analytic) + + expect(result.analytic.langSmith).toEqual({ + projectName: 'default', + status: true + }) + expect(result.envCredentials.langSmith).toEqual({ langSmithApiKey: 'env-key' }) + }) + + it('does not mutate the passed-in analytic object', () => { + process.env.LANGSMITH_TRACING = 'true' + process.env.LANGSMITH_API_KEY = 'k' + + const analytic: Record = {} + const result = applyEnvTracingProviders(analytic) + + expect(result.analytic).not.toBe(analytic) + expect(analytic.langSmith).toBeUndefined() + expect(result.analytic.langSmith).toBeDefined() + }) +}) diff --git a/packages/components/src/tracingEnv.ts b/packages/components/src/tracingEnv.ts new file mode 100644 index 00000000000..5e200359fd6 --- /dev/null +++ b/packages/components/src/tracingEnv.ts @@ -0,0 +1,111 @@ +import { getEnvironmentVariable } from './utils' +import { ICommonObject } from './Interface' + +export interface TracingEnvProvider { + name: string + getEnvConfig: () => ICommonObject | undefined + /** + * Map env config to the `(providerConfig, credentialData)` pair the UI-driven analytics loop + * consumes, so env-var and UI sources flow through the same code path — no duplicate client + * construction. + */ + buildProviderEntry: (cfg: ICommonObject) => { providerConfig: ICommonObject; credentialData: ICommonObject } +} + +/** + * Process-lifetime cache for env-var tracing configs. + * + * Entries are populated on first access and never expire — a server restart is + * required to pick up env-var changes. This is intentional: env vars are set at + * deploy time and re-reading them on every agent execution would add overhead + * with no benefit. Use {@link resetTracingEnvCache} (test-only) to clear the + * cache in unit tests. + */ +const tracingEnvConfigCache = new Map() +const memoizeEnvConfig = (name: string, resolver: () => ICommonObject | undefined): (() => ICommonObject | undefined) => { + return () => { + const cached = tracingEnvConfigCache.get(name) + if (cached) return cached.value + const value = resolver() + tracingEnvConfigCache.set(name, { value }) + return value + } +} + +/** @internal Test-only: drop cached env-var tracing configs so a subsequent call re-reads env. */ +export const resetTracingEnvCache = (): void => { + tracingEnvConfigCache.clear() +} + +/** + * Env var flags that activate LangChain's built-in auto-tracer (see @langchain/core + * `isTracingEnabled()`). Once Flowise adopts the tracing config, these must be cleared from + * `process.env` so the auto-tracer doesn't emit duplicate top-level runs alongside Flowise's + * manual `onLLMStart`/`onToolStart` child RunTrees. + */ +const LANGCHAIN_TRACING_FLAG_VARS = ['LANGSMITH_TRACING', 'LANGCHAIN_TRACING_V2', 'LANGSMITH_TRACING_V2', 'LANGCHAIN_TRACING'] as const + +/** + * Reads LangSmith env vars (both new `LANGSMITH_*` and legacy `LANGCHAIN_*` prefixes). + * Returns a config object if tracing is enabled and an API key is present; otherwise undefined. + * + * Side effect: on a successful read, the four LangChain tracing-flag env vars are deleted from + * `process.env`. Flowise owns tracing emission from this point on; leaving the flags set would let + * LangChain's auto-tracer fire on every `.invoke()`/`.call()` and produce orphan top-level runs + * next to the manually-emitted parent/child RunTree. + */ +export const getLangSmithEnvConfig = (): { apiKey: string; endpoint?: string; projectName?: string } | undefined => { + const tracingFlag = getEnvironmentVariable('LANGSMITH_TRACING') ?? getEnvironmentVariable('LANGCHAIN_TRACING_V2') + if (tracingFlag !== 'true') return undefined + + const apiKey = getEnvironmentVariable('LANGSMITH_API_KEY') ?? getEnvironmentVariable('LANGCHAIN_API_KEY') + if (!apiKey) return undefined + + const endpoint = getEnvironmentVariable('LANGSMITH_ENDPOINT') ?? getEnvironmentVariable('LANGCHAIN_ENDPOINT') + const projectName = getEnvironmentVariable('LANGSMITH_PROJECT') ?? getEnvironmentVariable('LANGCHAIN_PROJECT') + + // the four LangChain tracing-flag env vars are deleted from `process.env`. Flowise owns tracing + // emission from this point on; leaving the flags set would letLangChain's auto-tracer fire on + // every `.invoke()`/`.call()` and produce orphan top-level runs next to the manually-emitted + // parent/child RunTree. + for (const k of LANGCHAIN_TRACING_FLAG_VARS) delete process.env[k] + + return { apiKey, endpoint, projectName } +} + +export const TRACING_ENV_PROVIDERS: TracingEnvProvider[] = [ + { + name: 'langSmith', + getEnvConfig: memoizeEnvConfig('langSmith', getLangSmithEnvConfig), + buildProviderEntry: (cfg) => ({ + providerConfig: { projectName: cfg.projectName ?? 'default', status: true }, + credentialData: { + langSmithApiKey: cfg.apiKey, + ...(cfg.endpoint ? { langSmithEndpoint: cfg.endpoint } : {}) + } + }) + } +] + +export const tracingEnvEnabled = (): boolean => TRACING_ENV_PROVIDERS.some((p) => p.getEnvConfig() !== undefined) + +/** + * Merge env-var-enabled tracing providers into the analytic map. UI config wins when both sources + * activate the same provider. Returns a credentialData map for env-injected providers so the loop + * can skip DB credential loading for those entries. + */ +export const applyEnvTracingProviders = ( + analytic: Record +): { analytic: Record; envCredentials: Record } => { + const envCredentials: Record = {} + const newEntries: Record = {} + for (const p of TRACING_ENV_PROVIDERS) { + const cfg = p.getEnvConfig() + if (!cfg) continue + if (analytic[p.name]?.status === true) continue + const { providerConfig, credentialData } = p.buildProviderEntry(cfg) + newEntries[p.name] = providerConfig + envCredentials[p.name] = credentialData + } + return { analytic: { ...analytic, ...newEntries }, envCredentials } +} diff --git a/packages/server/src/utils/buildAgentflow.ts b/packages/server/src/utils/buildAgentflow.ts index 6d1d937377c..3c4a451a17b 100644 --- a/packages/server/src/utils/buildAgentflow.ts +++ b/packages/server/src/utils/buildAgentflow.ts @@ -11,7 +11,8 @@ import { IMessage, IServerSideEventStreamer, convertChatHistoryToText, - generateFollowUpPrompts + generateFollowUpPrompts, + tracingEnvEnabled } from 'flowise-components' import { IncomingAgentflowInput, @@ -1894,7 +1895,7 @@ export const executeAgentFlow = async ({ let parentTraceIds: ICommonObject | undefined try { - if (chatflow.analytic) { + if (chatflow.analytic || tracingEnvEnabled()) { // Override config analytics let analyticInputs: ICommonObject = {} if (overrideConfig?.analytics && Object.keys(overrideConfig.analytics).length > 0) { @@ -1912,11 +1913,13 @@ export const executeAgentFlow = async ({ chatId }) await analyticHandlers.init() - const flowName = chatflow.name || 'Agentflow' - parentTraceIds = await analyticHandlers.onChainStart( - flowName, - form && Object.keys(form).length > 0 ? JSON.stringify(form) : question || '' - ) + if (analyticHandlers?.hasActiveProviders()) { + const flowName = chatflow.name || 'Agentflow' + parentTraceIds = await analyticHandlers.onChainStart( + flowName, + form && Object.keys(form).length > 0 ? JSON.stringify(form) : question || '' + ) + } } } catch (error) { logger.error(`[server]: Error initializing analytic handlers: ${getErrorMessage(error)}`)