feat: Add Opik Tracer integration (#4194)

This update adds support for Opik tracer in Flowise. It includes new
credential components, analytic nodes, and UI assets for both tracers. The
changes enhance observability by allowing users to integrate Opik for better
model monitoring, analysis and evaluation.
This commit is contained in:
Boris Feld 2025-04-03 09:57:54 +02:00 committed by GitHub
parent a07546145d
commit dd56d03b78
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 311 additions and 2 deletions

View File

@ -0,0 +1,39 @@
import { INodeParams, INodeCredential } from '../src/Interface'
class OpikApi implements INodeCredential {
label: string
name: string
version: number
description: string
inputs: INodeParams[]
constructor() {
this.label = 'Opik API'
this.name = 'opikApi'
this.version = 1.0
this.description =
'Refer to <a target="_blank" href="https://www.comet.com/docs/opik/tracing/sdk_configuration">Opik documentation</a> on how to configure Opik credentials'
this.inputs = [
{
label: 'API Key',
name: 'opikApiKey',
type: 'password',
placeholder: '<OPIK_API_KEY>'
},
{
label: 'URL',
name: 'opikUrl',
type: 'string',
placeholder: 'https://www.comet.com/opik/api'
},
{
label: 'Workspace',
name: 'opikWorkspace',
type: 'string',
placeholder: 'default'
}
]
}
}
module.exports = { credClass: OpikApi }

View File

@ -0,0 +1,33 @@
import { INode, INodeParams } from '../../../src/Interface'
class Opik_Analytic implements INode {
label: string
name: string
version: number
description: string
type: string
icon: string
category: string
baseClasses: string[]
inputs?: INodeParams[]
credential: INodeParams
constructor() {
this.label = 'Opik'
this.name = 'opik'
this.version = 1.0
this.type = 'Opik'
this.icon = 'opik.png'
this.category = 'Analytic'
this.baseClasses = [this.type]
this.inputs = []
this.credential = {
label: 'Connect Credential',
name: 'credential',
type: 'credential',
credentialNames: ['opikApi']
}
}
}
module.exports = { nodeClass: Opik_Analytic }

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -121,6 +121,50 @@ function getPhoenixTracer(options: PhoenixTracerOptions): Tracer | undefined {
}
}
interface OpikTracerOptions {
apiKey: string
baseUrl: string
projectName: string
workspace: string
sdkIntegration?: string
sessionId?: string
enableCallback?: boolean
}
function getOpikTracer(options: OpikTracerOptions): Tracer | undefined {
const SEMRESATTRS_PROJECT_NAME = 'openinference.project.name'
try {
const traceExporter = new ProtoOTLPTraceExporter({
url: `${options.baseUrl}/v1/private/otel/v1/traces`,
headers: {
Authorization: options.apiKey,
projectName: options.projectName,
'Comet-Workspace': options.workspace
}
})
const tracerProvider = new NodeTracerProvider({
resource: new Resource({
[ATTR_SERVICE_NAME]: options.projectName,
[ATTR_SERVICE_VERSION]: '1.0.0',
[SEMRESATTRS_PROJECT_NAME]: options.projectName
})
})
tracerProvider.addSpanProcessor(new SimpleSpanProcessor(traceExporter))
if (options.enableCallback) {
registerInstrumentations({
instrumentations: []
})
const lcInstrumentation = new LangChainInstrumentation()
lcInstrumentation.manuallyInstrument(CallbackManagerModule)
tracerProvider.register()
}
return tracerProvider.getTracer(`opik-tracer-${uuidv4().toString()}`)
} catch (err) {
if (process.env.DEBUG === 'true') console.error(`Error setting up Opik tracer: ${err.message}`)
return undefined
}
}
function tryGetJsonSpaces() {
try {
return parseInt(getEnvironmentVariable('LOG_JSON_SPACES') ?? '2')
@ -559,6 +603,28 @@ export const additionalCallbacks = async (nodeData: INodeData, options: ICommonO
const tracer: Tracer | undefined = getPhoenixTracer(phoenixOptions)
callbacks.push(tracer)
} else if (provider === 'opik') {
const opikApiKey = getCredentialParam('opikApiKey', credentialData, nodeData)
const opikEndpoint = getCredentialParam('opikUrl', credentialData, nodeData)
const opikWorkspace = getCredentialParam('opikWorkspace', credentialData, nodeData)
const opikProject = analytic[provider].opikProjectName as string
let opikOptions: OpikTracerOptions = {
apiKey: opikApiKey,
baseUrl: opikEndpoint ?? 'https://www.comet.com/opik/api',
projectName: opikProject ?? 'default',
workspace: opikWorkspace ?? 'default',
sdkIntegration: 'Flowise',
enableCallback: true
}
if (options.chatId) opikOptions.sessionId = options.chatId
if (nodeData?.inputs?.analytics?.opik) {
opikOptions = { ...opikOptions, ...nodeData?.inputs?.analytics?.opik }
}
const tracer: Tracer | undefined = getOpikTracer(opikOptions)
callbacks.push(tracer)
}
}
}
@ -673,6 +739,25 @@ export class AnalyticHandler {
const rootSpan: Span | undefined = undefined
this.handlers['phoenix'] = { client: phoenix, phoenixProject, rootSpan }
} else if (provider === 'opik') {
const opikApiKey = getCredentialParam('opikApiKey', credentialData, this.nodeData)
const opikEndpoint = getCredentialParam('opikUrl', credentialData, this.nodeData)
const opikWorkspace = getCredentialParam('opikWorkspace', credentialData, this.nodeData)
const opikProject = analytic[provider].opikProjectName as string
let opikOptions: OpikTracerOptions = {
apiKey: opikApiKey,
baseUrl: opikEndpoint ?? 'https://www.comet.com/opik/api',
projectName: opikProject ?? 'default',
workspace: opikWorkspace ?? 'default',
sdkIntegration: 'Flowise',
enableCallback: false
}
const opik: Tracer | undefined = getOpikTracer(opikOptions)
const rootSpan: Span | undefined = undefined
this.handlers['opik'] = { client: opik, opikProject, rootSpan }
}
}
}
@ -688,7 +773,8 @@ export class AnalyticHandler {
lunary: {},
langWatch: {},
arize: {},
phoenix: {}
phoenix: {},
opik: {}
}
if (Object.prototype.hasOwnProperty.call(this.handlers, 'langSmith')) {
@ -870,6 +956,40 @@ export class AnalyticHandler {
returnIds['phoenix'].chainSpan = chainSpanId
}
if (Object.prototype.hasOwnProperty.call(this.handlers, 'opik')) {
const tracer: Tracer | undefined = this.handlers['opik'].client
let rootSpan: Span | undefined = this.handlers['opik'].rootSpan
if (!parentIds || !Object.keys(parentIds).length) {
rootSpan = tracer ? tracer.startSpan('Flowise') : undefined
if (rootSpan) {
rootSpan.setAttribute('session.id', this.options.chatId)
rootSpan.setAttribute('openinference.span.kind', 'CHAIN')
rootSpan.setAttribute('input.value', input)
rootSpan.setAttribute('input.mime_type', 'text/plain')
rootSpan.setAttribute('output.value', '[Object]')
rootSpan.setAttribute('output.mime_type', 'text/plain')
rootSpan.setStatus({ code: SpanStatusCode.OK })
rootSpan.end()
}
this.handlers['opik'].rootSpan = rootSpan
}
const rootSpanContext = rootSpan
? opentelemetry.trace.setSpan(opentelemetry.context.active(), rootSpan as Span)
: opentelemetry.context.active()
const chainSpan = tracer?.startSpan(name, undefined, rootSpanContext)
if (chainSpan) {
chainSpan.setAttribute('openinference.span.kind', 'CHAIN')
chainSpan.setAttribute('input.value', JSON.stringify(input))
chainSpan.setAttribute('input.mime_type', 'application/json')
}
const chainSpanId: any = chainSpan?.spanContext().spanId
this.handlers['opik'].chainSpan = { [chainSpanId]: chainSpan }
returnIds['opik'].chainSpan = chainSpanId
}
return returnIds
}
@ -947,6 +1067,16 @@ export class AnalyticHandler {
chainSpan.end()
}
}
if (Object.prototype.hasOwnProperty.call(this.handlers, 'opik')) {
const chainSpan: Span | undefined = this.handlers['opik'].chainSpan[returnIds['opik'].chainSpan]
if (chainSpan) {
chainSpan.setAttribute('output.value', JSON.stringify(output))
chainSpan.setAttribute('output.mime_type', 'application/json')
chainSpan.setStatus({ code: SpanStatusCode.OK })
chainSpan.end()
}
}
}
async onChainError(returnIds: ICommonObject, error: string | object, shutdown = false) {
@ -1132,6 +1262,25 @@ export class AnalyticHandler {
returnIds['phoenix'].llmSpan = llmSpanId
}
if (Object.prototype.hasOwnProperty.call(this.handlers, 'opik')) {
const tracer: Tracer | undefined = this.handlers['opik'].client
const rootSpan: Span | undefined = this.handlers['opik'].rootSpan
const rootSpanContext = rootSpan
? opentelemetry.trace.setSpan(opentelemetry.context.active(), rootSpan as Span)
: opentelemetry.context.active()
const llmSpan = tracer?.startSpan(name, undefined, rootSpanContext)
if (llmSpan) {
llmSpan.setAttribute('openinference.span.kind', 'LLM')
llmSpan.setAttribute('input.value', JSON.stringify(input))
llmSpan.setAttribute('input.mime_type', 'application/json')
}
const llmSpanId: any = llmSpan?.spanContext().spanId
this.handlers['opik'].llmSpan = { [llmSpanId]: llmSpan }
returnIds['opik'].llmSpan = llmSpanId
}
return returnIds
}
@ -1197,6 +1346,16 @@ export class AnalyticHandler {
llmSpan.end()
}
}
if (Object.prototype.hasOwnProperty.call(this.handlers, 'opik')) {
const llmSpan: Span | undefined = this.handlers['opik'].llmSpan[returnIds['opik'].llmSpan]
if (llmSpan) {
llmSpan.setAttribute('output.value', JSON.stringify(output))
llmSpan.setAttribute('output.mime_type', 'application/json')
llmSpan.setStatus({ code: SpanStatusCode.OK })
llmSpan.end()
}
}
}
async onLLMError(returnIds: ICommonObject, error: string | object) {
@ -1261,6 +1420,16 @@ export class AnalyticHandler {
llmSpan.end()
}
}
if (Object.prototype.hasOwnProperty.call(this.handlers, 'opik')) {
const llmSpan: Span | undefined = this.handlers['opik'].llmSpan[returnIds['opik'].llmSpan]
if (llmSpan) {
llmSpan.setAttribute('error.value', JSON.stringify(error))
llmSpan.setAttribute('error.mime_type', 'application/json')
llmSpan.setStatus({ code: SpanStatusCode.ERROR, message: error.toString() })
llmSpan.end()
}
}
}
async onToolStart(name: string, input: string | object, parentIds: ICommonObject) {
@ -1270,7 +1439,8 @@ export class AnalyticHandler {
lunary: {},
langWatch: {},
arize: {},
phoenix: {}
phoenix: {},
opik: {}
}
if (Object.prototype.hasOwnProperty.call(this.handlers, 'langSmith')) {
@ -1369,6 +1539,25 @@ export class AnalyticHandler {
returnIds['phoenix'].toolSpan = toolSpanId
}
if (Object.prototype.hasOwnProperty.call(this.handlers, 'opik')) {
const tracer: Tracer | undefined = this.handlers['opik'].client
const rootSpan: Span | undefined = this.handlers['opik'].rootSpan
const rootSpanContext = rootSpan
? opentelemetry.trace.setSpan(opentelemetry.context.active(), rootSpan as Span)
: opentelemetry.context.active()
const toolSpan = tracer?.startSpan(name, undefined, rootSpanContext)
if (toolSpan) {
toolSpan.setAttribute('openinference.span.kind', 'TOOL')
toolSpan.setAttribute('input.value', JSON.stringify(input))
toolSpan.setAttribute('input.mime_type', 'application/json')
}
const toolSpanId: any = toolSpan?.spanContext().spanId
this.handlers['opik'].toolSpan = { [toolSpanId]: toolSpan }
returnIds['opik'].toolSpan = toolSpanId
}
return returnIds
}
@ -1434,6 +1623,16 @@ export class AnalyticHandler {
toolSpan.end()
}
}
if (Object.prototype.hasOwnProperty.call(this.handlers, 'opik')) {
const toolSpan: Span | undefined = this.handlers['opik'].toolSpan[returnIds['opik'].toolSpan]
if (toolSpan) {
toolSpan.setAttribute('output.value', JSON.stringify(output))
toolSpan.setAttribute('output.mime_type', 'application/json')
toolSpan.setStatus({ code: SpanStatusCode.OK })
toolSpan.end()
}
}
}
async onToolError(returnIds: ICommonObject, error: string | object) {
@ -1498,6 +1697,16 @@ export class AnalyticHandler {
toolSpan.end()
}
}
if (Object.prototype.hasOwnProperty.call(this.handlers, 'opik')) {
const toolSpan: Span | undefined = this.handlers['opik'].toolSpan[returnIds['opik'].toolSpan]
if (toolSpan) {
toolSpan.setAttribute('error.value', JSON.stringify(error))
toolSpan.setAttribute('error.mime_type', 'application/json')
toolSpan.setStatus({ code: SpanStatusCode.ERROR, message: error.toString() })
toolSpan.end()
}
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -30,6 +30,7 @@ import lunarySVG from '@/assets/images/lunary.svg'
import langwatchSVG from '@/assets/images/langwatch.svg'
import arizePNG from '@/assets/images/arize.png'
import phoenixPNG from '@/assets/images/phoenix.png'
import opikPNG from '@/assets/images/opik.png'
// store
import useNotifier from '@/utils/useNotifier'
@ -188,6 +189,33 @@ const analyticProviders = [
optional: true
}
]
},
{
label: 'Opik',
name: 'opik',
icon: opikPNG,
url: 'https://www.comet.com/opik',
inputs: [
{
label: 'Connect Credential',
name: 'credential',
type: 'credential',
credentialNames: ['opikApi']
},
{
label: 'Project Name',
name: 'opikProjectName',
type: 'string',
description: 'Name of your Opik project',
placeholder: 'default'
},
{
label: 'On/Off',
name: 'status',
type: 'boolean',
optional: true
}
]
}
]